comfy-cli 1.7.3__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.3 → comfy_cli-1.8.0}/PKG-INFO +1 -1
  2. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/cmdline.py +17 -1
  3. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/code_search.py +46 -14
  4. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/command.py +11 -0
  5. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/install.py +138 -9
  6. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/run.py +103 -23
  7. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/git_utils.py +20 -3
  8. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/registry/config_parser.py +203 -5
  9. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/tracking.py +9 -1
  10. comfy_cli-1.8.0/comfy_cli/workflow_to_api.py +1369 -0
  11. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli.egg-info/PKG-INFO +1 -1
  12. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli.egg-info/SOURCES.txt +1 -0
  13. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/pyproject.toml +1 -1
  14. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/LICENSE +0 -0
  15. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/README.md +0 -0
  16. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/__init__.py +0 -0
  17. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/__main__.py +0 -0
  18. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/__init__.py +0 -0
  19. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/__init__.py +0 -0
  20. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/bisect_custom_nodes.py +0 -0
  21. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/custom_nodes/cm_cli_util.py +0 -0
  22. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/github/pr_info.py +0 -0
  23. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/launch.py +0 -0
  24. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/models/models.py +0 -0
  25. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/command/pr_command.py +0 -0
  26. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/config_manager.py +0 -0
  27. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/constants.py +0 -0
  28. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/cuda_detect.py +0 -0
  29. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/env_checker.py +0 -0
  30. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/file_utils.py +0 -0
  31. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/logging.py +0 -0
  32. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/pr_cache.py +0 -0
  33. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/registry/__init__.py +0 -0
  34. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/registry/api.py +0 -0
  35. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/registry/types.py +0 -0
  36. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/resolve_python.py +0 -0
  37. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/standalone.py +0 -0
  38. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/typing.py +0 -0
  39. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/ui.py +0 -0
  40. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/update.py +0 -0
  41. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/utils.py +0 -0
  42. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/uv.py +0 -0
  43. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli/workspace_manager.py +0 -0
  44. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli.egg-info/dependency_links.txt +0 -0
  45. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli.egg-info/entry_points.txt +0 -0
  46. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli.egg-info/requires.txt +0 -0
  47. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/comfy_cli.egg-info/top_level.txt +0 -0
  48. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/setup.cfg +0 -0
  49. {comfy_cli-1.7.3 → comfy_cli-1.8.0}/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.7.3
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)"),
@@ -975,6 +975,17 @@ def validate_node_for_publishing():
975
975
  # Perform some validation logic here
976
976
  typer.echo("Validating node configuration...")
977
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)
978
989
 
979
990
  # Run security checks first
980
991
  typer.echo("Running security checks...")
@@ -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
 
@@ -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())
@@ -28,9 +28,16 @@ def git_checkout_tag(repo_path: str, tag: str) -> bool:
28
28
  """
29
29
  Checkout a specific Git tag in the given repository.
30
30
 
31
+ Skips the network ``git fetch --tags`` when the tag already exists locally.
32
+ This avoids a redundant round-trip on the happy path (the caller usually
33
+ just cloned the repo or just ran a fetch via the resolver) and lets offline
34
+ installs proceed when the tag is already cached. Only when the tag is
35
+ absent locally do we attempt to fetch — and a failed fetch in that case is
36
+ a real, unrecoverable error (``check=True`` surfaces it as before).
37
+
31
38
  :param repo_path: Path to the Git repository
32
39
  :param tag: The tag to checkout
33
- :return: The output of the git command if successful, None if an error occurred
40
+ :return: True if the checkout succeeds, False if any git command failed.
34
41
  """
35
42
  original_dir = os.getcwd()
36
43
  try:
@@ -38,8 +45,18 @@ def git_checkout_tag(repo_path: str, tag: str) -> bool:
38
45
 
39
46
  os.chdir(repo_path)
40
47
 
41
- # Fetch the latest tags
42
- subprocess.run(["git", "fetch", "--tags"], check=True, capture_output=True, text=True)
48
+ # Skip the network fetch when the tag is already present locally.
49
+ tag_present_locally = (
50
+ subprocess.run(
51
+ ["git", "rev-parse", "--verify", f"refs/tags/{tag}"],
52
+ capture_output=True,
53
+ text=True,
54
+ check=False,
55
+ ).returncode
56
+ == 0
57
+ )
58
+ if not tag_present_locally:
59
+ subprocess.run(["git", "fetch", "--tags"], check=True, capture_output=True, text=True)
43
60
 
44
61
  # Checkout the specified tag
45
62
  subprocess.run(["git", "checkout", tag], check=True, capture_output=True, text=True)