comfy-cli 1.7.2__tar.gz → 1.8.0__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 (49) hide show
  1. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/PKG-INFO +1 -1
  2. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/cmdline.py +17 -1
  3. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/code_search.py +46 -14
  4. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/command.py +23 -8
  5. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/install.py +138 -9
  6. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/models/models.py +11 -2
  7. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/run.py +103 -23
  8. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/constants.py +1 -0
  9. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/file_utils.py +38 -1
  10. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/git_utils.py +20 -3
  11. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/registry/config_parser.py +222 -6
  12. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/tracking.py +12 -3
  13. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/ui.py +4 -2
  14. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/uv.py +7 -2
  15. comfy_cli-1.8.0/comfy_cli/workflow_to_api.py +1369 -0
  16. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli.egg-info/PKG-INFO +1 -1
  17. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli.egg-info/SOURCES.txt +1 -0
  18. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/pyproject.toml +1 -1
  19. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/tests/test_file_utils_network.py +332 -0
  20. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/LICENSE +0 -0
  21. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/README.md +0 -0
  22. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/__init__.py +0 -0
  23. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/__main__.py +0 -0
  24. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/__init__.py +0 -0
  25. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/__init__.py +0 -0
  26. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/bisect_custom_nodes.py +0 -0
  27. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/cm_cli_util.py +0 -0
  28. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/github/pr_info.py +0 -0
  29. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/launch.py +0 -0
  30. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/command/pr_command.py +0 -0
  31. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/config_manager.py +0 -0
  32. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/cuda_detect.py +0 -0
  33. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/env_checker.py +0 -0
  34. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/logging.py +0 -0
  35. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/pr_cache.py +0 -0
  36. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/registry/__init__.py +0 -0
  37. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/registry/api.py +0 -0
  38. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/registry/types.py +0 -0
  39. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/resolve_python.py +0 -0
  40. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/standalone.py +0 -0
  41. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/typing.py +0 -0
  42. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/update.py +0 -0
  43. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/utils.py +0 -0
  44. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli/workspace_manager.py +0 -0
  45. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli.egg-info/dependency_links.txt +0 -0
  46. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli.egg-info/entry_points.txt +0 -0
  47. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli.egg-info/requires.txt +0 -0
  48. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/comfy_cli.egg-info/top_level.txt +0 -0
  49. {comfy_cli-1.7.2 → comfy_cli-1.8.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: comfy-cli
3
- Version: 1.7.2
3
+ Version: 1.8.0
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
@@ -446,7 +446,23 @@ def run(
446
446
  int | None,
447
447
  typer.Option(help="The timeout in seconds for the workflow execution."),
448
448
  ] = 30,
449
+ api_key: Annotated[
450
+ str | None,
451
+ typer.Option(
452
+ "--api-key",
453
+ envvar="COMFY_API_KEY",
454
+ help=(
455
+ "Comfy API key for API Nodes (Partner Nodes). "
456
+ "Embedded in the prompt body as extra_data.api_key_comfy_org on POST /prompt. "
457
+ "For scripting, prefer the COMFY_API_KEY environment variable so the secret "
458
+ "stays out of shell history."
459
+ ),
460
+ ),
461
+ ] = None,
449
462
  ):
463
+ if api_key:
464
+ api_key = api_key.strip() or None
465
+
450
466
  config = ConfigManager()
451
467
 
452
468
  if host:
@@ -470,7 +486,7 @@ def run(
470
486
  if not port:
471
487
  port = 8188
472
488
 
473
- run_inner.execute(workflow, host, port, wait, verbose, local_paths, timeout)
489
+ run_inner.execute(workflow, host, port, wait, verbose, local_paths, timeout, api_key=api_key)
474
490
 
475
491
 
476
492
  def validate_comfyui(_env_checker):
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  import re
5
+ import sys
5
6
  from typing import Annotated
6
7
  from urllib.parse import quote
7
8
 
@@ -20,12 +21,19 @@ DEFAULT_COUNT = 20
20
21
  REQUEST_TIMEOUT = 30
21
22
 
22
23
 
24
+ _TYPE_FILTER_RE = re.compile(r"(^|\s)type:")
25
+
26
+
23
27
  def _build_query(query: str, repo: str | None, count: int) -> str:
24
28
  parts = []
25
29
  if repo:
26
30
  if "/" not in repo:
27
31
  repo = f"Comfy-Org/{repo}"
28
32
  parts.append(f"repo:^{re.escape(repo)}$")
33
+ # Only default to file matches when the user hasn't specified their own
34
+ # type: filter — otherwise respect whatever they passed (e.g. type:commit).
35
+ if not _TYPE_FILTER_RE.search(query):
36
+ parts.append("type:file")
29
37
  parts.append(f"count:{count}")
30
38
  parts.append(query)
31
39
  return " ".join(parts)
@@ -56,20 +64,21 @@ def _format_results(search: dict) -> list[dict]:
56
64
  commit_hash = (default_branch.get("target") or {}).get("commit", {}).get("oid", "")
57
65
  ref = commit_hash or branch_name
58
66
 
67
+ encoded_path = quote(file_path, safe="/")
68
+ file_url = f"https://github.com/{clean_name}/blob/{ref}/{encoded_path}"
69
+
59
70
  line_matches = result.get("lineMatches") or []
60
71
  matches = []
61
- if line_matches:
62
- encoded_path = quote(file_path, safe="/")
63
- base_url = f"https://github.com/{clean_name}/blob/{ref}/{encoded_path}"
64
- for m in line_matches:
65
- line = m.get("lineNumber", 0) + 1
66
- preview = m.get("preview", "").rstrip()
67
- matches.append({"line": line, "preview": preview, "url": f"{base_url}#L{line}"})
72
+ for m in line_matches:
73
+ line = m.get("lineNumber", 0) + 1
74
+ preview = m.get("preview", "").rstrip()
75
+ matches.append({"line": line, "preview": preview, "url": f"{file_url}#L{line}"})
68
76
 
69
77
  formatted.append(
70
78
  {
71
79
  "repository": clean_name,
72
80
  "file": file_path,
81
+ "file_url": file_url,
73
82
  "branch": branch_name,
74
83
  "commit": commit_hash,
75
84
  "matches": matches,
@@ -96,19 +105,34 @@ def _print_results(results: list[dict], stats: dict, json_output: bool) -> None:
96
105
  console.print("[yellow]No results found.[/yellow]")
97
106
  return
98
107
 
108
+ # Use raw isatty() rather than Rich's console.is_terminal: Rich treats
109
+ # FORCE_COLOR=1 / TTY_COMPATIBLE=1 as terminal-capable even when stdout
110
+ # is redirected, but OSC 8 escapes in a piped stream defeat the whole
111
+ # point of this branch (hiding URLs from humans, exposing them to AI).
112
+ is_tty = sys.stdout.isatty()
113
+
99
114
  for file_result in results:
115
+ repo = file_result["repository"]
116
+ path = file_result["file"]
117
+ file_url = file_result["file_url"]
118
+
100
119
  header = Text()
101
- header.append(file_result["repository"], style="bold cyan")
102
- header.append(" / ", style="dim")
103
- header.append(file_result["file"], style="bold")
120
+ if is_tty:
121
+ # Humans: clickable OSC 8 hyperlink, URL hidden from visible output.
122
+ header.append(f"{repo} / {path}", style=f"bold cyan link {file_url}")
123
+ else:
124
+ # Non-TTY (pipes, AI agents): print the raw URL once per file so
125
+ # agents can synthesize #L<line> anchors themselves.
126
+ header.append(f"{repo} / {path}\n")
127
+ header.append(f" {file_url}", style="dim")
104
128
  console.print(header)
105
129
 
106
130
  for match in file_result["matches"]:
107
- line_text = Text()
108
- line_text.append(f" L{match['line']:>5}", style="green")
131
+ line_text = Text(" ")
132
+ line_style = f"green link {match['url']}" if is_tty else "green"
133
+ line_text.append(f"L{match['line']:>5}", style=line_style)
109
134
  line_text.append(f" {match['preview']}")
110
135
  console.print(line_text)
111
- console.print(f" [dim]{match['url']}[/dim]")
112
136
 
113
137
  console.print()
114
138
 
@@ -121,7 +145,15 @@ def _print_results(results: list[dict], stats: dict, json_output: bool) -> None:
121
145
  @app.callback(invoke_without_command=True)
122
146
  @tracking.track_command()
123
147
  def code_search(
124
- query: Annotated[str, typer.Argument(help="Search query (supports Sourcegraph syntax)")],
148
+ query: Annotated[
149
+ str,
150
+ typer.Argument(
151
+ help=(
152
+ "Search query (supports Sourcegraph syntax). Defaults to file matches; "
153
+ "pass your own `type:` filter (e.g. `type:commit`) to override."
154
+ ),
155
+ ),
156
+ ],
125
157
  repo: Annotated[
126
158
  str | None,
127
159
  typer.Option("--repo", "-r", help="Filter by repository (e.g. ComfyUI, Comfy-Org/ComfyUI)"),
@@ -18,6 +18,7 @@ from comfy_cli.command.custom_nodes.cm_cli_util import execute_cm_cli, find_cm_c
18
18
  from comfy_cli.config_manager import ConfigManager
19
19
  from comfy_cli.constants import NODE_ZIP_FILENAME
20
20
  from comfy_cli.file_utils import (
21
+ DownloadException,
21
22
  download_file,
22
23
  extract_package_as_zip,
23
24
  upload_file_to_signed_url,
@@ -170,13 +171,11 @@ def execute_install_script(repo_path):
170
171
  if os.path.exists(requirements_path):
171
172
  print("Install: pip packages")
172
173
  python = resolve_workspace_python(workspace_manager.workspace_path)
173
- with open(requirements_path, encoding="utf-8") as requirements_file:
174
- for line in requirements_file:
175
- package_name = line.strip()
176
- if package_name and not package_name.startswith("#"):
177
- install_cmd = [python, "-m", "pip", "install", package_name]
178
- if package_name.strip() != "":
179
- try_install_script(repo_path, install_cmd)
174
+ # Absolute path so pip doesn't re-resolve it against cwd=repo_path
175
+ # in try_install_script, which would double the path if repo_path
176
+ # is relative.
177
+ install_cmd = [python, "-m", "pip", "install", "-r", os.path.abspath(requirements_path)]
178
+ try_install_script(repo_path, install_cmd)
180
179
 
181
180
  if os.path.exists(install_script_path):
182
181
  print("Install: install script")
@@ -976,6 +975,17 @@ def validate_node_for_publishing():
976
975
  # Perform some validation logic here
977
976
  typer.echo("Validating node configuration...")
978
977
  config = extract_node_configuration()
978
+ if config is None:
979
+ raise typer.Exit(code=1)
980
+
981
+ if not config.project.version:
982
+ # Escape `[` chars so rich doesn't parse `[tool.comfy.version]` and
983
+ # `["version"]` as markup tags; `]` doesn't need escaping.
984
+ print(
985
+ "[red]Error: project version is empty. Set `project.version` in pyproject.toml, "
986
+ r'or configure `\[tool.comfy.version].path` if using `dynamic = \["version"]`.[/red]'
987
+ )
988
+ raise typer.Exit(code=1)
979
989
 
980
990
  # Run security checks first
981
991
  typer.echo("Running security checks...")
@@ -1168,7 +1178,12 @@ def registry_install(
1168
1178
 
1169
1179
  local_filename = node_specific_path / f"{node_id}-{node_version.version}.zip"
1170
1180
  logging.debug(f"Start downloading the node {node_id} version {node_version.version} to {local_filename}")
1171
- download_file(node_version.download_url, local_filename)
1181
+ try:
1182
+ download_file(node_version.download_url, local_filename)
1183
+ except DownloadException as e:
1184
+ logging.error(f"Failed to download node {node_id} version {node_version.version}: {e}")
1185
+ ui.display_error_message(f"Failed to download the custom node {node_id}: {e}")
1186
+ raise typer.Exit(code=1) from None
1172
1187
 
1173
1188
  # Extract the downloaded archive to the custom_node directory on the workspace.
1174
1189
  logging.debug(f"Start extracting the node {node_id} version {node_version.version} to {custom_nodes_path}")
@@ -1,5 +1,6 @@
1
1
  import os
2
2
  import platform
3
+ import re
3
4
  import subprocess
4
5
  import sys
5
6
  from typing import TypedDict
@@ -189,7 +190,7 @@ def execute(
189
190
 
190
191
  if version != "nightly":
191
192
  try:
192
- checkout_stable_comfyui(version=version, repo_dir=repo_dir)
193
+ checkout_stable_comfyui(version=version, repo_dir=repo_dir, url=url)
193
194
  except GitHubRateLimitError as e:
194
195
  rprint(f"[bold red]Error checking out ComfyUI version: {e}[/bold red]")
195
196
  sys.exit(1)
@@ -434,17 +435,136 @@ def clone_comfyui(url: str, repo_dir: str):
434
435
  subprocess.run(["git", "clone", url, repo_dir], check=True)
435
436
 
436
437
 
437
- def checkout_stable_comfyui(version: str, repo_dir: str):
438
+ def _resolve_latest_tag_from_local(repo_dir: str) -> tuple[str | None, bool]:
439
+ """Pick the highest stable semver tag from the local clone.
440
+
441
+ Returns ``(tag, fetch_ok)``:
442
+ - ``tag``: the tag string (e.g. ``"v0.20.1"``), or ``None`` when no stable
443
+ semver tag is available (or the directory isn't a git repo).
444
+ - ``fetch_ok``: whether ``git fetch --tags`` succeeded. Callers can use this
445
+ to distinguish "no new releases" from "couldn't reach the remote", which
446
+ changes the right messaging when falling back to the API.
447
+
448
+ Pre-release tags (e.g. ``v1.2.3-rc1``) are skipped to mirror GitHub's
449
+ ``releases/latest`` behavior. Note that this picks the highest semver tag,
450
+ which may differ from the release a maintainer has manually marked as
451
+ "Latest" on GitHub — acceptable trade-off given the unauthenticated API's
452
+ 60 req/hr per-IP cap; users can pin a specific version with ``--version``
453
+ if needed.
454
+
455
+ ``git_checkout_tag`` skips its own ``git fetch --tags`` when the resolved
456
+ tag is already present locally, so on the happy path we fetch exactly once
457
+ here. Crucially, that also lets the cached-tag offline path succeed: if
458
+ fetch above fails (``fetch_ok=False``) but a tag is found from disk,
459
+ ``git_checkout_tag`` will not retry the unreachable fetch.
460
+ """
461
+ fetch_ok = False
462
+ try:
463
+ completed = subprocess.run(
464
+ ["git", "-C", repo_dir, "fetch", "--tags", "--quiet"],
465
+ capture_output=True,
466
+ text=True,
467
+ timeout=30,
468
+ )
469
+ fetch_ok = completed.returncode == 0
470
+ except (subprocess.SubprocessError, FileNotFoundError, OSError):
471
+ # Tolerate timeout / OS-level failure; fall through with whatever's on disk.
472
+ pass
473
+
474
+ try:
475
+ result = subprocess.run(
476
+ ["git", "-C", repo_dir, "tag", "--list"],
477
+ capture_output=True,
478
+ text=True,
479
+ check=True,
480
+ timeout=10,
481
+ )
482
+ except (subprocess.SubprocessError, FileNotFoundError, OSError):
483
+ return None, fetch_ok
484
+
485
+ best: tuple[semver.VersionInfo, str] | None = None
486
+ for line in result.stdout.splitlines():
487
+ tag = line.strip()
488
+ if not tag:
489
+ continue
490
+ try:
491
+ parsed = semver.VersionInfo.parse(tag.lstrip("v"))
492
+ except ValueError:
493
+ continue
494
+ if parsed.prerelease:
495
+ continue
496
+ if best is None or parsed > best[0]:
497
+ best = (parsed, tag)
498
+
499
+ return (best[1] if best else None), fetch_ok
500
+
501
+
502
+ _GITHUB_REPO_RE = re.compile(
503
+ # `github.com[:/]<owner>/<repo>` with optional `.git` and optional setuptools-style
504
+ # `@branch` suffix (matching what ``clone_comfyui`` accepts via ``rsplit("@", 1)``).
505
+ # Branch names may contain slashes (`release/1.0`), so the `@<branch>` group is greedy
506
+ # to end-of-string. The repo segment forbids `@` and `/` to avoid eating those parts.
507
+ r"github\.com[/:]([^/\s]+)/([^/@\s]+?)(?:\.git)?(?:@.+)?/?$",
508
+ )
509
+
510
+
511
+ def _parse_github_owner_repo(url: str | None) -> tuple[str, str] | None:
512
+ """Parse a GitHub repo URL into ``(owner, repo)``.
513
+
514
+ Handles the URL forms ``clone_comfyui`` accepts:
515
+ - ``https://github.com/owner/repo``
516
+ - ``https://github.com/owner/repo.git``
517
+ - ``https://github.com/owner/repo@branch`` (setuptools-style branch suffix)
518
+ - ``git@github.com:owner/repo`` (SSH form)
519
+
520
+ Returns ``None`` for empty input, local paths, or non-GitHub URLs (GitLab,
521
+ self-hosted, etc.) — the caller decides what to do with that.
522
+ """
523
+ if not url:
524
+ return None
525
+ match = _GITHUB_REPO_RE.search(url)
526
+ return (match.group(1), match.group(2)) if match else None
527
+
528
+
529
+ def checkout_stable_comfyui(version: str, repo_dir: str, url: str | None = None):
438
530
  """
439
531
  Supports installing stable releases of Comfy (semantic versioning) or the 'latest' version.
532
+
533
+ For ``version="latest"`` we resolve the highest stable semver tag from the
534
+ local clone first to avoid burning the unauthenticated GitHub API budget
535
+ (60 req/hr per IP). The ``releases/latest`` API is only consulted when local
536
+ resolution turns up nothing.
537
+
538
+ The optional ``url`` is the install URL forwarded from ``execute``; it lets
539
+ the API fallback query the same repo we cloned from (forks included)
540
+ instead of always asking upstream. Non-GitHub URLs and missing URLs
541
+ fall back to ``comfyanonymous/ComfyUI`` so the prior behavior is preserved
542
+ for users who pass a local path or a non-GitHub remote.
440
543
  """
441
544
  rprint(f"Looking for ComfyUI version '{version}'...")
442
545
  if version == "latest":
443
- selected_release = get_latest_release("comfyanonymous", "ComfyUI")
444
- if selected_release is None:
445
- rprint(f"Error: No release found for version '{version}'.")
446
- sys.exit(1)
447
- tag = str(selected_release["tag"])
546
+ tag, fetch_ok = _resolve_latest_tag_from_local(repo_dir)
547
+ if tag is None:
548
+ if not fetch_ok:
549
+ rprint(
550
+ "[yellow]Could not refresh tags from the remote (offline or auth failure); "
551
+ "trying GitHub API as a last resort.[/yellow]"
552
+ )
553
+ else:
554
+ rprint("[yellow]No stable release tags found locally; querying GitHub API.[/yellow]")
555
+ owner, repo = _parse_github_owner_repo(url) or ("comfyanonymous", "ComfyUI")
556
+ selected_release = get_latest_release(owner, repo)
557
+ if selected_release is None:
558
+ rprint(f"Error: No release found for version '{version}'.")
559
+ sys.exit(1)
560
+ tag = str(selected_release["tag"])
561
+ elif not fetch_ok:
562
+ # Tag list comes from a cached state — flag it so the user knows
563
+ # they may not be on the actual newest release.
564
+ rprint(
565
+ f"[yellow]Warning: could not refresh tags from remote; "
566
+ f"using cached tag {tag}. Re-run with network access to get the newest release.[/yellow]"
567
+ )
448
568
  else:
449
569
  # For specific versions, directly construct the tag (add 'v' prefix if needed)
450
570
  tag = f"v{version}" if not version.startswith("v") else version
@@ -490,9 +610,18 @@ def get_latest_release(repo_owner: str, repo_name: str) -> GithubRelease | None:
490
610
 
491
611
  data = response.json()
492
612
 
613
+ # Forks may use non-semver tags (e.g. "release-2026-04"); the caller
614
+ # only needs the raw tag string for git checkout, so let `version`
615
+ # fall back to None instead of crashing.
616
+ tag_name = data["tag_name"]
617
+ try:
618
+ parsed_version = semver.VersionInfo.parse(tag_name.lstrip("v"))
619
+ except ValueError:
620
+ parsed_version = None
621
+
493
622
  return GithubRelease(
494
- tag=data["tag_name"],
495
- version=semver.VersionInfo.parse(data["tag_name"].lstrip("v")),
623
+ tag=tag_name,
624
+ version=parsed_version,
496
625
  download_url=data["zipball_url"],
497
626
  )
498
627
 
@@ -8,6 +8,7 @@ from urllib.parse import parse_qs, unquote, urlparse
8
8
  import requests
9
9
  import typer
10
10
  from rich import print
11
+ from rich.markup import escape
11
12
 
12
13
  from comfy_cli import constants, tracking, ui
13
14
  from comfy_cli.config_manager import ConfigManager
@@ -20,6 +21,8 @@ app = typer.Typer()
20
21
  workspace_manager = WorkspaceManager()
21
22
  config_manager = ConfigManager()
22
23
 
24
+ _CIVITAI_SUBDOMAIN_SUFFIXES = tuple(f".{h}" for h in constants.CIVITAI_ALLOWED_HOSTS)
25
+
23
26
 
24
27
  model_path_map = {
25
28
  "lora": "loras",
@@ -98,7 +101,7 @@ def check_civitai_url(url: str) -> tuple[bool, bool, int | None, int | None]:
98
101
  try:
99
102
  parsed = urlparse(url)
100
103
  host = (parsed.hostname or "").lower()
101
- if host != "civitai.com" and not host.endswith(".civitai.com"):
104
+ if host not in constants.CIVITAI_ALLOWED_HOSTS and not host.endswith(_CIVITAI_SUBDOMAIN_SUFFIXES):
102
105
  return False, False, None, None
103
106
  p_parts = [p for p in parsed.path.split("/") if p]
104
107
  query = parse_qs(parsed.query)
@@ -354,7 +357,13 @@ def download(
354
357
  print(f"Model downloaded successfully to: {output_path}")
355
358
  else:
356
359
  print(f"Start downloading URL: {url} into {local_filepath}")
357
- download_file(url, local_filepath, headers, downloader=resolved_downloader)
360
+ try:
361
+ download_file(url, local_filepath, headers, downloader=resolved_downloader)
362
+ except DownloadException as e:
363
+ # escape() so a dynamic error message containing "[/]" or similar
364
+ # rich-markup syntax doesn't trigger MarkupError or get mis-rendered.
365
+ print(f"[bold red]{escape(str(e))}[/bold red]")
366
+ raise typer.Exit(code=1) from None
358
367
 
359
368
  elapsed = time.monotonic() - start_time
360
369
  print(f"Done in {_format_elapsed(elapsed)}")
@@ -14,28 +14,67 @@ from rich.progress import BarColumn, Column, Progress, Table, TimeElapsedColumn
14
14
  from websocket import WebSocket, WebSocketException, WebSocketTimeoutException
15
15
 
16
16
  from comfy_cli.env_checker import check_comfy_server_running
17
+ from comfy_cli.workflow_to_api import WorkflowConversionError, convert_ui_to_api
17
18
  from comfy_cli.workspace_manager import WorkspaceManager
18
19
 
19
20
  workspace_manager = WorkspaceManager()
20
21
 
21
22
 
22
- def load_api_workflow(file: str):
23
- with open(file, encoding="utf-8") as f:
24
- workflow = json.load(f)
25
- # Check for litegraph properties to ensure this isnt a UI workflow file
26
- if "nodes" in workflow and "links" in workflow:
27
- return None
23
+ def is_ui_workflow(workflow) -> bool:
24
+ return (
25
+ isinstance(workflow, dict)
26
+ and isinstance(workflow.get("nodes"), list)
27
+ and isinstance(workflow.get("links"), list)
28
+ )
28
29
 
29
- # Try validating the first entry to ensure it has a node class property
30
- node_id = next(iter(workflow))
31
- node = workflow[node_id]
32
- if "class_type" not in node:
33
- return None
34
30
 
35
- return workflow
31
+ def _validate_api_workflow(workflow):
32
+ """Return the workflow dict if it has the shape of API format, else None."""
33
+ if not isinstance(workflow, dict) or not workflow:
34
+ return None
35
+ node = workflow[next(iter(workflow))]
36
+ if not isinstance(node, dict) or "class_type" not in node:
37
+ return None
38
+ return workflow
36
39
 
37
40
 
38
- def execute(workflow: str, host, port, wait=True, verbose=False, local_paths=False, timeout=30):
41
+ def fetch_object_info(host: str, port: int, timeout: int) -> dict:
42
+ """GET ``/object_info`` from the running ComfyUI server.
43
+
44
+ The response describes every loaded node class's input schema and is what
45
+ the converter uses to map widget values to input names, fill defaults, etc.
46
+ """
47
+ url = f"http://{host}:{port}/object_info"
48
+ try:
49
+ with request.urlopen(url, timeout=timeout) as resp:
50
+ body = resp.read()
51
+ except urllib.error.HTTPError as e:
52
+ body = e.read().decode("utf-8", errors="replace").strip()
53
+ pprint(f"[bold red]Failed to fetch /object_info (HTTP {e.code}): {body[:500]}[/bold red]")
54
+ raise typer.Exit(code=1) from e
55
+ except urllib.error.URLError as e:
56
+ pprint(f"[bold red]Failed to fetch /object_info: {e.reason}[/bold red]")
57
+ raise typer.Exit(code=1) from e
58
+ except TimeoutError as e:
59
+ pprint(f"[bold red]Failed to fetch /object_info: timed out after {timeout}s[/bold red]")
60
+ raise typer.Exit(code=1) from e
61
+ try:
62
+ return json.loads(body)
63
+ except json.JSONDecodeError as e:
64
+ pprint("[bold red]Failed to fetch /object_info: server returned invalid JSON[/bold red]")
65
+ raise typer.Exit(code=1) from e
66
+
67
+
68
+ def execute(
69
+ workflow: str,
70
+ host,
71
+ port,
72
+ wait=True,
73
+ verbose=False,
74
+ local_paths=False,
75
+ timeout=30,
76
+ api_key: str | None = None,
77
+ ):
39
78
  workflow_name = os.path.abspath(os.path.expanduser(workflow))
40
79
  if not os.path.isfile(workflow):
41
80
  pprint(
@@ -44,16 +83,51 @@ def execute(workflow: str, host, port, wait=True, verbose=False, local_paths=Fal
44
83
  )
45
84
  raise typer.Exit(code=1)
46
85
 
47
- workflow = load_api_workflow(workflow)
48
-
49
- if not workflow:
50
- pprint("[bold red]Specified workflow does not appear to be an API workflow json file[/bold red]")
51
- raise typer.Exit(code=1)
52
-
53
86
  if not check_comfy_server_running(port, host):
54
87
  pprint(f"[bold red]ComfyUI not running on specified address ({host}:{port})[/bold red]")
55
88
  raise typer.Exit(code=1)
56
89
 
90
+ try:
91
+ with open(workflow_name, encoding="utf-8") as f:
92
+ raw_workflow = json.load(f)
93
+ except OSError as e:
94
+ pprint(f"[bold red]Unable to read workflow file: {e}[/bold red]")
95
+ raise typer.Exit(code=1) from e
96
+ except json.JSONDecodeError as e:
97
+ pprint(f"[bold red]Specified workflow file is not valid JSON: {e}[/bold red]")
98
+ raise typer.Exit(code=1) from e
99
+
100
+ if is_ui_workflow(raw_workflow):
101
+ pprint("[yellow]Detected UI-format workflow, converting to API format...[/yellow]")
102
+ object_info = fetch_object_info(host, port, timeout)
103
+ try:
104
+ workflow = convert_ui_to_api(raw_workflow, object_info)
105
+ except WorkflowConversionError as e:
106
+ pprint(f"[bold red]Workflow conversion failed: {e}[/bold red]")
107
+ raise typer.Exit(code=1) from e
108
+ except Exception as e:
109
+ # The converter is experimental; an unexpected crash here is a bug
110
+ # in our code, not user error. Show a clean message and a pointer.
111
+ pprint(
112
+ f"[bold red]Workflow conversion crashed unexpectedly: {type(e).__name__}: {e}[/bold red]\n"
113
+ "[yellow]The UI-to-API converter is experimental. Please report this at[/yellow]\n"
114
+ "[yellow] https://github.com/Comfy-Org/comfy-cli/issues[/yellow]\n"
115
+ "[yellow]and attach the workflow file if possible.[/yellow]"
116
+ )
117
+ if verbose:
118
+ import traceback
119
+
120
+ traceback.print_exc()
121
+ raise typer.Exit(code=1) from e
122
+ if not workflow:
123
+ pprint("[bold red]Workflow conversion produced no executable nodes[/bold red]")
124
+ raise typer.Exit(code=1)
125
+ else:
126
+ workflow = _validate_api_workflow(raw_workflow)
127
+ if not workflow:
128
+ pprint("[bold red]Specified workflow does not appear to be an API workflow json file[/bold red]")
129
+ raise typer.Exit(code=1)
130
+
57
131
  progress = None
58
132
  start = time.time()
59
133
  if wait:
@@ -63,7 +137,7 @@ def execute(workflow: str, host, port, wait=True, verbose=False, local_paths=Fal
63
137
  else:
64
138
  print(f"Queuing workflow: {workflow_name}")
65
139
 
66
- execution = WorkflowExecution(workflow, host, port, verbose, progress, local_paths, timeout)
140
+ execution = WorkflowExecution(workflow, host, port, verbose, progress, local_paths, timeout, api_key=api_key)
67
141
 
68
142
  try:
69
143
  if wait:
@@ -117,7 +191,7 @@ class ExecutionProgress(Progress):
117
191
 
118
192
 
119
193
  class WorkflowExecution:
120
- def __init__(self, workflow, host, port, verbose, progress, local_paths, timeout=30):
194
+ def __init__(self, workflow, host, port, verbose, progress, local_paths, timeout=30, api_key: str | None = None):
121
195
  self.workflow = workflow
122
196
  self.host = host
123
197
  self.port = port
@@ -136,14 +210,20 @@ class WorkflowExecution:
136
210
  self.prompt_id = None
137
211
  self.ws = None
138
212
  self.timeout = timeout
213
+ self.api_key = api_key
139
214
 
140
215
  def connect(self):
141
216
  self.ws = WebSocket()
142
217
  self.ws.connect(f"ws://{self.host}:{self.port}/ws?clientId={self.client_id}")
143
218
 
144
219
  def queue(self):
145
- data = {"prompt": self.workflow, "client_id": self.client_id}
146
- req = request.Request(f"http://{self.host}:{self.port}/prompt", json.dumps(data).encode("utf-8"))
220
+ data: dict = {"prompt": self.workflow, "client_id": self.client_id}
221
+ if self.api_key:
222
+ data["extra_data"] = {"api_key_comfy_org": self.api_key}
223
+ req = request.Request(
224
+ f"http://{self.host}:{self.port}/prompt",
225
+ json.dumps(data).encode("utf-8"),
226
+ )
147
227
  try:
148
228
  resp = request.urlopen(req)
149
229
  body = json.loads(resp.read())
@@ -47,6 +47,7 @@ CONFIG_KEY_UV_COMPILE_DEFAULT = "uv_compile_default"
47
47
 
48
48
  CIVITAI_API_TOKEN_KEY = "civitai_api_token"
49
49
  CIVITAI_API_TOKEN_ENV_KEY = "CIVITAI_API_TOKEN"
50
+ CIVITAI_ALLOWED_HOSTS: tuple[str, ...] = ("civitai.com", "civitai.red")
50
51
  HF_API_TOKEN_KEY = "hf_api_token"
51
52
  HF_API_TOKEN_ENV_KEY = "HF_API_TOKEN"
52
53