everypixel-cli 0.1.0__py3-none-any.whl

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.
everypixel_cli/cli.py ADDED
@@ -0,0 +1,1520 @@
1
+ """Everypixel CLI Typer application.
2
+
3
+ This module defines the command tree and runtime option resolution.
4
+ HTTP logic lives in client.py; Pydantic payload validation lives in schemas.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ import traceback
11
+ import webbrowser
12
+ from collections.abc import Callable, Sequence
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from pathlib import Path
16
+ from typing import Annotated, Any, NoReturn, Optional
17
+
18
+ import typer
19
+ from click import BadParameter, ClickException
20
+ from typer.core import TyperGroup
21
+
22
+ try: # Typer 0.26+ vendors Click while older supported releases import it.
23
+ from typer._click.exceptions import ClickException as TyperClickException
24
+ except ImportError: # pragma: no cover - exercised with older Typer versions.
25
+ TyperClickException = ClickException # type: ignore[misc, assignment]
26
+
27
+ from .application import ApplicationServices, ExecutionOptions
28
+ from .application.serialization import serialize_operation_result
29
+ from .application.services import parse_generic_payload
30
+ from .client import APIClient
31
+ from .config import (
32
+ DEFAULT_BASE_URL,
33
+ ProfileConfig,
34
+ delete_credentials,
35
+ load_config,
36
+ resolved_settings,
37
+ safe_config_payload,
38
+ save_config,
39
+ save_credentials,
40
+ set_config_value,
41
+ use_profile,
42
+ )
43
+ from .errors import (
44
+ CLIError,
45
+ InternalCLIError,
46
+ ValidationCLIError,
47
+ mask_secret,
48
+ normalize_exception,
49
+ )
50
+ from .openapi import load_schema
51
+ from .output import emit_error, emit_human, emit_json
52
+
53
+
54
+ def json_output_requested(args: Sequence[str]) -> bool:
55
+ """Detect JSON mode before Click has parsed the full command line."""
56
+
57
+ for index, argument in enumerate(args):
58
+ if argument == "--":
59
+ break
60
+ if argument in {"--output-json", "-j", "--jq"}:
61
+ return True
62
+ if argument.startswith("--jq="):
63
+ return True
64
+ if (
65
+ argument == "--output"
66
+ and index + 1 < len(args)
67
+ and args[index + 1] == "json"
68
+ ):
69
+ return True
70
+ if argument == "--output=json":
71
+ return True
72
+ return False
73
+
74
+
75
+ class JSONErrorBoundaryGroup(TyperGroup):
76
+ """Render Click parsing failures through the shared JSON error contract."""
77
+
78
+ def main(
79
+ self,
80
+ args: Sequence[str] | None = None,
81
+ prog_name: str | None = None,
82
+ complete_var: str | None = None,
83
+ standalone_mode: bool = True,
84
+ windows_expand_args: bool = True,
85
+ **extra: Any,
86
+ ) -> Any:
87
+ raw_args = list(args) if args is not None else sys.argv[1:]
88
+ if not json_output_requested(raw_args):
89
+ return super().main(
90
+ args=args,
91
+ prog_name=prog_name,
92
+ complete_var=complete_var,
93
+ standalone_mode=standalone_mode,
94
+ windows_expand_args=windows_expand_args,
95
+ **extra,
96
+ )
97
+
98
+ try:
99
+ result = super().main(
100
+ args=args,
101
+ prog_name=prog_name,
102
+ complete_var=complete_var,
103
+ standalone_mode=False,
104
+ windows_expand_args=windows_expand_args,
105
+ **extra,
106
+ )
107
+ except (ClickException, TyperClickException) as exc:
108
+ emit_error(
109
+ ValidationCLIError(exc.format_message()),
110
+ output_json=True,
111
+ )
112
+ if not standalone_mode:
113
+ raise
114
+ raise SystemExit(exc.exit_code) from exc
115
+
116
+ if standalone_mode:
117
+ raise SystemExit(result if isinstance(result, int) else 0)
118
+ return result
119
+
120
+
121
+ app = typer.Typer(
122
+ cls=JSONErrorBoundaryGroup,
123
+ no_args_is_help=True,
124
+ context_settings={"help_option_names": ["-h", "--help"]},
125
+ )
126
+ auth_app = typer.Typer(no_args_is_help=True)
127
+ config_app = typer.Typer(no_args_is_help=True)
128
+ profile_app = typer.Typer(no_args_is_help=True)
129
+ image_app = typer.Typer(no_args_is_help=True)
130
+ video_app = typer.Typer(no_args_is_help=True)
131
+ lipsync_app = typer.Typer(no_args_is_help=True)
132
+ audio_app = typer.Typer(no_args_is_help=True)
133
+ docs_app = typer.Typer(no_args_is_help=True)
134
+ schema_app = typer.Typer(no_args_is_help=True)
135
+ mcp_app = typer.Typer(no_args_is_help=True)
136
+
137
+ app.add_typer(auth_app, name="auth")
138
+ app.add_typer(config_app, name="config")
139
+ config_app.add_typer(profile_app, name="profile")
140
+ app.add_typer(image_app, name="image")
141
+ app.add_typer(video_app, name="video")
142
+ app.add_typer(lipsync_app, name="lipsync")
143
+ app.add_typer(audio_app, name="audio")
144
+ app.add_typer(docs_app, name="docs")
145
+ app.add_typer(schema_app, name="schema")
146
+ app.add_typer(mcp_app, name="mcp")
147
+
148
+
149
+ class OutputMode(str, Enum):
150
+ human = "human"
151
+ json = "json"
152
+
153
+
154
+ def positive_float(value: float) -> float:
155
+ """Reject zero and negative wait controls at the CLI boundary."""
156
+
157
+ if value <= 0:
158
+ raise typer.BadParameter("must be greater than 0")
159
+ return value
160
+
161
+
162
+ WaitOption = Annotated[
163
+ Optional[bool], typer.Option("--wait/--no-wait", help="Override wait mode.")
164
+ ]
165
+ DownloadOption = Annotated[
166
+ Optional[Path],
167
+ typer.Option("--download", "-d", help="Download result URLs into a directory."),
168
+ ]
169
+ OutputJsonOption = Annotated[
170
+ bool, typer.Option("--output-json", "-j", help="Alias for --output json.")
171
+ ]
172
+ JqOption = Annotated[
173
+ Optional[str], typer.Option("--jq", help="Apply jq expression to JSON output.")
174
+ ]
175
+
176
+
177
+ @dataclass
178
+ class Runtime:
179
+ """Resolved settings for one CLI invocation."""
180
+
181
+ base_url: str
182
+ profile: str
183
+ client_id: str | None
184
+ client_secret: str | None
185
+ output_json: bool
186
+ jq: str | None
187
+ wait: bool
188
+ timeout: float
189
+ poll_interval: float
190
+ download: Path | None
191
+ no_color: bool
192
+ debug: bool
193
+ _services: ApplicationServices | None = field(
194
+ default=None,
195
+ init=False,
196
+ repr=False,
197
+ )
198
+
199
+ def client(self) -> APIClient:
200
+ """Create an API client with the current credentials."""
201
+
202
+ return APIClient(
203
+ base_url=self.base_url,
204
+ client_id=self.client_id,
205
+ client_secret=self.client_secret,
206
+ )
207
+
208
+ def services(self) -> ApplicationServices:
209
+ """Create the CLI-independent operation services for this invocation."""
210
+
211
+ if self._services is None:
212
+ self._services = ApplicationServices.with_client(self.client())
213
+ return self._services
214
+
215
+ def close(self) -> None:
216
+ """Close transport resources created for this invocation."""
217
+
218
+ if self._services is not None:
219
+ self._services.close()
220
+
221
+ def execution_options(self, *, wait: bool | None = None) -> ExecutionOptions:
222
+ """Build application execution options from the resolved CLI runtime."""
223
+
224
+ return ExecutionOptions(
225
+ wait=self.wait if wait is None else wait,
226
+ download_directory=self.download,
227
+ timeout=self.timeout,
228
+ poll_interval=self.poll_interval,
229
+ )
230
+
231
+
232
+ def get_runtime(ctx: typer.Context) -> Runtime:
233
+ """Read Runtime from Typer context or fail with a CLI error."""
234
+
235
+ if not isinstance(ctx.obj, Runtime):
236
+ raise CLIError("Runtime context is not initialized")
237
+ return ctx.obj
238
+
239
+
240
+ @app.callback()
241
+ def main(
242
+ ctx: typer.Context,
243
+ base_url: Annotated[
244
+ Optional[str],
245
+ typer.Option("--base-url", help="Override API base URL for this run."),
246
+ ] = None,
247
+ profile: Annotated[
248
+ Optional[str], typer.Option("--profile", help="Config profile name.")
249
+ ] = None,
250
+ dev: Annotated[bool, typer.Option("--dev", help="Use the dev profile.")] = False,
251
+ output: Annotated[
252
+ OutputMode, typer.Option("--output", help="Output mode: human or json.")
253
+ ] = OutputMode.human,
254
+ output_json: Annotated[
255
+ bool, typer.Option("--output-json", "-j", help="Alias for --output json.")
256
+ ] = False,
257
+ jq: Annotated[
258
+ Optional[str], typer.Option("--jq", help="Apply jq expression to JSON output.")
259
+ ] = None,
260
+ wait: Annotated[
261
+ bool, typer.Option("--wait/--no-wait", help="Wait for async task completion.")
262
+ ] = True,
263
+ timeout: Annotated[
264
+ float,
265
+ typer.Option(
266
+ "--timeout",
267
+ callback=positive_float,
268
+ help="Task wait timeout in seconds.",
269
+ ),
270
+ ] = 300.0,
271
+ poll_interval: Annotated[
272
+ float,
273
+ typer.Option(
274
+ "--poll-interval",
275
+ callback=positive_float,
276
+ help="Task polling interval in seconds.",
277
+ ),
278
+ ] = 2.0,
279
+ download: Annotated[
280
+ Optional[Path],
281
+ typer.Option("--download", "-d", help="Download result URLs into a directory."),
282
+ ] = None,
283
+ no_color: Annotated[
284
+ bool, typer.Option("--no-color", help="Disable colored human output.")
285
+ ] = False,
286
+ debug: Annotated[
287
+ bool,
288
+ typer.Option(
289
+ "--debug",
290
+ help="Include sanitized stack frames for unexpected internal errors.",
291
+ ),
292
+ ] = False,
293
+ ) -> None:
294
+ """Global callback that resolves settings shared by all commands."""
295
+
296
+ try:
297
+ settings = resolved_settings(profile=profile, dev=dev, base_url=base_url)
298
+ except Exception as exc:
299
+ error = error_with_debug_context(normalized_error(exc), exc, enabled=debug)
300
+ emit_error(
301
+ error,
302
+ output_json=output_json or output == OutputMode.json or jq is not None,
303
+ no_color=no_color,
304
+ )
305
+ raise typer.Exit(error.exit_code) from exc
306
+ runtime = Runtime(
307
+ base_url=settings["base_url"],
308
+ profile=settings["profile"],
309
+ client_id=settings["client_id"],
310
+ client_secret=settings["client_secret"],
311
+ output_json=output_json or output == OutputMode.json or jq is not None,
312
+ jq=jq,
313
+ wait=wait,
314
+ timeout=timeout,
315
+ poll_interval=poll_interval,
316
+ download=download,
317
+ no_color=no_color,
318
+ debug=debug,
319
+ )
320
+ ctx.obj = runtime
321
+ ctx.call_on_close(runtime.close)
322
+
323
+
324
+ def finish(ctx: typer.Context, payload: Any, *, title: str | None = None) -> None:
325
+ """Print payload in the selected output format."""
326
+
327
+ runtime = get_runtime(ctx)
328
+ if hasattr(payload, "saved_files") and hasattr(payload, "value"):
329
+ payload = serialize_operation_result(payload)
330
+ if runtime.output_json:
331
+ emit_json(payload, runtime.jq)
332
+ else:
333
+ emit_human(payload, title=title, no_color=runtime.no_color)
334
+
335
+
336
+ def normalized_error(exc: Exception) -> CLIError:
337
+ """Convert expected technical exceptions to application errors."""
338
+
339
+ if isinstance(exc, BadParameter):
340
+ return ValidationCLIError(str(exc))
341
+ return normalize_exception(exc)
342
+
343
+
344
+ def error_with_debug_context(
345
+ error: CLIError,
346
+ exc: Exception,
347
+ *,
348
+ enabled: bool,
349
+ ) -> CLIError:
350
+ """Attach stack locations without exception messages, locals, or payloads."""
351
+
352
+ if not enabled or not isinstance(error, InternalCLIError):
353
+ return error
354
+ frames = traceback.extract_tb(exc.__traceback__)[-20:]
355
+ return InternalCLIError(
356
+ error.message,
357
+ details={
358
+ "exception_type": type(exc).__name__,
359
+ "stack": [
360
+ {
361
+ "file": sanitized_stack_filename(frame.filename),
362
+ "line": frame.lineno,
363
+ "function": frame.name,
364
+ }
365
+ for frame in frames
366
+ ],
367
+ },
368
+ )
369
+
370
+
371
+ def sanitized_stack_filename(filename: str) -> str:
372
+ """Keep project-relative locations while hiding host-specific path prefixes."""
373
+
374
+ path = Path(filename)
375
+ try:
376
+ return str(path.resolve().relative_to(Path.cwd().resolve()))
377
+ except (OSError, ValueError):
378
+ return path.name
379
+
380
+
381
+ def stop_with_error(ctx: typer.Context, exc: Exception) -> NoReturn:
382
+ """Render one error through the sole CLI output boundary and exit."""
383
+
384
+ runtime = ctx.obj if isinstance(ctx.obj, Runtime) else None
385
+ error = error_with_debug_context(
386
+ normalized_error(exc),
387
+ exc,
388
+ enabled=bool(runtime and runtime.debug),
389
+ )
390
+ emit_error(
391
+ error,
392
+ output_json=bool(runtime and runtime.output_json),
393
+ no_color=bool(runtime and runtime.no_color),
394
+ )
395
+ raise typer.Exit(error.exit_code) from exc
396
+
397
+
398
+ def run_action(ctx: typer.Context, action: Callable[[], Any]) -> None:
399
+ """Run a command action and handle all expected application errors."""
400
+
401
+ try:
402
+ finish(ctx, action())
403
+ except typer.Exit:
404
+ raise
405
+ except (
406
+ Exception
407
+ ) as exc: # Boundary deliberately turns unexpected errors into clean CLI failures.
408
+ stop_with_error(ctx, exc)
409
+
410
+
411
+ def apply_common_options(
412
+ ctx: typer.Context,
413
+ *,
414
+ wait_option: bool | None = None,
415
+ download: Path | None = None,
416
+ output_json: bool = False,
417
+ jq_expr: str | None = None,
418
+ ) -> Runtime:
419
+ """Apply command-local common flags on top of global runtime settings."""
420
+
421
+ runtime = get_runtime(ctx)
422
+ if output_json or jq_expr is not None:
423
+ runtime.output_json = True
424
+ if jq_expr is not None:
425
+ runtime.jq = jq_expr
426
+ if download is not None:
427
+ runtime.download = download
428
+ if wait_option is not None:
429
+ runtime.wait = wait_option
430
+ return runtime
431
+
432
+
433
+ @auth_app.command("configure")
434
+ def auth_configure(
435
+ ctx: typer.Context,
436
+ client_id: Annotated[str, typer.Option("--client-id", prompt=True)],
437
+ client_secret: Annotated[
438
+ str, typer.Option("--client-secret", prompt=True, hide_input=True)
439
+ ],
440
+ base_url: Annotated[
441
+ Optional[str],
442
+ typer.Option("--base-url", help="Persist base URL for the current profile."),
443
+ ] = None,
444
+ ) -> None:
445
+ """Save Basic Auth credentials for the current profile."""
446
+
447
+ runtime = get_runtime(ctx)
448
+
449
+ def action() -> dict[str, Any]:
450
+ keyring_ok = save_credentials(runtime.profile, client_id, client_secret)
451
+ if base_url:
452
+ set_config_value(runtime.profile, "base_url", base_url)
453
+ return {
454
+ "profile": runtime.profile,
455
+ "client_id": client_id,
456
+ "base_url": base_url or runtime.base_url,
457
+ "storage": "keyring" if keyring_ok else "config_file",
458
+ }
459
+
460
+ run_action(ctx, action)
461
+
462
+
463
+ @auth_app.command("check")
464
+ def auth_check(ctx: typer.Context) -> None:
465
+ """Check that stored credentials are accepted by the API."""
466
+
467
+ runtime = get_runtime(ctx)
468
+
469
+ def action() -> dict[str, Any]:
470
+ payload = serialize_operation_result(runtime.services().check_auth())
471
+ return {
472
+ **payload,
473
+ "profile": runtime.profile,
474
+ "base_url": runtime.base_url,
475
+ }
476
+
477
+ run_action(ctx, action)
478
+
479
+
480
+ @auth_app.command("whoami")
481
+ def auth_whoami(ctx: typer.Context) -> None:
482
+ """Show the active profile and masked credentials."""
483
+
484
+ runtime = get_runtime(ctx)
485
+ run_action(
486
+ ctx,
487
+ lambda: {
488
+ "profile": runtime.profile,
489
+ "base_url": runtime.base_url,
490
+ "client_id": runtime.client_id,
491
+ "client_secret": mask_secret(runtime.client_secret),
492
+ },
493
+ )
494
+
495
+
496
+ @auth_app.command("logout")
497
+ def auth_logout(ctx: typer.Context) -> None:
498
+ """Remove credentials from the current profile."""
499
+
500
+ runtime = get_runtime(ctx)
501
+
502
+ def action() -> dict[str, Any]:
503
+ delete_credentials(runtime.profile)
504
+ return {"status": "ok", "profile": runtime.profile}
505
+
506
+ run_action(ctx, action)
507
+
508
+
509
+ @config_app.command("set")
510
+ def config_set(ctx: typer.Context, key: str, value: str) -> None:
511
+ """Set a supported option for the current profile."""
512
+
513
+ runtime = get_runtime(ctx)
514
+
515
+ def action() -> dict[str, Any]:
516
+ set_config_value(runtime.profile, key, value)
517
+ return {"status": "ok", "profile": runtime.profile, key: value}
518
+
519
+ run_action(ctx, action)
520
+
521
+
522
+ @config_app.command("get")
523
+ def config_get(ctx: typer.Context, key: str) -> None:
524
+ """Show a setting from the current profile."""
525
+
526
+ runtime = get_runtime(ctx)
527
+
528
+ def action() -> dict[str, Any]:
529
+ if key != "base_url":
530
+ raise ValidationCLIError("Only base_url is supported")
531
+ return {"profile": runtime.profile, key: runtime.base_url}
532
+
533
+ run_action(ctx, action)
534
+
535
+
536
+ @config_app.command("list")
537
+ def config_list(ctx: typer.Context) -> None:
538
+ """Show local configuration without exposing secrets."""
539
+
540
+ run_action(ctx, lambda: safe_config_payload(load_config()))
541
+
542
+
543
+ @profile_app.command("create")
544
+ def profile_create(
545
+ ctx: typer.Context,
546
+ name: str,
547
+ base_url: Annotated[str, typer.Option("--base-url")] = DEFAULT_BASE_URL,
548
+ ) -> None:
549
+ run_action(ctx, lambda: create_profile_payload(name, base_url))
550
+
551
+
552
+ def create_profile_payload(name: str, base_url: str) -> dict[str, Any]:
553
+ """Create or update a profile and return a result payload."""
554
+
555
+ config = load_config()
556
+ config.profiles[name] = config.profiles.get(name) or ProfileConfig()
557
+ config.profiles[name].base_url = base_url
558
+ save_config(config)
559
+ return {"status": "ok", "profile": name, "base_url": base_url}
560
+
561
+
562
+ @profile_app.command("use")
563
+ def profile_use(ctx: typer.Context, name: str) -> None:
564
+ """Make a profile active for future runs."""
565
+
566
+ def action() -> dict[str, Any]:
567
+ use_profile(name)
568
+ return {"status": "ok", "profile": name}
569
+
570
+ run_action(ctx, action)
571
+
572
+
573
+ @image_app.command("generate")
574
+ def image_generate(
575
+ ctx: typer.Context,
576
+ prompt: str,
577
+ model: Annotated[str, typer.Option("--model")] = "zimage",
578
+ size: Annotated[str, typer.Option("--size")] = "square",
579
+ style: Annotated[Optional[str], typer.Option("--style")] = None,
580
+ image: Annotated[Optional[str], typer.Option("--image")] = None,
581
+ resolution: Annotated[Optional[str], typer.Option("--resolution")] = None,
582
+ seed: Annotated[int, typer.Option("--seed")] = -1,
583
+ callback_url: Annotated[Optional[str], typer.Option("--callback-url")] = None,
584
+ wait_option: WaitOption = None,
585
+ download: DownloadOption = None,
586
+ output_json: OutputJsonOption = False,
587
+ jq_expr: JqOption = None,
588
+ ) -> None:
589
+ """Start image generation."""
590
+
591
+ runtime = apply_common_options(
592
+ ctx,
593
+ wait_option=wait_option,
594
+ download=download,
595
+ output_json=output_json,
596
+ jq_expr=jq_expr,
597
+ )
598
+ run_action(
599
+ ctx,
600
+ lambda: runtime.services().execute_image_generate(
601
+ prompt=prompt,
602
+ model=model,
603
+ image_size=size,
604
+ style=style,
605
+ image=image,
606
+ resolution=resolution,
607
+ seed=seed,
608
+ callback_url=callback_url,
609
+ execution=runtime.execution_options(),
610
+ ),
611
+ )
612
+
613
+
614
+ @image_app.command("edit")
615
+ def image_edit(
616
+ ctx: typer.Context,
617
+ prompt: str,
618
+ image: Annotated[
619
+ list[str],
620
+ typer.Option("--image", help="Image URL or local path. Can be repeated."),
621
+ ],
622
+ model: Annotated[str, typer.Option("--model")] = "flux2",
623
+ size: Annotated[Optional[str], typer.Option("--size")] = None,
624
+ resolution: Annotated[Optional[str], typer.Option("--resolution")] = None,
625
+ seed: Annotated[int, typer.Option("--seed")] = -1,
626
+ callback_url: Annotated[Optional[str], typer.Option("--callback-url")] = None,
627
+ wait_option: WaitOption = None,
628
+ download: DownloadOption = None,
629
+ output_json: OutputJsonOption = False,
630
+ jq_expr: JqOption = None,
631
+ ) -> None:
632
+ """Start image editing."""
633
+
634
+ runtime = apply_common_options(
635
+ ctx,
636
+ wait_option=wait_option,
637
+ download=download,
638
+ output_json=output_json,
639
+ jq_expr=jq_expr,
640
+ )
641
+ run_action(
642
+ ctx,
643
+ lambda: runtime.services().execute_image_edit(
644
+ prompt=prompt,
645
+ images=image,
646
+ model=model,
647
+ image_size=size,
648
+ resolution=resolution,
649
+ seed=seed,
650
+ callback_url=callback_url,
651
+ execution=runtime.execution_options(),
652
+ ),
653
+ )
654
+
655
+
656
+ @image_app.command("upscale")
657
+ def image_upscale(
658
+ ctx: typer.Context,
659
+ image: Annotated[Optional[str], typer.Option("--image")] = None,
660
+ task_id: Annotated[Optional[str], typer.Option("--task-id")] = None,
661
+ model: Annotated[str, typer.Option("--model")] = "seedvr2",
662
+ callback_url: Annotated[Optional[str], typer.Option("--callback-url")] = None,
663
+ wait_option: WaitOption = None,
664
+ download: DownloadOption = None,
665
+ output_json: OutputJsonOption = False,
666
+ jq_expr: JqOption = None,
667
+ ) -> None:
668
+ """Start image upscale from a URL/file or task_id."""
669
+
670
+ runtime = apply_common_options(
671
+ ctx,
672
+ wait_option=wait_option,
673
+ download=download,
674
+ output_json=output_json,
675
+ jq_expr=jq_expr,
676
+ )
677
+ run_action(
678
+ ctx,
679
+ lambda: runtime.services().execute_image_upscale(
680
+ image=image,
681
+ task_id=task_id,
682
+ model=model,
683
+ callback_url=callback_url,
684
+ execution=runtime.execution_options(),
685
+ ),
686
+ )
687
+
688
+
689
+ @image_app.command("angles")
690
+ def image_angles(
691
+ ctx: typer.Context,
692
+ image: Annotated[str, typer.Option("--image")],
693
+ azimuth: str = "front",
694
+ elevation: str = "eye_level",
695
+ distance: str = "medium",
696
+ prompt: Optional[str] = None,
697
+ wait_option: WaitOption = None,
698
+ download: DownloadOption = None,
699
+ output_json: OutputJsonOption = False,
700
+ jq_expr: JqOption = None,
701
+ ) -> None:
702
+ """Change image angle through a dedicated endpoint."""
703
+
704
+ runtime = apply_common_options(
705
+ ctx,
706
+ wait_option=wait_option,
707
+ download=download,
708
+ output_json=output_json,
709
+ jq_expr=jq_expr,
710
+ )
711
+ run_action(
712
+ ctx,
713
+ lambda: runtime.services().execute_image_angles(
714
+ image=image,
715
+ azimuth=azimuth,
716
+ elevation=elevation,
717
+ distance=distance,
718
+ prompt=prompt,
719
+ execution=runtime.execution_options(),
720
+ ),
721
+ )
722
+
723
+
724
+ @image_app.command("colors")
725
+ def image_colors(
726
+ ctx: typer.Context,
727
+ image: Annotated[str, typer.Option("--image")],
728
+ reference: Annotated[str, typer.Option("--reference")],
729
+ wait_option: WaitOption = None,
730
+ download: DownloadOption = None,
731
+ output_json: OutputJsonOption = False,
732
+ jq_expr: JqOption = None,
733
+ ) -> None:
734
+ """Transfer reference colors to the source image."""
735
+
736
+ runtime = apply_common_options(
737
+ ctx,
738
+ wait_option=wait_option,
739
+ download=download,
740
+ output_json=output_json,
741
+ jq_expr=jq_expr,
742
+ )
743
+ run_action(
744
+ ctx,
745
+ lambda: runtime.services().execute_image_colors(
746
+ image=image,
747
+ reference=reference,
748
+ execution=runtime.execution_options(),
749
+ ),
750
+ )
751
+
752
+
753
+ @video_app.command("generate")
754
+ def video_generate(
755
+ ctx: typer.Context,
756
+ prompt: str,
757
+ model: str = "ltx23",
758
+ duration: Optional[int] = None,
759
+ resolution: Optional[str] = None,
760
+ aspect_ratio: Annotated[str, typer.Option("--aspect-ratio")] = "16:9",
761
+ lora_high_url: Annotated[Optional[str], typer.Option("--lora-high-url")] = None,
762
+ lora_low_url: Annotated[Optional[str], typer.Option("--lora-low-url")] = None,
763
+ reference_image: Annotated[
764
+ list[str],
765
+ typer.Option(
766
+ "--reference-image",
767
+ help="Wan or Veo reference image URL/local path. Can be repeated.",
768
+ ),
769
+ ] = [],
770
+ reference_video: Annotated[
771
+ list[str],
772
+ typer.Option(
773
+ "--reference-video",
774
+ help="Wan reference video URL or local path. Can be repeated.",
775
+ ),
776
+ ] = [],
777
+ seed: Optional[int] = None,
778
+ generate_audio: Annotated[
779
+ Optional[bool], typer.Option("--generate-audio/--no-generate-audio")
780
+ ] = None,
781
+ callback_url: Annotated[Optional[str], typer.Option("--callback-url")] = None,
782
+ wait_option: WaitOption = None,
783
+ download: DownloadOption = None,
784
+ output_json: OutputJsonOption = False,
785
+ jq_expr: JqOption = None,
786
+ ) -> None:
787
+ """Start text-to-video generation."""
788
+
789
+ runtime = apply_common_options(
790
+ ctx,
791
+ wait_option=wait_option,
792
+ download=download,
793
+ output_json=output_json,
794
+ jq_expr=jq_expr,
795
+ )
796
+ run_action(
797
+ ctx,
798
+ lambda: runtime.services().execute_video_generate(
799
+ prompt=prompt,
800
+ model=model,
801
+ duration=duration,
802
+ resolution=resolution,
803
+ aspect_ratio=aspect_ratio,
804
+ lora_high_url=lora_high_url,
805
+ lora_low_url=lora_low_url,
806
+ reference_images=reference_image,
807
+ reference_videos=reference_video,
808
+ seed=seed,
809
+ generate_audio=generate_audio,
810
+ callback_url=callback_url,
811
+ execution=runtime.execution_options(),
812
+ ),
813
+ )
814
+
815
+
816
+ @video_app.command("edit")
817
+ def video_edit(
818
+ ctx: typer.Context,
819
+ prompt: Annotated[
820
+ Optional[str], typer.Argument(help="Edit prompt; optional for Aleph 2.")
821
+ ] = None,
822
+ model: Annotated[str, typer.Option("--model")] = "seedance2",
823
+ image: Annotated[
824
+ list[str], typer.Option("--image", help="Reference image URL or local path.")
825
+ ] = [],
826
+ video: Annotated[
827
+ Optional[str],
828
+ typer.Option("--video", help="Reference video URL or local path."),
829
+ ] = None,
830
+ audio: Annotated[
831
+ Optional[str],
832
+ typer.Option("--audio", help="Reference audio URL or local path."),
833
+ ] = None,
834
+ duration: Optional[int] = None,
835
+ resolution: Optional[str] = None,
836
+ aspect_ratio: Annotated[Optional[str], typer.Option("--aspect-ratio")] = None,
837
+ seed: Annotated[Optional[int], typer.Option("--seed")] = None,
838
+ keyframe: Annotated[
839
+ list[str],
840
+ typer.Option(
841
+ "--keyframe",
842
+ help=(
843
+ "Aleph 2 keyframe JSON with image_url and seconds or at. "
844
+ "Can be repeated."
845
+ ),
846
+ ),
847
+ ] = [],
848
+ public_figure_threshold: Annotated[
849
+ Optional[str], typer.Option("--public-figure-threshold")
850
+ ] = None,
851
+ generate_audio: Annotated[
852
+ Optional[bool], typer.Option("--generate-audio/--no-generate-audio")
853
+ ] = None,
854
+ callback_url: Annotated[Optional[str], typer.Option("--callback-url")] = None,
855
+ wait_option: WaitOption = None,
856
+ download: DownloadOption = None,
857
+ output_json: OutputJsonOption = False,
858
+ jq_expr: JqOption = None,
859
+ ) -> None:
860
+ """Edit video with Seedance, Kling, Wan, or Aleph media."""
861
+
862
+ runtime = apply_common_options(
863
+ ctx,
864
+ wait_option=wait_option,
865
+ download=download,
866
+ output_json=output_json,
867
+ jq_expr=jq_expr,
868
+ )
869
+ run_action(
870
+ ctx,
871
+ lambda: runtime.services().execute_video_edit(
872
+ prompt=prompt,
873
+ model=model,
874
+ images=image,
875
+ video=video,
876
+ audio=audio,
877
+ duration=duration,
878
+ resolution=resolution,
879
+ aspect_ratio=aspect_ratio,
880
+ seed=seed,
881
+ keyframes=keyframe,
882
+ public_figure_threshold=public_figure_threshold,
883
+ generate_audio=generate_audio,
884
+ callback_url=callback_url,
885
+ execution=runtime.execution_options(),
886
+ ),
887
+ )
888
+
889
+
890
+ @video_app.command("from-image")
891
+ def video_from_image(
892
+ ctx: typer.Context,
893
+ prompt: str,
894
+ image: Annotated[str, typer.Option("--image")],
895
+ model: str = "ltx23",
896
+ duration: Optional[int] = None,
897
+ resolution: Optional[str] = None,
898
+ aspect_ratio: Annotated[str, typer.Option("--aspect-ratio")] = "16:9",
899
+ seed: Optional[int] = None,
900
+ callback_url: Annotated[Optional[str], typer.Option("--callback-url")] = None,
901
+ wait_option: WaitOption = None,
902
+ download: DownloadOption = None,
903
+ output_json: OutputJsonOption = False,
904
+ jq_expr: JqOption = None,
905
+ ) -> None:
906
+ """Start image-to-video generation."""
907
+
908
+ runtime = apply_common_options(
909
+ ctx,
910
+ wait_option=wait_option,
911
+ download=download,
912
+ output_json=output_json,
913
+ jq_expr=jq_expr,
914
+ )
915
+ run_action(
916
+ ctx,
917
+ lambda: runtime.services().execute_video_generate(
918
+ prompt=prompt,
919
+ model=model,
920
+ duration=duration,
921
+ resolution=resolution,
922
+ aspect_ratio=aspect_ratio,
923
+ image=image,
924
+ seed=seed,
925
+ callback_url=callback_url,
926
+ execution=runtime.execution_options(),
927
+ ),
928
+ )
929
+
930
+
931
+ @video_app.command("first-last")
932
+ def video_first_last(
933
+ ctx: typer.Context,
934
+ prompt: str,
935
+ image: Annotated[str, typer.Option("--image")],
936
+ last_image: Annotated[str, typer.Option("--last-image")],
937
+ model: str = "ltx23",
938
+ duration: Optional[int] = None,
939
+ resolution: Optional[str] = None,
940
+ aspect_ratio: Annotated[str, typer.Option("--aspect-ratio")] = "16:9",
941
+ seed: Optional[int] = None,
942
+ callback_url: Annotated[Optional[str], typer.Option("--callback-url")] = None,
943
+ wait_option: WaitOption = None,
944
+ download: DownloadOption = None,
945
+ output_json: OutputJsonOption = False,
946
+ jq_expr: JqOption = None,
947
+ ) -> None:
948
+ """Generate video between first and last frames."""
949
+
950
+ runtime = apply_common_options(
951
+ ctx,
952
+ wait_option=wait_option,
953
+ download=download,
954
+ output_json=output_json,
955
+ jq_expr=jq_expr,
956
+ )
957
+ run_action(
958
+ ctx,
959
+ lambda: runtime.services().execute_video_generate(
960
+ prompt=prompt,
961
+ model=model,
962
+ duration=duration,
963
+ resolution=resolution,
964
+ aspect_ratio=aspect_ratio,
965
+ image=image,
966
+ last_image=last_image,
967
+ seed=seed,
968
+ callback_url=callback_url,
969
+ execution=runtime.execution_options(),
970
+ ),
971
+ )
972
+
973
+
974
+ @video_app.command("upscale")
975
+ def video_upscale(
976
+ ctx: typer.Context,
977
+ video: Annotated[Optional[str], typer.Option("--video")] = None,
978
+ task_id: Annotated[Optional[str], typer.Option("--task-id")] = None,
979
+ resolution: Annotated[
980
+ str,
981
+ typer.Option(
982
+ "--resolution",
983
+ help="Output resolution: 720p, 1080p, or 1440p (up to 20s at 1440p).",
984
+ ),
985
+ ] = "1080p",
986
+ wait_option: WaitOption = None,
987
+ download: DownloadOption = None,
988
+ output_json: OutputJsonOption = False,
989
+ jq_expr: JqOption = None,
990
+ ) -> None:
991
+ """Start video upscale from a URL/file or task_id.
992
+
993
+ Source videos longer than 20 seconds cannot be upscaled to 1440p.
994
+ """
995
+
996
+ runtime = apply_common_options(
997
+ ctx,
998
+ wait_option=wait_option,
999
+ download=download,
1000
+ output_json=output_json,
1001
+ jq_expr=jq_expr,
1002
+ )
1003
+ run_action(
1004
+ ctx,
1005
+ lambda: runtime.services().execute_video_upscale(
1006
+ video=video,
1007
+ task_id=task_id,
1008
+ resolution=resolution,
1009
+ execution=runtime.execution_options(),
1010
+ ),
1011
+ )
1012
+
1013
+
1014
+ @lipsync_app.command("video")
1015
+ def lipsync_video(
1016
+ ctx: typer.Context,
1017
+ video: Annotated[str, typer.Option("--video")],
1018
+ audio: Annotated[str, typer.Option("--audio")],
1019
+ resolution: str = "480p",
1020
+ prompt: Optional[str] = None,
1021
+ seed: int = -1,
1022
+ callback_url: Annotated[Optional[str], typer.Option("--callback-url")] = None,
1023
+ wait_option: WaitOption = None,
1024
+ download: DownloadOption = None,
1025
+ output_json: OutputJsonOption = False,
1026
+ jq_expr: JqOption = None,
1027
+ ) -> None:
1028
+ """Synchronize lips in a video using an audio track."""
1029
+
1030
+ runtime = apply_common_options(
1031
+ ctx,
1032
+ wait_option=wait_option,
1033
+ download=download,
1034
+ output_json=output_json,
1035
+ jq_expr=jq_expr,
1036
+ )
1037
+ run_action(
1038
+ ctx,
1039
+ lambda: runtime.services().execute_lipsync_video(
1040
+ video=video,
1041
+ audio=audio,
1042
+ resolution=resolution,
1043
+ prompt=prompt,
1044
+ seed=seed,
1045
+ callback_url=callback_url,
1046
+ execution=runtime.execution_options(),
1047
+ ),
1048
+ )
1049
+
1050
+
1051
+ @lipsync_app.command("image")
1052
+ def lipsync_image(
1053
+ ctx: typer.Context,
1054
+ image: Annotated[str, typer.Option("--image")],
1055
+ audio: Annotated[str, typer.Option("--audio")],
1056
+ model: str = "inftalk",
1057
+ resolution: str = "480p",
1058
+ prompt: Optional[str] = None,
1059
+ seed: int = -1,
1060
+ callback_url: Annotated[Optional[str], typer.Option("--callback-url")] = None,
1061
+ wait_option: WaitOption = None,
1062
+ download: DownloadOption = None,
1063
+ output_json: OutputJsonOption = False,
1064
+ jq_expr: JqOption = None,
1065
+ ) -> None:
1066
+ """Create a lipsync video from a still image and audio."""
1067
+
1068
+ runtime = apply_common_options(
1069
+ ctx,
1070
+ wait_option=wait_option,
1071
+ download=download,
1072
+ output_json=output_json,
1073
+ jq_expr=jq_expr,
1074
+ )
1075
+ run_action(
1076
+ ctx,
1077
+ lambda: runtime.services().execute_lipsync_image(
1078
+ image=image,
1079
+ audio=audio,
1080
+ model=model,
1081
+ resolution=resolution,
1082
+ prompt=prompt,
1083
+ seed=seed,
1084
+ callback_url=callback_url,
1085
+ execution=runtime.execution_options(),
1086
+ ),
1087
+ )
1088
+
1089
+
1090
+ @audio_app.command("transcribe")
1091
+ def audio_transcribe(
1092
+ ctx: typer.Context,
1093
+ audio: Annotated[str, typer.Option("--audio")],
1094
+ language: str = "auto",
1095
+ hints: str = "",
1096
+ denoise: bool = True,
1097
+ wait_option: WaitOption = None,
1098
+ download: DownloadOption = None,
1099
+ output_json: OutputJsonOption = False,
1100
+ jq_expr: JqOption = None,
1101
+ ) -> None:
1102
+ """Start speech transcription for an audio file."""
1103
+
1104
+ runtime = apply_common_options(
1105
+ ctx,
1106
+ wait_option=wait_option,
1107
+ download=download,
1108
+ output_json=output_json,
1109
+ jq_expr=jq_expr,
1110
+ )
1111
+ run_action(
1112
+ ctx,
1113
+ lambda: runtime.services().execute_audio_transcribe(
1114
+ audio=audio,
1115
+ language=language,
1116
+ hints=hints,
1117
+ denoise=denoise,
1118
+ execution=runtime.execution_options(),
1119
+ ),
1120
+ )
1121
+
1122
+
1123
+ @audio_app.command("tts-create")
1124
+ def tts_create(
1125
+ ctx: typer.Context,
1126
+ text: Annotated[Optional[str], typer.Option("--text")] = None,
1127
+ text_file: Annotated[Optional[Path], typer.Option("--text-file")] = None,
1128
+ speaker: str = "Ryan",
1129
+ style: str = "Auto",
1130
+ language: str = "Auto",
1131
+ prompt: str = "",
1132
+ seed: int = -1,
1133
+ wait_option: WaitOption = None,
1134
+ download: DownloadOption = None,
1135
+ output_json: OutputJsonOption = False,
1136
+ jq_expr: JqOption = None,
1137
+ ) -> None:
1138
+ """Create speech from text using a selected speaker."""
1139
+
1140
+ runtime = apply_common_options(
1141
+ ctx,
1142
+ wait_option=wait_option,
1143
+ download=download,
1144
+ output_json=output_json,
1145
+ jq_expr=jq_expr,
1146
+ )
1147
+ run_action(
1148
+ ctx,
1149
+ lambda: runtime.services().execute_tts_create(
1150
+ text=text,
1151
+ text_file=text_file,
1152
+ speaker=speaker,
1153
+ style=style,
1154
+ language=language,
1155
+ prompt=prompt,
1156
+ seed=seed,
1157
+ execution=runtime.execution_options(),
1158
+ ),
1159
+ )
1160
+
1161
+
1162
+ @audio_app.command("tts-clone")
1163
+ def tts_clone(
1164
+ ctx: typer.Context,
1165
+ audio: Annotated[str, typer.Option("--audio")],
1166
+ text: Annotated[Optional[str], typer.Option("--text")] = None,
1167
+ text_file: Annotated[Optional[Path], typer.Option("--text-file")] = None,
1168
+ language: str = "Auto",
1169
+ seed: int = -1,
1170
+ wait_option: WaitOption = None,
1171
+ download: DownloadOption = None,
1172
+ output_json: OutputJsonOption = False,
1173
+ jq_expr: JqOption = None,
1174
+ ) -> None:
1175
+ """Create speech from text using a cloned voice sample."""
1176
+
1177
+ runtime = apply_common_options(
1178
+ ctx,
1179
+ wait_option=wait_option,
1180
+ download=download,
1181
+ output_json=output_json,
1182
+ jq_expr=jq_expr,
1183
+ )
1184
+ run_action(
1185
+ ctx,
1186
+ lambda: runtime.services().execute_tts_clone(
1187
+ audio=audio,
1188
+ text=text,
1189
+ text_file=text_file,
1190
+ language=language,
1191
+ seed=seed,
1192
+ execution=runtime.execution_options(),
1193
+ ),
1194
+ )
1195
+
1196
+
1197
+ @audio_app.command("tts-voice")
1198
+ def tts_voice(
1199
+ ctx: typer.Context,
1200
+ text: Annotated[Optional[str], typer.Option("--text")] = None,
1201
+ text_file: Annotated[Optional[Path], typer.Option("--text-file")] = None,
1202
+ character: str = "Female",
1203
+ style: str = "Auto",
1204
+ language: str = "Auto",
1205
+ prompt: str = "",
1206
+ seed: int = -1,
1207
+ wait_option: WaitOption = None,
1208
+ download: DownloadOption = None,
1209
+ output_json: OutputJsonOption = False,
1210
+ jq_expr: JqOption = None,
1211
+ ) -> None:
1212
+ """Create speech from text using a character voice."""
1213
+
1214
+ runtime = apply_common_options(
1215
+ ctx,
1216
+ wait_option=wait_option,
1217
+ download=download,
1218
+ output_json=output_json,
1219
+ jq_expr=jq_expr,
1220
+ )
1221
+ run_action(
1222
+ ctx,
1223
+ lambda: runtime.services().execute_tts_voice(
1224
+ text=text,
1225
+ text_file=text_file,
1226
+ character=character,
1227
+ style=style,
1228
+ language=language,
1229
+ prompt=prompt,
1230
+ seed=seed,
1231
+ execution=runtime.execution_options(),
1232
+ ),
1233
+ )
1234
+
1235
+
1236
+ @app.command("status")
1237
+ def status(
1238
+ ctx: typer.Context,
1239
+ task_id: str,
1240
+ download: DownloadOption = None,
1241
+ output_json: OutputJsonOption = False,
1242
+ jq_expr: JqOption = None,
1243
+ ) -> None:
1244
+ """Show task status once; --download waits for completion first."""
1245
+
1246
+ runtime = apply_common_options(
1247
+ ctx, download=download, output_json=output_json, jq_expr=jq_expr
1248
+ )
1249
+
1250
+ def action() -> Any:
1251
+ return runtime.services().get_task_status(
1252
+ task_id=task_id,
1253
+ execution=runtime.execution_options(wait=False),
1254
+ )
1255
+
1256
+ run_action(ctx, action)
1257
+
1258
+
1259
+ @app.command("wait")
1260
+ def wait_command(
1261
+ ctx: typer.Context,
1262
+ task_id: str,
1263
+ download: DownloadOption = None,
1264
+ output_json: OutputJsonOption = False,
1265
+ jq_expr: JqOption = None,
1266
+ ) -> None:
1267
+ """Wait for an existing async task to finish."""
1268
+
1269
+ runtime = apply_common_options(
1270
+ ctx, download=download, output_json=output_json, jq_expr=jq_expr
1271
+ )
1272
+ run_action(
1273
+ ctx,
1274
+ lambda: runtime.services().wait_for_task(
1275
+ task_id=task_id,
1276
+ execution=runtime.execution_options(wait=True),
1277
+ ),
1278
+ )
1279
+
1280
+
1281
+ @app.command("keywords")
1282
+ def keywords(
1283
+ ctx: typer.Context,
1284
+ image: Annotated[str, typer.Option("--image")],
1285
+ lang: str = "en",
1286
+ num_keywords: Optional[int] = None,
1287
+ colors: bool = False,
1288
+ output_json: OutputJsonOption = False,
1289
+ jq_expr: JqOption = None,
1290
+ ) -> None:
1291
+ """Extract keywords from an image."""
1292
+
1293
+ runtime = apply_common_options(ctx, output_json=output_json, jq_expr=jq_expr)
1294
+ run_action(
1295
+ ctx,
1296
+ lambda: runtime.services().execute_keywords(
1297
+ image=image,
1298
+ lang=lang,
1299
+ num_keywords=num_keywords,
1300
+ colors=colors,
1301
+ ),
1302
+ )
1303
+
1304
+
1305
+ @app.command("quality")
1306
+ def quality(
1307
+ ctx: typer.Context,
1308
+ image: Annotated[str, typer.Option("--image")],
1309
+ output_json: OutputJsonOption = False,
1310
+ jq_expr: JqOption = None,
1311
+ ) -> None:
1312
+ """Score technical image quality."""
1313
+
1314
+ runtime = apply_common_options(ctx, output_json=output_json, jq_expr=jq_expr)
1315
+ run_action(
1316
+ ctx,
1317
+ lambda: runtime.services().execute_quality(image=image),
1318
+ )
1319
+
1320
+
1321
+ @app.command("quality-ugc")
1322
+ def quality_ugc(
1323
+ ctx: typer.Context,
1324
+ image: Annotated[str, typer.Option("--image")],
1325
+ output_json: OutputJsonOption = False,
1326
+ jq_expr: JqOption = None,
1327
+ ) -> None:
1328
+ """Score UGC image quality."""
1329
+
1330
+ runtime = apply_common_options(ctx, output_json=output_json, jq_expr=jq_expr)
1331
+ run_action(
1332
+ ctx,
1333
+ lambda: runtime.services().execute_quality_ugc(image=image),
1334
+ )
1335
+
1336
+
1337
+ @app.command("faces")
1338
+ def faces(
1339
+ ctx: typer.Context,
1340
+ image: Annotated[str, typer.Option("--image")],
1341
+ output_json: OutputJsonOption = False,
1342
+ jq_expr: JqOption = None,
1343
+ ) -> None:
1344
+ """Detect faces in an image."""
1345
+
1346
+ runtime = apply_common_options(ctx, output_json=output_json, jq_expr=jq_expr)
1347
+ run_action(ctx, lambda: runtime.services().execute_faces(image=image))
1348
+
1349
+
1350
+ @app.command("captioning")
1351
+ def captioning(
1352
+ ctx: typer.Context,
1353
+ image: Annotated[str, typer.Option("--image")],
1354
+ output_json: OutputJsonOption = False,
1355
+ jq_expr: JqOption = None,
1356
+ ) -> None:
1357
+ """Generate an image caption."""
1358
+
1359
+ runtime = apply_common_options(ctx, output_json=output_json, jq_expr=jq_expr)
1360
+ run_action(
1361
+ ctx,
1362
+ lambda: runtime.services().execute_captioning(image=image),
1363
+ )
1364
+
1365
+
1366
+ @app.command("video-keywords")
1367
+ def video_keywords(
1368
+ ctx: typer.Context,
1369
+ video: Annotated[str, typer.Option("--video")],
1370
+ output_json: OutputJsonOption = False,
1371
+ jq_expr: JqOption = None,
1372
+ ) -> None:
1373
+ """Extract keywords from a video."""
1374
+
1375
+ runtime = apply_common_options(ctx, output_json=output_json, jq_expr=jq_expr)
1376
+ run_action(
1377
+ ctx,
1378
+ lambda: runtime.services().execute_video_keywords(video=video),
1379
+ )
1380
+
1381
+
1382
+ @app.command("run")
1383
+ def generic_run(
1384
+ ctx: typer.Context,
1385
+ endpoint: str,
1386
+ prompt: Annotated[Optional[str], typer.Option("--prompt", "-p")] = None,
1387
+ item: Annotated[
1388
+ list[str],
1389
+ typer.Option(
1390
+ "--input",
1391
+ "-i",
1392
+ help="key=value input. Value is parsed as JSON when possible.",
1393
+ ),
1394
+ ] = [],
1395
+ input_file: Annotated[Optional[Path], typer.Option("--input-file")] = None,
1396
+ method: Annotated[Optional[str], typer.Option("--method")] = None,
1397
+ dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
1398
+ help_schema: Annotated[bool, typer.Option("--help-schema")] = False,
1399
+ wait_option: WaitOption = None,
1400
+ download: DownloadOption = None,
1401
+ output_json: OutputJsonOption = False,
1402
+ jq_expr: JqOption = None,
1403
+ ) -> None:
1404
+ """Call an endpoint by name/path with a payload from CLI options."""
1405
+
1406
+ runtime = apply_common_options(
1407
+ ctx,
1408
+ wait_option=wait_option,
1409
+ download=download,
1410
+ output_json=output_json,
1411
+ jq_expr=jq_expr,
1412
+ )
1413
+
1414
+ def action() -> Any:
1415
+ return runtime.services().execute_generic(
1416
+ endpoint=endpoint,
1417
+ payload=parse_generic_payload(
1418
+ prompt=prompt, items=item, input_file=input_file
1419
+ ),
1420
+ method=method,
1421
+ execution=runtime.execution_options(),
1422
+ dry_run=dry_run,
1423
+ help_schema=help_schema,
1424
+ client_id_present=runtime.client_id is not None,
1425
+ schema_loader=load_schema,
1426
+ )
1427
+
1428
+ run_action(ctx, action)
1429
+
1430
+
1431
+ def apply_local_output_options(
1432
+ ctx: typer.Context,
1433
+ output_json: bool,
1434
+ jq_expr: str | None,
1435
+ ) -> None:
1436
+ """Apply command-local output flags."""
1437
+
1438
+ apply_common_options(ctx, output_json=output_json, jq_expr=jq_expr)
1439
+
1440
+
1441
+ @mcp_app.command("serve")
1442
+ def mcp_serve(ctx: typer.Context) -> None:
1443
+ """Start the Everypixel MCP server using the stdio transport."""
1444
+
1445
+ runtime = get_runtime(ctx)
1446
+ from .mcp_server import create_mcp_server, run_mcp_server
1447
+
1448
+ server = create_mcp_server(
1449
+ runtime.services,
1450
+ client_id_present=runtime.client_id is not None,
1451
+ )
1452
+ run_mcp_server(server)
1453
+
1454
+
1455
+ @docs_app.command("openapi")
1456
+ def docs_openapi(
1457
+ ctx: typer.Context,
1458
+ output_json: Annotated[bool, typer.Option("--output-json", "-j")] = False,
1459
+ jq_expr: Annotated[Optional[str], typer.Option("--jq")] = None,
1460
+ ) -> None:
1461
+ """Print the OpenAPI schema selected through live/cache/bundled fallback."""
1462
+
1463
+ apply_local_output_options(ctx, output_json, jq_expr)
1464
+ runtime = get_runtime(ctx)
1465
+ run_action(ctx, lambda: runtime.services().openapi())
1466
+
1467
+
1468
+ @docs_app.command("open")
1469
+ def docs_open(ctx: typer.Context) -> None:
1470
+ """Open API Swagger UI in a browser."""
1471
+
1472
+ runtime = get_runtime(ctx)
1473
+
1474
+ def action() -> dict[str, Any]:
1475
+ url = f"{runtime.base_url.rstrip('/')}/v1/docs"
1476
+ webbrowser.open(url)
1477
+ return {"status": "ok", "url": url}
1478
+
1479
+ run_action(ctx, action)
1480
+
1481
+
1482
+ @docs_app.command("refresh")
1483
+ def docs_refresh(ctx: typer.Context) -> None:
1484
+ """Force-refresh the local OpenAPI schema cache."""
1485
+
1486
+ runtime = get_runtime(ctx)
1487
+
1488
+ def action() -> dict[str, Any]:
1489
+ result, path = runtime.services().refresh_openapi()
1490
+ schema = serialize_operation_result(result)
1491
+ return {
1492
+ "status": "ok",
1493
+ "source": "live",
1494
+ "cache_path": str(path),
1495
+ "paths": len(schema.get("paths", {})),
1496
+ }
1497
+
1498
+ run_action(ctx, action)
1499
+
1500
+
1501
+ @schema_app.command("refresh")
1502
+ def schema_refresh(ctx: typer.Context) -> None:
1503
+ """Alias for `docs refresh`."""
1504
+
1505
+ docs_refresh(ctx)
1506
+
1507
+
1508
+ @schema_app.command("openapi")
1509
+ def schema_openapi(
1510
+ ctx: typer.Context,
1511
+ output_json: Annotated[bool, typer.Option("--output-json", "-j")] = False,
1512
+ jq_expr: Annotated[Optional[str], typer.Option("--jq")] = None,
1513
+ ) -> None:
1514
+ """Alias for `docs openapi`."""
1515
+
1516
+ docs_openapi(ctx, output_json=output_json, jq_expr=jq_expr)
1517
+
1518
+
1519
+ if __name__ == "__main__":
1520
+ app()