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
133 changes: 125 additions & 8 deletions comfy_cli/command/generate/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,28 @@

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

comfy generate <model> [--<param> value]... [--download P] [--async]
comfy generate <model> [--<param> value]... [--download P] [--async] [--yes]
comfy generate list [--partner P] [--style S]
comfy generate schema <model>
comfy generate refresh
comfy generate resume <model> <job_id> [--download P]
comfy generate consent [show|always|ask]

The first positional is either a reserved action (``list``/``schema``/
``refresh``/``resume``) or a model alias (``flux-pro``, ``ideogram-edit``, …).
Anything not in the reserved set falls through to the generate path.
``refresh``/``resume``/``consent``) or a model alias (``flux-pro``,
``ideogram-edit``, …). Anything not in the reserved set falls through to the
generate path.

Spend gate: a generation call spends Comfy credits, so the proxy call sits
behind a consent interlock (``_confirm_spend``) — an interactive TTY prompt,
bypassed by ``--yes`` or the persisted ``spend.auto_confirm`` config, and
fail-closed (error, no spend) when neither is present and no prompt is
possible (``--json`` or no TTY).
"""

from __future__ import annotations

import sys
import uuid
from pathlib import Path
from typing import Annotated
Expand All @@ -32,8 +41,9 @@
from rich import print as rprint
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn

from comfy_cli import tracking, ui
from comfy_cli import constants, tracking, ui
from comfy_cli.command.generate import adapters, client, emit, output, poll, schema, spec, upload
from comfy_cli.config_manager import ConfigManager
from comfy_cli.output.renderer import get_renderer

_HELP = "Generate images via ComfyUI partner nodes (Flux, Ideogram, DALL·E, Recraft, Stability, …)."
Expand All @@ -58,7 +68,7 @@ def _generate_entry(
str | None,
typer.Argument(
help="A model alias (e.g. flux-pro, ideogram-edit, dalle) "
"or one of: list, schema, refresh, upload, resume.",
"or one of: list, schema, refresh, upload, resume, consent.",
),
] = None,
) -> None:
Expand Down Expand Up @@ -87,12 +97,16 @@ def _generate_entry(
{"model": resume_model, "job_id": resume_job_id},
)
return _resume(extra)
if target == "consent":
consent_action = extra[0] if extra and not extra[0].startswith("-") else None
tracking.track_event("generate:consent", {"action": consent_action})
return _consent(extra)
_generate(target, extra)


def _separate_meta_flags(extra_args: list[str]) -> tuple[list[str], dict[str, str | bool]]:
"""Pull run-level flags out of the user's argv tail."""
meta_names = {"download", "async", "json", "timeout", "api-key", "emit-workflow", "output-prefix"}
meta_names = {"download", "async", "json", "timeout", "api-key", "emit-workflow", "output-prefix", "yes"}
meta: dict[str, str | bool] = {}
remaining: list[str] = []
i = 0
Expand All @@ -104,7 +118,7 @@ def _separate_meta_flags(extra_args: list[str]) -> tuple[list[str], dict[str, st
if "=" in body:
body, raw = body.split("=", 1)
if body in meta_names:
if body in {"async", "json"}:
if body in {"async", "json", "yes"}:
meta[body] = True if raw is None else raw.lower() not in {"false", "0", "no"}
i += 1
continue
Expand Down Expand Up @@ -173,6 +187,90 @@ def _emit_result(result: poll.PollResult, *, request_id: str, download: str | No
rprint("[yellow]--download requested but no image URLs found in response.[/yellow]")


class SpendNotConfirmed(RuntimeError):
"""A credit-spending call lacked consent — declined at the prompt, or
fail-closed because no prompt was possible and nothing pre-authorized it."""


def _spend_auto_confirmed() -> bool:
"""True only when the persisted ``spend.auto_confirm`` config is an
affirmative boolean. A missing key or a garbage value never authorizes
spending — the gate fails closed."""
try:
return bool(ConfigManager().get_bool(constants.CONFIG_KEY_SPEND_AUTO_CONFIRM))
except ValueError:
return False


def _stdin_is_tty() -> bool:
try:
return sys.stdin is not None and sys.stdin.isatty()
except ValueError:
# stdin already closed (e.g. daemonized caller) — no prompt possible.
return False


def _confirm_spend(*, model_name: str, assume_yes: bool, as_json: bool) -> None:
"""The money interlock: explicit consent before a credit-spending proxy call.

- ``--yes`` or persisted ``spend.auto_confirm=true`` → proceed.
- Interactive TTY → prompt, default No.
- ``--json`` or no TTY with neither → fail closed: raise, spend nothing.
Never hang on a prompt a machine caller can't answer.
"""
if assume_yes or _spend_auto_confirmed():
return
if as_json or not _stdin_is_tty():
msg = (
f"`comfy generate {model_name}` spends Comfy credits and no consent was given. "
"Re-run with --yes, or persist consent with `comfy generate consent always`."
)
if as_json:
output.print_json({"error": msg, "code": "spend_consent_required"})
else:
rprint(f"[bold red]{msg}[/bold red]")
raise SpendNotConfirmed(msg)
rprint(f"[bold]{model_name}[/bold] runs via the partner API and [bold]spends Comfy credits[/bold].")
rprint("[dim]Skip this prompt with --yes; persist always-proceed with `comfy generate consent always`.[/dim]")
if not typer.confirm("Proceed?", default=False):
rprint("Canceled — no credits were spent.")
raise SpendNotConfirmed("user declined the spend confirmation prompt")


def _consent(extra_args: list[str]) -> None:
"""``comfy generate consent [show|always|ask]`` — inspect or persist the
spend gate's always-proceed setting (``spend.auto_confirm`` in config.ini,
the same store that backs the CLI's other persisted settings)."""
try:
clean, meta = _separate_meta_flags(extra_args)
except schema.SchemaError as e:
rprint(f"[bold red]{e}[/bold red]")
raise typer.Exit(code=1)
as_json = bool(meta.get("json", False))
action = clean[0] if clean and not clean[0].startswith("-") else "show"
if action not in {"show", "always", "ask"}:
msg = f"Unknown consent action {action!r}. Usage: comfy generate consent [show|always|ask]"
if as_json:
output.print_json({"error": msg})
else:
rprint(f"[bold red]{msg}[/bold red]")
raise typer.Exit(code=1)
if action == "always":
ConfigManager().set(constants.CONFIG_KEY_SPEND_AUTO_CONFIRM, "true")
elif action == "ask":
ConfigManager().set(constants.CONFIG_KEY_SPEND_AUTO_CONFIRM, "false")
auto = _spend_auto_confirmed()
if as_json:
output.print_json({"spend_auto_confirm": auto, "action": action})
return
if auto:
rprint("[bold]spend.auto_confirm: true[/bold] — `comfy generate` spends credits without prompting.")
rprint("[dim]Revert with `comfy generate consent ask`.[/dim]")
else:
rprint("[bold]spend.auto_confirm: false[/bold] — credit-spending calls prompt first (or need --yes).")
rprint("[dim]Persist always-proceed with `comfy generate consent always`.[/dim]")


def _generate(model: str, extra_args: list[str]) -> None:
# --help short-circuits before tracking — it's a help-display action, not an execution attempt.
# If the model is unknown, fall through so the tracking path records the schema error.
Expand Down Expand Up @@ -272,6 +370,20 @@ def _track_error(error_kind: str, exc: BaseException) -> None:
)
return

# Spend gate — a proxy call spends Comfy credits, so consent comes
# BEFORE any network side effect (auth refresh, asset uploads, the
# generation request itself). Everything above this line is local:
# spec lookup, arg parsing, emit-workflow.
try:
_confirm_spend(
model_name=str(gen_props["model_alias"] or ep.id),
assume_yes=bool(meta.get("yes", False)),
as_json=as_json,
)
except SpendNotConfirmed as e:
_track_error("consent", e)
raise typer.Exit(code=1) from e

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:
Expand Down Expand Up @@ -683,7 +795,7 @@ def _print_top_help() -> None:
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]")
rprint(" comfy generate <model> [--<param> value]... [--download PATH] [--async] [--yes] [--api-key KEY]")
rprint("")
rprint("[bold]Examples:[/bold]")
rprint(' comfy generate flux-pro --prompt "a cat on the moon" --width 1024 --height 1024 --download cat.png')
Expand All @@ -702,7 +814,12 @@ def _print_top_help() -> None:
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(" comfy generate consent [show|always|ask] Inspect/persist the credit-spend confirmation")
rprint("")
rprint(
"[dim]Generation spends Comfy credits: interactive runs confirm first; pass --yes (or "
"`comfy generate consent always`) to skip, required for --json / non-TTY runs.[/dim]"
)
rprint(
"[dim]Auth: run `comfy cloud login` (session outranks env var), set COMFY_API_KEY, or pass --api-key. Get one at https://platform.comfy.org.[/dim]"
)
2 changes: 2 additions & 0 deletions comfy_cli/command/generate/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ def help_text(endpoint: Endpoint, flags: list[FlagDef]) -> str:
lines.append(" --download <path> Save outputs locally. Supports {request_id}, {index}, {ext}.")
lines.append(" --async Submit and return job id without waiting.")
lines.append(" --json Emit raw JSON response instead of pretty output.")
lines.append(" --yes Skip the credit-spend confirmation (required for --json/non-TTY runs")
lines.append(" unless `comfy generate consent always` was set).")
lines.append(" --timeout <sec> Override sync-poll timeout (default 300).")
lines.append(" --api-key <key> Per-call override. Auth: --api-key > login session > COMFY_API_KEY env.")
lines.append(" --emit-workflow <path> Write a runnable partner-node workflow instead of calling the proxy.")
Expand Down
3 changes: 3 additions & 0 deletions comfy_cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ class PROC(str, Enum):
CONFIG_KEY_MANAGER_GUI_ENABLED = "manager_gui_enabled" # Legacy, kept for backward compatibility
CONFIG_KEY_MANAGER_GUI_MODE = "manager_gui_mode" # Valid: "disable", "enable-gui", "disable-gui", "enable-legacy-gui"
CONFIG_KEY_UV_COMPILE_DEFAULT = "uv_compile_default"
# Persistent "always proceed" for credit-spending `comfy generate` calls
# (the spend gate). Set via `comfy generate consent always|ask`.
CONFIG_KEY_SPEND_AUTO_CONFIRM = "spend.auto_confirm"

CIVITAI_API_TOKEN_KEY = "civitai_api_token"
CIVITAI_API_TOKEN_ENV_KEY = "CIVITAI_API_TOKEN"
Expand Down
6 changes: 6 additions & 0 deletions comfy_cli/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,12 @@ class ErrorCode:
"`generate --emit-workflow` could not build the partner-node workflow.",
"check the model name and that all required inputs are provided",
),
ErrorCode(
"spend_consent_required",
"A credit-spending `comfy generate` call ran non-interactively (--json / no TTY) "
"with no consent — the spend gate fails closed and nothing was spent.",
"re-run with --yes, or persist consent with `comfy generate consent always`",
),
# --- feedback ------------------------------------------------------------
ErrorCode(
"feedback_message_required",
Expand Down
9 changes: 9 additions & 0 deletions comfy_cli/skills/comfy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,15 @@ comfy generate resume <model> <job_id> --download outputs/x.mp4

Mechanical contracts that bite agents — encode them, don't rediscover:

- **Spend gate — generation spends the user's Comfy credits and requires
consent.** Interactive TTY runs confirm before spending; `--json` / non-TTY
runs **fail closed** with error code `spend_consent_required` (exit 1,
nothing spent) unless consent is supplied. Pass `--yes` only when the human
has actually approved the spend — do not reflexively add it to make the
error go away. A human can persist always-proceed with
`comfy generate consent always` (revert: `consent ask`; inspect:
`consent show`). `list` / `schema` / `refresh` / `upload` / `resume` /
`--emit-workflow` spend nothing and are not gated.
- **Machine-readable output:** `generate <model> --json` prints the raw API
response as JSON; `generate upload <file> --json` emits structured
`{url, expires_at, …}`; `generate <model> --emit-workflow out.json` goes
Expand Down
26 changes: 26 additions & 0 deletions tests/comfy_cli/command/generate/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Shared fixtures for the ``comfy generate`` test package."""

import pytest


@pytest.fixture(autouse=True)
def _auto_confirm_spend():
"""Pre-authorize the credit-spend gate (BE-4103) for generate tests.

Most tests in this package exercise behavior *downstream* of the consent
interlock, and under CliRunner there is no TTY — without this, every
execution-path invocation would fail closed before reaching the code
under test. The gate's own tests override this fixture by name in
``test_spend_gate.py`` to put the gate back in force.

ConfigManager is a process-wide singleton, so the teardown removes the
in-memory key rather than relying on the per-test config-dir isolation
alone (the file is isolated; the loaded configparser is not).
"""
from comfy_cli import constants
from comfy_cli.config_manager import ConfigManager

cm = ConfigManager()
cm.set(constants.CONFIG_KEY_SPEND_AUTO_CONFIRM, "true")
yield
cm.config.remove_option("DEFAULT", constants.CONFIG_KEY_SPEND_AUTO_CONFIRM)
Loading
Loading