comfy-cli 1.10.0__tar.gz → 1.10.2__tar.gz

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.
Files changed (61) hide show
  1. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/PKG-INFO +2 -1
  2. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/cmdline.py +110 -29
  3. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/generate/app.py +216 -121
  4. comfy_cli-1.10.2/comfy_cli/command/generate/spec/openapi.yml +31636 -0
  5. comfy_cli-1.10.2/comfy_cli/command/run.py +955 -0
  6. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/env_checker.py +5 -2
  7. comfy_cli-1.10.2/comfy_cli/tracking.py +261 -0
  8. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli.egg-info/PKG-INFO +2 -1
  9. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli.egg-info/SOURCES.txt +1 -0
  10. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli.egg-info/requires.txt +1 -0
  11. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/pyproject.toml +5 -1
  12. comfy_cli-1.10.0/comfy_cli/command/run.py +0 -369
  13. comfy_cli-1.10.0/comfy_cli/tracking.py +0 -128
  14. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/LICENSE +0 -0
  15. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/README.md +0 -0
  16. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/__init__.py +0 -0
  17. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/__main__.py +0 -0
  18. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/__init__.py +0 -0
  19. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/code_search.py +0 -0
  20. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/custom_nodes/__init__.py +0 -0
  21. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/custom_nodes/bisect_custom_nodes.py +0 -0
  22. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/custom_nodes/cm_cli_util.py +0 -0
  23. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/custom_nodes/command.py +0 -0
  24. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/generate/__init__.py +0 -0
  25. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/generate/adapters.py +0 -0
  26. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/generate/client.py +0 -0
  27. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/generate/output.py +0 -0
  28. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/generate/poll.py +0 -0
  29. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/generate/schema.py +0 -0
  30. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/generate/spec.py +0 -0
  31. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/generate/upload.py +0 -0
  32. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/github/pr_info.py +0 -0
  33. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/install.py +0 -0
  34. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/launch.py +0 -0
  35. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/models/models.py +0 -0
  36. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/command/pr_command.py +0 -0
  37. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/config_manager.py +0 -0
  38. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/constants.py +0 -0
  39. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/cuda_detect.py +0 -0
  40. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/file_utils.py +0 -0
  41. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/git_utils.py +0 -0
  42. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/logging.py +0 -0
  43. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/pr_cache.py +0 -0
  44. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/registry/__init__.py +0 -0
  45. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/registry/api.py +0 -0
  46. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/registry/config_parser.py +0 -0
  47. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/registry/types.py +0 -0
  48. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/resolve_python.py +0 -0
  49. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/standalone.py +0 -0
  50. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/typing.py +0 -0
  51. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/ui.py +0 -0
  52. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/update.py +0 -0
  53. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/utils.py +0 -0
  54. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/uv.py +0 -0
  55. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/workflow_to_api.py +0 -0
  56. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli/workspace_manager.py +0 -0
  57. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli.egg-info/dependency_links.txt +0 -0
  58. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli.egg-info/entry_points.txt +0 -0
  59. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/comfy_cli.egg-info/top_level.txt +0 -0
  60. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/setup.cfg +0 -0
  61. {comfy_cli-1.10.0 → comfy_cli-1.10.2}/tests/test_file_utils_network.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: comfy-cli
3
- Version: 1.10.0
3
+ Version: 1.10.2
4
4
  Summary: A CLI tool for installing and using ComfyUI.
5
5
  Maintainer-email: Yoland Yan <yoland@drip.art>, James Kwon <hongilkwon316@gmail.com>, Robin Huang <robin@drip.art>, "Dr.Lt.Data" <dr.lt.data@gmail.com>
6
6
  License: GPL-3.0-only
@@ -24,6 +24,7 @@ Requires-Dist: httpx
24
24
  Requires-Dist: mixpanel
25
25
  Requires-Dist: packaging
26
26
  Requires-Dist: pathspec
27
+ Requires-Dist: posthog<8,>=6
27
28
  Requires-Dist: psutil
28
29
  Requires-Dist: pyyaml
29
30
  Requires-Dist: questionary
@@ -423,10 +423,24 @@ def update(
423
423
  rprint(f"[yellow]Failed to update node id cache: {e}[/yellow]")
424
424
 
425
425
 
426
- @app.command(help="Run API workflow file using the ComfyUI launched by `comfy launch --background`")
427
- @tracking.track_command()
426
+ @app.command(
427
+ help=(
428
+ "Run a workflow on the ComfyUI launched by `comfy launch --background`. "
429
+ "Accepts both ComfyUI API format and exported UI workflow JSON; "
430
+ "UI workflows are converted to API format client-side via /object_info."
431
+ )
432
+ )
428
433
  def run(
429
- workflow: Annotated[str, typer.Option(help="Path to the workflow API json file.")],
434
+ workflow: Annotated[
435
+ str,
436
+ typer.Option(
437
+ help=(
438
+ "Path to the workflow JSON file. Both ComfyUI API format and "
439
+ "exported UI format are accepted; UI workflows are converted "
440
+ "to API format client-side."
441
+ )
442
+ ),
443
+ ],
430
444
  wait: Annotated[
431
445
  bool,
432
446
  typer.Option(help="If the command should wait until execution completes."),
@@ -444,9 +458,17 @@ def run(
444
458
  typer.Option(help="The port where the ComfyUI instance is running, e.g. 8188."),
445
459
  ] = None,
446
460
  timeout: Annotated[
447
- int | None,
448
- typer.Option(help="The timeout in seconds for the workflow execution."),
449
- ] = 30,
461
+ int,
462
+ typer.Option(
463
+ help=(
464
+ "Per-event timeout in seconds: bails out if the server is silent "
465
+ "for this long. Also caps HTTP connect, /prompt POST, and websocket "
466
+ "handshake. NOT a wall-clock execution deadline — a workflow that "
467
+ "streams progress events faster than the timeout can run "
468
+ "indefinitely."
469
+ ),
470
+ ),
471
+ ] = 120,
450
472
  api_key: Annotated[
451
473
  str | None,
452
474
  typer.Option(
@@ -454,40 +476,99 @@ def run(
454
476
  envvar="COMFY_API_KEY",
455
477
  help=(
456
478
  "Comfy API key for API Nodes (Partner Nodes). "
457
- "Embedded in the prompt body as extra_data.api_key_comfy_org on POST /prompt. "
479
+ "Embedded in the POST /prompt request body as extra_data.api_key_comfy_org. "
458
480
  "For scripting, prefer the COMFY_API_KEY environment variable so the secret "
459
481
  "stays out of shell history."
460
482
  ),
461
483
  ),
462
484
  ] = None,
485
+ json_output: Annotated[
486
+ bool,
487
+ typer.Option(
488
+ "--json",
489
+ help=(
490
+ "Emit NDJSON events to stdout instead of human-readable output. "
491
+ "One JSON object per line, terminated by \\n. See docs/json-output.md "
492
+ "for the event reference and stability contract. In this mode, "
493
+ "--verbose has no effect and Rich progress is suppressed. "
494
+ "Workflow input accepts both API and UI format JSON (UI input "
495
+ "triggers a `converted` event before `queued`). The converted "
496
+ "workflow graph is always emitted as a `prompt_preview` event "
497
+ "before `queued`, so agents have a full audit trail of what "
498
+ "the CLI submitted."
499
+ ),
500
+ ),
501
+ ] = False,
502
+ print_prompt: Annotated[
503
+ bool,
504
+ typer.Option(
505
+ "--print-prompt",
506
+ help=(
507
+ "Print the API-format workflow graph that WOULD be sent to /prompt and exit. "
508
+ "Does not POST and does not execute. For UI-format input the workflow is "
509
+ "converted first (requires a reachable ComfyUI for /object_info); API input "
510
+ "is printed as-is with no server hit. In --json mode emits a `prompt_preview` "
511
+ "event; otherwise pretty-prints to stdout."
512
+ ),
513
+ ),
514
+ ] = False,
463
515
  ):
464
- if api_key:
465
- api_key = api_key.strip() or None
516
+ # Snapshot kwargs before the body mutates api_key/host/port — analytics should record what user actually supplied.
517
+ _track_props = tracking.filter_command_kwargs(dict(locals()))
518
+ tracking.track_event("execution_start", _track_props, mixpanel_name="run")
466
519
 
467
- config = ConfigManager()
520
+ try:
521
+ if api_key:
522
+ api_key = api_key.strip() or None
468
523
 
469
- if host:
470
- s = host.split(":")
471
- host = s[0]
472
- if not port and len(s) == 2:
473
- port = int(s[1])
524
+ config = ConfigManager()
474
525
 
475
- local_paths = False
476
- if config.background:
477
- if not host:
478
- host = config.background[0]
479
- local_paths = True
480
- if port:
481
- local_paths = False
482
- else:
483
- port = config.background[1]
526
+ if host:
527
+ s = host.split(":")
528
+ host = s[0]
529
+ if not port and len(s) == 2:
530
+ port = int(s[1])
484
531
 
485
- if not host:
486
- host = "127.0.0.1"
487
- if not port:
488
- port = 8188
532
+ if config.background:
533
+ bg_host, bg_port = config.background[0], config.background[1]
534
+ if not host:
535
+ host = bg_host
536
+ if not port:
537
+ port = bg_port
489
538
 
490
- run_inner.execute(workflow, host, port, wait, verbose, local_paths, timeout, api_key=api_key)
539
+ if not host:
540
+ host = "127.0.0.1"
541
+ if not port:
542
+ port = 8188
543
+
544
+ run_inner.execute(
545
+ workflow,
546
+ host,
547
+ port,
548
+ wait,
549
+ verbose,
550
+ timeout,
551
+ api_key=api_key,
552
+ json_mode=json_output,
553
+ print_prompt=print_prompt,
554
+ )
555
+ except typer.Exit as e:
556
+ if (e.exit_code or 0) == 0:
557
+ tracking.track_event("execution_success", _track_props)
558
+ else:
559
+ tracking.track_event(
560
+ "execution_error",
561
+ {**_track_props, "error_type": type(e).__name__, "exit_code": e.exit_code},
562
+ )
563
+ raise
564
+ except Exception as e:
565
+ tracking.track_event(
566
+ "execution_error",
567
+ {**_track_props, "error_type": type(e).__name__},
568
+ )
569
+ raise
570
+ else:
571
+ tracking.track_event("execution_success", _track_props)
491
572
 
492
573
 
493
574
  def validate_comfyui(_env_checker):
@@ -43,7 +43,6 @@ def register_with(parent: typer.Typer) -> None:
43
43
  subcommand name and error."""
44
44
 
45
45
  @parent.command(name="generate", help=_HELP, context_settings=_CONTEXT_SETTINGS)
46
- @tracking.track_command()
47
46
  def _generate_entry(
48
47
  ctx: typer.Context,
49
48
  target: Annotated[
@@ -57,17 +56,29 @@ def register_with(parent: typer.Typer) -> None:
57
56
  if target is None or target in {"-h", "--help"}:
58
57
  _print_top_help()
59
58
  raise typer.Exit(code=0)
59
+ extra = list(ctx.args)
60
60
  if target == "list":
61
- return _list_models(list(ctx.args))
61
+ tracking.track_event("generate:list")
62
+ return _list_models(extra)
62
63
  if target == "schema":
63
- return _schema(list(ctx.args))
64
+ model_arg = extra[0] if extra and not extra[0].startswith("-") else None
65
+ tracking.track_event("generate:schema", {"model": model_arg})
66
+ return _schema(extra)
64
67
  if target == "refresh":
68
+ tracking.track_event("generate:refresh")
65
69
  return _refresh()
66
70
  if target == "upload":
67
- return _upload(list(ctx.args))
71
+ tracking.track_event("generate:upload")
72
+ return _upload(extra)
68
73
  if target == "resume":
69
- return _resume(list(ctx.args))
70
- _generate(target, list(ctx.args))
74
+ resume_model = extra[0] if extra and not extra[0].startswith("-") else None
75
+ resume_job_id = extra[1] if len(extra) >= 2 and not extra[1].startswith("-") else None
76
+ tracking.track_event(
77
+ "generate:resume",
78
+ {"model": resume_model, "job_id": resume_job_id},
79
+ )
80
+ return _resume(extra)
81
+ _generate(target, extra)
71
82
 
72
83
 
73
84
  def _separate_meta_flags(extra_args: list[str]) -> tuple[list[str], dict[str, str | bool]]:
@@ -146,130 +157,214 @@ def _emit_result(result: poll.PollResult, *, request_id: str, download: str | No
146
157
 
147
158
 
148
159
  def _generate(model: str, extra_args: list[str]) -> None:
149
- try:
150
- ep = spec.get_endpoint(model)
151
- except spec.SpecError as e:
152
- rprint(f"[bold red]{e}[/bold red]")
153
- raise typer.Exit(code=1)
154
-
155
- if any(a in {"--help", "-h"} for a in extra_args):
156
- _show_schema_help(ep)
157
- raise typer.Exit(code=0)
158
-
159
- try:
160
- remaining, meta = _separate_meta_flags(extra_args)
161
- except schema.SchemaError as e:
162
- rprint(f"[bold red]{e}[/bold red]")
163
- raise typer.Exit(code=1)
164
-
165
- flags = schema.flags_for(ep)
166
- try:
167
- values = schema.parse_args(flags, remaining)
168
- except schema.SchemaError as e:
169
- rprint(f"[bold red]{e}[/bold red]")
170
- name = spec.preferred_alias(ep.id) or ep.id
171
- rprint(f"[dim]Run `comfy generate schema {name}` for the full parameter list.[/dim]")
172
- raise typer.Exit(code=1)
173
-
174
- try:
175
- api_key = client.resolve_api_key(meta.get("api-key") if isinstance(meta.get("api-key"), str) else None)
176
- except client.ApiError as e:
177
- rprint(f"[bold red]{e}[/bold red]")
178
- raise typer.Exit(code=1)
179
-
180
- timeout_raw = meta.get("timeout", "300")
181
- try:
182
- timeout = float(timeout_raw) if isinstance(timeout_raw, str) else 300.0
183
- except ValueError:
184
- rprint(f"[bold red]--timeout: expected number, got {timeout_raw!r}[/bold red]")
185
- raise typer.Exit(code=1)
186
-
187
- do_async = bool(meta.get("async", False))
188
- download = meta.get("download") if isinstance(meta.get("download"), str) else None
189
- as_json = bool(meta.get("json", False))
190
-
191
- try:
192
- _apply_upload_transforms(values, flags, ep, api_key)
193
- except (client.ApiError, httpx.HTTPError) as e:
194
- rprint(f"[bold red]Upload failed: {e}[/bold red]")
195
- raise typer.Exit(code=1)
160
+ # --help short-circuits before tracking — it's a help-display action, not an execution attempt.
161
+ # If the model is unknown, fall through so the tracking path records the schema error.
162
+ asks_help = any(a in {"--help", "-h"} for a in extra_args)
163
+ if asks_help:
164
+ try:
165
+ help_ep = spec.get_endpoint(model)
166
+ except spec.SpecError:
167
+ help_ep = None
168
+ if help_ep is not None:
169
+ _show_schema_help(help_ep)
170
+ raise typer.Exit(code=0)
196
171
 
197
- request_id = str(uuid.uuid4())[:8]
198
- try:
199
- resp = client.send_request(ep, values, flags, api_key, timeout=timeout)
200
- except httpx.HTTPError as e:
201
- rprint(f"[bold red]Network error contacting {spec.base_url()}: {e}[/bold red]")
202
- raise typer.Exit(code=1) from e
172
+ # generate:start fires at entry so every invocation has a paired start/end lifecycle.
173
+ # Props are filled in progressively as model_alias / partner / async / has_download become known.
174
+ gen_props: dict[str, object | None] = {
175
+ "model": model,
176
+ "model_alias": None,
177
+ "async": None,
178
+ "has_download": None,
179
+ "partner": None,
180
+ }
181
+ tracking.track_event("generate:start", gen_props)
182
+
183
+ def _track_error(error_kind: str, exc: BaseException) -> None:
184
+ tracking.track_event(
185
+ "generate:error",
186
+ {**gen_props, "error_type": type(exc).__name__, "error_kind": error_kind},
187
+ )
203
188
 
204
189
  try:
205
- client.raise_for_status(resp)
206
- except client.ApiError as e:
207
- rprint(f"[bold red]API error {e.status}[/bold red]\n{e.body}")
208
- raise typer.Exit(code=1) from e
209
-
210
- if resp.headers.get("content-type", "").startswith("image/"):
211
- if download:
212
- saved = output.save_binary_response(resp, download, request_id)
213
- output.print_saved([saved])
214
- else:
215
- rprint("[yellow]Binary image response; nothing saved. Pass --download <path> to write it to disk.[/yellow]")
216
- return
190
+ try:
191
+ ep = spec.get_endpoint(model)
192
+ except spec.SpecError as e:
193
+ rprint(f"[bold red]{e}[/bold red]")
194
+ _track_error("schema", e)
195
+ raise typer.Exit(code=1)
196
+
197
+ gen_props["model_alias"] = spec.preferred_alias(ep.id)
198
+ gen_props["partner"] = getattr(ep, "partner", None)
199
+
200
+ try:
201
+ remaining, meta = _separate_meta_flags(extra_args)
202
+ except schema.SchemaError as e:
203
+ rprint(f"[bold red]{e}[/bold red]")
204
+ _track_error("schema", e)
205
+ raise typer.Exit(code=1)
206
+
207
+ do_async = bool(meta.get("async", False))
208
+ download = meta.get("download") if isinstance(meta.get("download"), str) else None
209
+ as_json = bool(meta.get("json", False))
210
+ gen_props["async"] = do_async
211
+ gen_props["has_download"] = bool(download)
212
+
213
+ flags = schema.flags_for(ep)
214
+ try:
215
+ values = schema.parse_args(flags, remaining)
216
+ except schema.SchemaError as e:
217
+ rprint(f"[bold red]{e}[/bold red]")
218
+ name = gen_props["model_alias"] or ep.id
219
+ rprint(f"[dim]Run `comfy generate schema {name}` for the full parameter list.[/dim]")
220
+ _track_error("schema", e)
221
+ raise typer.Exit(code=1)
222
+
223
+ try:
224
+ api_key = client.resolve_api_key(meta.get("api-key") if isinstance(meta.get("api-key"), str) else None)
225
+ except client.ApiError as e:
226
+ rprint(f"[bold red]{e}[/bold red]")
227
+ _track_error("api", e)
228
+ raise typer.Exit(code=1)
229
+
230
+ timeout_raw = meta.get("timeout", "300")
231
+ try:
232
+ timeout = float(timeout_raw) if isinstance(timeout_raw, str) else 300.0
233
+ except ValueError as e:
234
+ rprint(f"[bold red]--timeout: expected number, got {timeout_raw!r}[/bold red]")
235
+ _track_error("schema", e)
236
+ raise typer.Exit(code=1)
237
+
238
+ try:
239
+ _apply_upload_transforms(values, flags, ep, api_key)
240
+ except (client.ApiError, httpx.HTTPError) as e:
241
+ rprint(f"[bold red]Upload failed: {e}[/bold red]")
242
+ _track_error("upload", e)
243
+ raise typer.Exit(code=1)
244
+
245
+ request_id = str(uuid.uuid4())[:8]
246
+ try:
247
+ resp = client.send_request(ep, values, flags, api_key, timeout=timeout)
248
+ except httpx.HTTPError as e:
249
+ rprint(f"[bold red]Network error contacting {spec.base_url()}: {e}[/bold red]")
250
+ _track_error("network", e)
251
+ raise typer.Exit(code=1) from e
252
+
253
+ try:
254
+ client.raise_for_status(resp)
255
+ except client.ApiError as e:
256
+ rprint(f"[bold red]API error {e.status}[/bold red]\n{e.body}")
257
+ _track_error("api", e)
258
+ raise typer.Exit(code=1) from e
259
+
260
+ if resp.headers.get("content-type", "").startswith("image/"):
261
+ if download:
262
+ saved = output.save_binary_response(resp, download, request_id)
263
+ output.print_saved([saved])
264
+ else:
265
+ rprint(
266
+ "[yellow]Binary image response; nothing saved. Pass --download <path> to write it to disk.[/yellow]"
267
+ )
268
+ tracking.track_event("generate:success", gen_props)
269
+ return
217
270
 
218
- try:
219
- body = resp.json()
220
- except ValueError:
221
- rprint("[bold red]Unexpected non-JSON response.[/bold red]")
222
- rprint(resp.text[:500])
223
- raise typer.Exit(code=1)
271
+ try:
272
+ body = resp.json()
273
+ except ValueError as e:
274
+ rprint("[bold red]Unexpected non-JSON response.[/bold red]")
275
+ rprint(resp.text[:500])
276
+ _track_error("non_json_response", e)
277
+ raise typer.Exit(code=1)
278
+
279
+ if ep.polling:
280
+ job_id = poll.extract_job_id(ep.polling, body) or request_id
281
+ name = gen_props["model_alias"] or ep.id
282
+ if do_async:
283
+ if as_json:
284
+ output.print_json(body)
285
+ else:
286
+ rprint(f"[bold green]Submitted:[/bold green] {name}")
287
+ rprint(f" job id: {job_id}")
288
+ rprint(f" resume: comfy generate resume {name} {job_id}")
289
+ # Submitted, not succeeded — the workflow runs on the partner side and completion is
290
+ # observed server-side via partner_node:api_call_*. No generate:success pair here.
291
+ tracking.track_event(
292
+ "generate:submitted",
293
+ {
294
+ "model": model,
295
+ "model_alias": gen_props["model_alias"],
296
+ "job_id": job_id,
297
+ "partner": gen_props["partner"],
298
+ },
299
+ )
300
+ return
301
+
302
+ poller = poll.get_poller(ep.polling)
303
+ with _spinner() as prog:
304
+ task = prog.add_task(f"Generating with {name} (job {job_id})", total=None)
305
+
306
+ def _on_progress(p: float) -> None:
307
+ prog.update(task, description=f"Generating ({p * 100:.0f}%)")
308
+
309
+ try:
310
+ result = poller(
311
+ body,
312
+ api_key=api_key,
313
+ timeout=timeout,
314
+ on_progress=_on_progress,
315
+ create_path=ep.path,
316
+ )
317
+ except (client.ApiError, httpx.HTTPError) as e:
318
+ _track_error("network" if isinstance(e, httpx.HTTPError) else "api", e)
319
+ raise typer.Exit(code=1) from e
320
+ try:
321
+ _emit_result(result, request_id=job_id, download=download, as_json=as_json)
322
+ tracking.track_event("generate:success", gen_props)
323
+ except typer.Exit as e:
324
+ if (e.exit_code or 0) == 0:
325
+ tracking.track_event("generate:success", gen_props)
326
+ else:
327
+ _track_error("api", e)
328
+ raise
329
+ return
224
330
 
225
- if ep.polling:
226
- job_id = poll.extract_job_id(ep.polling, body) or request_id
227
- name = spec.preferred_alias(ep.id) or ep.id
228
- if do_async:
331
+ adapter = adapters.get(ep.id)
332
+ if adapter is not None and adapter.decode_sync is not None:
333
+ body = resp.json()
229
334
  if as_json:
230
335
  output.print_json(body)
336
+ tracking.track_event("generate:success", gen_props)
337
+ return
338
+ if not download:
339
+ rprint("[yellow]Image data returned inline. Pass --download <path> to save.[/yellow]")
340
+ tracking.track_event("generate:success", gen_props)
341
+ return
342
+ saved = adapter.decode_sync(body, download, request_id)
343
+ if saved:
344
+ output.print_saved(saved)
231
345
  else:
232
- rprint(f"[bold green]Submitted:[/bold green] {name}")
233
- rprint(f" job id: {job_id}")
234
- rprint(f" resume: comfy generate resume {name} {job_id}")
235
- return
236
-
237
- poller = poll.get_poller(ep.polling)
238
- with _spinner() as prog:
239
- task = prog.add_task(f"Generating with {name} (job {job_id})", total=None)
240
-
241
- def _on_progress(p: float) -> None:
242
- prog.update(task, description=f"Generating ({p * 100:.0f}%)")
243
-
244
- result = poller(
245
- body,
246
- api_key=api_key,
247
- timeout=timeout,
248
- on_progress=_on_progress,
249
- create_path=ep.path,
250
- )
251
- _emit_result(result, request_id=job_id, download=download, as_json=as_json)
252
- return
253
-
254
- adapter = adapters.get(ep.id)
255
- if adapter is not None and adapter.decode_sync is not None:
256
- body = resp.json()
257
- if as_json:
258
- output.print_json(body)
259
- return
260
- if not download:
261
- rprint("[yellow]Image data returned inline. Pass --download <path> to save.[/yellow]")
346
+ rprint("[yellow]No image data found in response.[/yellow]")
347
+ output.print_json(body)
348
+ tracking.track_event("generate:success", gen_props)
262
349
  return
263
- saved = adapter.decode_sync(body, download, request_id)
264
- if saved:
265
- output.print_saved(saved)
266
- else:
267
- rprint("[yellow]No image data found in response.[/yellow]")
268
- output.print_json(body)
269
- return
270
350
 
271
- result = poll.sync_result_from_response(resp)
272
- _emit_result(result, request_id=request_id, download=download, as_json=as_json)
351
+ try:
352
+ result = poll.sync_result_from_response(resp)
353
+ _emit_result(result, request_id=request_id, download=download, as_json=as_json)
354
+ tracking.track_event("generate:success", gen_props)
355
+ except typer.Exit as e:
356
+ if (e.exit_code or 0) == 0:
357
+ tracking.track_event("generate:success", gen_props)
358
+ else:
359
+ _track_error("api", e)
360
+ raise
361
+ except typer.Exit:
362
+ # Inline raise sites already emitted their lifecycle event.
363
+ raise
364
+ except Exception as e:
365
+ # Safety net so an unexpected exception still pairs generate:start with a terminal generate:error.
366
+ _track_error("unknown", e)
367
+ raise
273
368
 
274
369
 
275
370
  def _arg_value(args: list[str], *names: str) -> str | None:
@@ -410,7 +505,7 @@ def _apply_upload_transforms(values: dict, flags: list[schema.FlagDef], endpoint
410
505
 
411
506
 
412
507
  def _resume(extra_args: list[str]) -> None:
413
- if len(extra_args) < 2:
508
+ if len(extra_args) < 2 or extra_args[0].startswith("-") or extra_args[1].startswith("-"):
414
509
  rprint("[bold red]Usage: comfy generate resume <model> <job_id> [--download PATH] [--json][/bold red]")
415
510
  raise typer.Exit(code=1)
416
511
  model, job_id = extra_args[0], extra_args[1]