Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 79 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,41 +7,43 @@
[![Python](https://img.shields.io/pypi/pyversions/comfy-cli)](https://pypi.org/project/comfy-cli/)
[![License](https://img.shields.io/pypi/l/comfy-cli)](https://github.com/Comfy-Org/comfy-cli/blob/main/LICENSE)

comfy-cli is a command line tool that helps users easily install and manage
[ComfyUI](https://github.com/comfyanonymous/ComfyUI), a powerful open-source
machine learning framework. With comfy-cli, you can quickly set up ComfyUI,
install packages, and manage custom nodes, all from the convenience of your
terminal.
comfy-cli is a command-line tool for installing, running, and extending
[ComfyUI](https://github.com/comfyanonymous/ComfyUI) — the open-source
generative-media engine. Set up ComfyUI, install custom nodes and models, run
workflows, and call hosted partner image models, all from your terminal.

## Demo

<img src="https://github.com/yoland68/comfy-cli/raw/main/assets/comfy-demo.gif" width="400" alt="Comfy Command Demo">

## Features

- 🚀 Easy installation of ComfyUI with a single command
- 📦 Seamless package management for ComfyUI extensions and dependencies
- 🔧 Custom node management for extending ComfyUI's functionality
- 🗄️ Download checkpoints and save model hash
- 💻 Cross-platform compatibility (Windows, macOS, Linux)
- 📖 Comprehensive documentation and examples
- 🎉 install pull request to ComfyUI automatically
- 🚀 One-command ComfyUI install and launch
- 🎨 Direct calls to partner image and video nodes (Flux, Ideogram, DALL·E, Recraft, Stability, Kling, Luma, Runway, Pika, Vidu, Hailuo, …) via `comfy generate`, no workflow JSON required
- 🔧 Custom node management — install, update, snapshot, bisect
- 📦 Fast dependency resolution with `uv` (`--fast-deps`, `--uv-compile`)
- 🗄️ Model downloads from CivitAI, Hugging Face, and direct URLs
- 🎬 Run workflows against a local ComfyUI server, including auto-conversion of UI-format JSON
- 🧪 Test ComfyUI and frontend pull requests with one flag
- 💻 Cross-platform: Windows, macOS, Linux

## Installation

1. (Recommended, but not necessary) Enable virtual environment ([venv](https://docs.python.org/3/library/venv.html)/[conda](https://conda.io/projects/conda/en/latest/user-guide/getting-started.html))
1. (Recommended) Activate a virtual environment ([venv](https://docs.python.org/3/library/venv.html) or [conda](https://conda.io/projects/conda/en/latest/user-guide/getting-started.html)).

2. To install comfy-cli, make sure you have Python 3.9 or higher installed on your system. Then, run the following command:
2. Install with `pip` (requires Python 3.10+):

`pip install comfy-cli`
```bash
pip install comfy-cli
```

### Shell Autocomplete

To install autocompletion hints in your shell run:
Install shell completion so `comfy <TAB>` expands commands and options:

`comfy --install-completion`

This enables you to type `comfy [TAP]` to autocomplete commands and options
```bash
comfy --install-completion
```

## Usage

Expand Down Expand Up @@ -284,6 +286,57 @@ the bisect tool can help you pinpoint the custom node that causes the issue.

`comfy model list ?[--relative-path <PATH>]`

### Calling partner nodes (`comfy generate`)

`comfy generate` calls Comfy's partner nodes directly from the terminal — no
local ComfyUI or workflow JSON required. It hits the same hosted partner nodes
you'd otherwise wire into a ComfyUI workflow, but as one-shot CLI calls. Image
models (Flux, Ideogram, DALL·E, Recraft, Stability, Runway, Reve, xAI Grok, …)
and video models (Kling, Luma, Runway Gen-3, Pika, Vidu, Moonvalley, Hailuo,
Grok video) are all covered; video jobs run async and the CLI polls until the
result is ready.

Prerequisites — a Comfy API key and a credit balance:

- [Create an API key](https://docs.comfy.org/development/comfyui-server/api-key-integration)
- [Browse partner nodes and per-call credit costs](https://docs.comfy.org/tutorials/partner-nodes/overview) · [pricing table](https://docs.comfy.org/tutorials/partner-nodes/pricing)
- [Add credits](https://docs.comfy.org/interface/credits)

Set the key once, then go:

```bash
export COMFY_API_KEY=comfyui-... # or pass --api-key on each call

comfy generate list # browse available models
comfy generate schema flux-pro # see params for one model
comfy generate flux-pro --prompt "a cat on the moon" \
--width 1024 --height 1024 --download cat.png
```

Reference images can be passed as local paths — the CLI uploads them through
the cloud's storage endpoint (or base64-encodes inline, as each partner
requires):

```bash
comfy generate flux-kontext --prompt "add a top hat" \
--input_image ./photo.jpg --download out.png

comfy generate upload ./photo.jpg # explicit upload
```

Async models (every video model plus the Flux family) block until ready by
default. Pass `--async` to return immediately with a job id, then resume later
with `comfy generate resume <model> <job_id>`. Examples:

```bash
comfy generate kling --prompt "a paper boat drifting on a river at dusk" \
--duration 5 --download boat.mp4

comfy generate luma --prompt "..." --aspect_ratio 16:9 --async
# → prints job id; resume with:
comfy generate resume luma <job_id> --download out.mp4
```

Comment thread
robinjhuang marked this conversation as resolved.
### Managing ComfyUI-Manager

- Disable ComfyUI-Manager completely (no manager flags passed to ComfyUI):
Expand Down Expand Up @@ -369,19 +422,19 @@ Check out the usage here: [Mixpanel Board](https://mixpanel.com/p/13hGfPfEPdRkjP

## Contributing

We welcome contributions to comfy-cli! If you have any ideas, suggestions, or
bug reports, please open an issue on our [GitHub
repository](https://github.com/yoland68/comfy-cli/issues). If you'd like to contribute code,
please fork the repository and submit a pull request.
We welcome contributions to comfy-cli! For ideas, suggestions, or bug reports,
open an issue at [Comfy-Org/comfy-cli](https://github.com/Comfy-Org/comfy-cli/issues).
For code changes, fork the repo and open a pull request.

Check out the [Dev Guide](/DEV_README.md) for more details.
See the [Dev Guide](/DEV_README.md) for setup details.

## License

comfy is released under the [GNU General Public License v3.0](https://github.com/yoland68/comfy-cli/blob/master/LICENSE).
Released under the [GNU General Public License v3.0](https://github.com/Comfy-Org/comfy-cli/blob/main/LICENSE).

## Support

If you encounter any issues or have questions about comfy-cli, please [open an issue](https://github.com/comfy-cli/issues) on our GitHub repository or contact us on [Discord](https://discord.com/invite/comfyorg). We'll be happy to assist you!
Questions or issues? [Open an issue](https://github.com/Comfy-Org/comfy-cli/issues)
or reach us on [Discord](https://discord.com/invite/comfyorg).

Happy diffusing with ComfyUI and comfy-cli! 🎉
122 changes: 110 additions & 12 deletions comfy_cli/command/generate/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""``comfy generate`` — call ComfyUI cloud models from the CLI.
"""``comfy generate`` — call ComfyUI partner nodes from the CLI.

UX shape, modeled on fal-ai's genmedia but creative-user-first:

Expand All @@ -16,6 +16,7 @@
from __future__ import annotations

import uuid
from pathlib import Path
from typing import Annotated

import httpx
Expand All @@ -24,9 +25,9 @@
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn

from comfy_cli import tracking, ui
from comfy_cli.command.generate import client, output, poll, schema, spec
from comfy_cli.command.generate import client, output, poll, schema, spec, upload

_HELP = "Generate images via ComfyUI cloud models (Flux, Ideogram, DALL·E, Recraft, Stability, …)."
_HELP = "Generate images via ComfyUI partner nodes (Flux, Ideogram, DALL·E, Recraft, Stability, …)."

_CONTEXT_SETTINGS = {
"allow_extra_args": True,
Expand All @@ -48,7 +49,8 @@ def _generate_entry(
target: Annotated[
str | None,
typer.Argument(
help="A model alias (e.g. flux-pro, ideogram-edit, dalle) or one of: list, schema, refresh, resume.",
help="A model alias (e.g. flux-pro, ideogram-edit, dalle) "
"or one of: list, schema, refresh, upload, resume.",
),
] = None,
) -> None:
Expand All @@ -61,6 +63,8 @@ def _generate_entry(
return _schema(list(ctx.args))
if target == "refresh":
return _refresh()
if target == "upload":
return _upload(list(ctx.args))
if target == "resume":
return _resume(list(ctx.args))
_generate(target, list(ctx.args))
Expand Down Expand Up @@ -184,6 +188,12 @@ def _generate(model: str, extra_args: list[str]) -> None:
download = meta.get("download") if isinstance(meta.get("download"), str) else None
as_json = bool(meta.get("json", False))

try:
_apply_upload_transforms(values, flags, ep, api_key)
except (client.ApiError, httpx.HTTPError) as e:
rprint(f"[bold red]Upload failed: {e}[/bold red]")
raise typer.Exit(code=1)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

request_id = str(uuid.uuid4())[:8]
try:
resp = client.send_request(ep, values, flags, api_key, timeout=timeout)
Expand Down Expand Up @@ -213,7 +223,7 @@ def _generate(model: str, extra_args: list[str]) -> None:
raise typer.Exit(code=1)

if ep.polling:
job_id = str(body.get("id") or (body.get("data") or {}).get("task_id") or request_id)
job_id = poll.extract_job_id(ep.polling, body) or request_id
name = spec.preferred_alias(ep.id) or ep.id
if do_async:
if as_json:
Expand All @@ -231,7 +241,13 @@ def _generate(model: str, extra_args: list[str]) -> None:
def _on_progress(p: float) -> None:
prog.update(task, description=f"Generating ({p * 100:.0f}%)")

result = poller(body, api_key=api_key, timeout=timeout, on_progress=_on_progress)
result = poller(
body,
api_key=api_key,
timeout=timeout,
on_progress=_on_progress,
create_path=ep.path,
)
_emit_result(result, request_id=job_id, download=download, as_json=as_json)
return

Expand Down Expand Up @@ -298,6 +314,81 @@ def _refresh() -> None:
rprint(f"[bold green]Refreshed model catalog at {path}[/bold green]")


def _upload(extra_args: list[str]) -> None:
"""`comfy generate upload <file-or-url> [--json] [--api-key K]`."""
try:
remaining, meta = _separate_meta_flags(extra_args)
except schema.SchemaError as e:
rprint(f"[bold red]{e}[/bold red]")
raise typer.Exit(code=1)
# `remaining` already excludes recognized --meta flags AND their values, so
# `comfy generate upload --api-key KEY ./img.png` correctly resolves to "./img.png".
if not remaining:
rprint("[bold red]Usage: comfy generate upload <file-or-url> [--json][/bold red]")
raise typer.Exit(code=1)
target = remaining[0]
try:
api_key = client.resolve_api_key(meta.get("api-key") if isinstance(meta.get("api-key"), str) else None)
except client.ApiError as e:
rprint(f"[bold red]{e}[/bold red]")
raise typer.Exit(code=1)
as_json = bool(meta.get("json", False))
try:
result = upload.upload_target(target, api_key)
except (client.ApiError, httpx.HTTPError) as e:
rprint(f"[bold red]Upload failed: {e}[/bold red]")
raise typer.Exit(code=1)
if as_json:
output.print_json(
{
"url": result.url,
"expires_at": result.expires_at,
"existing_file": result.existing_file,
"hint": "Pass this URL as the model's image/input_image field.",
}
)
return
rprint(f"[bold green]Uploaded:[/bold green] {result.url}")
if result.expires_at:
rprint(f" expires: {result.expires_at}")
if result.existing_file:
rprint(" [dim](server already had a hash-match; no bytes transferred)[/dim]")


def _apply_upload_transforms(values: dict, flags: list[schema.FlagDef], endpoint: spec.Endpoint, api_key: str) -> None:
"""When the user supplies a local file path for a field that expects a
base64 blob or a URL, transform it transparently.

This only applies to JSON endpoints — multipart endpoints already stream
file paths natively via httpx and don't need pre-uploading.
"""
if endpoint.request_content_type != "application/json":
return
flag_by_name = {f.name: f for f in flags}
for name, value in list(values.items()):
flag = flag_by_name.get(name)
if flag is None or flag.upload_mode is None or not isinstance(value, str):
continue
if value.startswith(("http://", "https://", "data:")):
continue
path = Path(value).expanduser()
if not path.is_file():
continue
if flag.upload_mode == "base64":
import base64 as _base64

try:
data = path.read_bytes()
except OSError as e:
raise client.ApiError(0, "", f"Unable to read file for --{name}: {path} ({e})") from e
values[name] = _base64.b64encode(data).decode("ascii")
rprint(f"[dim]base64-encoded {path.name} for --{name}[/dim]")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
elif flag.upload_mode == "url":
rprint(f"[dim]uploading {path.name} for --{name}…[/dim]")
result = upload.upload_path(path, api_key)
values[name] = result.url


def _resume(extra_args: list[str]) -> None:
if len(extra_args) < 2:
rprint("[bold red]Usage: comfy generate resume <model> <job_id> [--download PATH] [--json][/bold red]")
Expand Down Expand Up @@ -326,10 +417,10 @@ def _resume(extra_args: list[str]) -> None:
download = meta.get("download") if isinstance(meta.get("download"), str) else None
as_json = bool(meta.get("json", False))

if ep.polling == "bfl":
initial = {"polling_url": f"{spec.base_url()}/proxy/bfl/get_result?id={job_id}"}
else:
rprint(f"[bold red]Resume not implemented for partner {ep.partner}[/bold red]")
try:
initial = poll.build_synthetic_initial(ep.polling, job_id, base_url=spec.base_url())
except client.ApiError as e:
rprint(f"[bold red]{e}[/bold red]")
raise typer.Exit(code=1)

poller = poll.get_poller(ep.polling)
Expand All @@ -339,13 +430,19 @@ def _resume(extra_args: list[str]) -> None:
def _on_progress(p: float) -> None:
prog.update(task, description=f"Job {job_id} ({p * 100:.0f}%)")

result = poller(initial, api_key=api_key, timeout=timeout, on_progress=_on_progress)
result = poller(
initial,
api_key=api_key,
timeout=timeout,
on_progress=_on_progress,
create_path=ep.path,
)
_emit_result(result, request_id=job_id, download=download, as_json=as_json)


def _print_top_help() -> None:
"""Custom help that emphasizes the model-first UX over Typer's auto-help."""
rprint("[bold]comfy generate[/bold] — call ComfyUI cloud models")
rprint("[bold]comfy generate[/bold] — call ComfyUI partner nodes")
rprint("")
rprint("[bold]Usage:[/bold]")
rprint(" comfy generate <model> [--<param> value]... [--download PATH] [--async] [--api-key KEY]")
Expand All @@ -361,6 +458,7 @@ def _print_top_help() -> None:
rprint(" comfy generate list Browse available models")
rprint(" comfy generate schema <model> Show parameters for a model")
rprint(" comfy generate refresh Refresh the model catalog")
rprint(" comfy generate upload <file-or-url> Host a local file or remote URL and print its signed URL")
rprint(" comfy generate resume <model> <job> Resume an async job")
rprint("")
rprint("[dim]Auth: set COMFY_API_KEY or pass --api-key. Get one at https://platform.comfy.org.[/dim]")
12 changes: 11 additions & 1 deletion comfy_cli/command/generate/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,21 @@ def _resolve_template(template: str, request_id: str, index: int, ext: str) -> P


def save_urls(urls: list[str], template: str, request_id: str) -> list[Path]:
"""Download each URL and save under the resolved template path. Returns saved paths."""
"""Download each URL and save under the resolved template path. Returns saved paths.

Multi-URL responses (a video + thumbnail from Luma, for example) need a
per-output filename. If the template has no ``{index}`` placeholder and
isn't a directory shorthand, we auto-insert ``_<i>`` before the suffix
and switch the extension to whatever the URL actually points at — so a
user who typed ``--download out.mp4`` doesn't silently get a thumbnail
JPEG written into ``out.mp4`` because the model returned two URLs."""
saved: list[Path] = []
auto_index = len(urls) > 1 and "{index}" not in template and not template.endswith(("/", "\\"))
for i, url in enumerate(urls):
ext = _ext_from_url(url)
dest = _resolve_template(template, request_id, i, ext)
if auto_index:
dest = dest.with_name(f"{dest.stem}_{i}.{ext}")
dest.parent.mkdir(parents=True, exist_ok=True)
data = client.download_bytes(url)
dest.write_bytes(data)
Expand Down
Loading
Loading