
Comfyui
- 283 installs
- 226k repo stars
- Updated August 5, 2026
- nousresearch/hermes-agent
Connect Hermes agents to ComfyUI workflows so they can generate, batch, and refine images or video nodes from agent-driven prompts and parameters.
About
The comfyui skill enables Hermes agents to operate ComfyUI graphs, submit generation jobs, pass node parameters, and retrieve outputs for content products, creative automation, and multimodal agent workflows.
- ComfyUI workflow invocation
- Parameterized image generation
- Batch and pipeline automation
- Agent-driven media refinement
Comfyui by the numbers
- 283 all-time installs (skills.sh)
- +21 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #526 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/nousresearch/hermes-agent --skill comfyuiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 283 |
|---|---|
| repo stars | ★ 226k |
| Last updated | August 5, 2026 |
| Repository | nousresearch/hermes-agent ↗ |
What it does
Connect Hermes agents to ComfyUI workflows so they can generate, batch, and refine images or video nodes from agent-driven prompts and parameters.
Files
ComfyUI
Generate images, video, audio, and 3D content through ComfyUI using the official comfy-cli for setup/lifecycle and direct REST/WebSocket API for workflow execution.
What's in this skill
Reference docs (`references/`):
official-cli.md— everycomfy ...command, with flagsrest-api.md— REST + WebSocket endpoints (local + cloud), payload schemasworkflow-format.md— API-format JSON, common node types, param mappingtemplate-integrity.md— convertingcomfyui-workflow-templatesfrom
editor format to API format: Reroute bypass, dotted dynamic-input keys (values.a, resize_type.width), Cloud quirks (302 redirect, 1 concurrent free-tier job, 1080p VRAM ceiling), Discord-compatible ffmpeg stitch. Authored by @purzbeats. Load this whenever you're starting from an official template.
Scripts (`scripts/`):
| Script | Purpose |
|---|---|
_common.py | Shared HTTP, cloud routing, node catalogs (don't run directly) |
hardware_check.py | Probe GPU/VRAM/disk → recommend local vs Comfy Cloud |
comfyui_setup.sh | Hardware check + comfy-cli + ComfyUI install + launch + verify |
extract_schema.py | Read a workflow → list controllable params + model deps |
check_deps.py | Check workflow against running server → list missing nodes/models |
auto_fix_deps.py | Run check_deps then comfy node install / comfy model download |
run_workflow.py | Inject params, submit, monitor, download outputs (HTTP or WS) |
run_batch.py | Submit a workflow N times with sweeps, parallel up to your tier |
ws_monitor.py | Real-time WebSocket viewer for executing jobs (live progress) |
health_check.py | Verification checklist runner — comfy-cli + server + models + smoke test |
fetch_logs.py | Pull traceback / status messages for a given prompt_id |
Example workflows (`workflows/`): SD 1.5, SDXL, Flux Dev, SDXL img2img, SDXL inpaint, ESRGAN upscale, AnimateDiff video, Wan T2V. See workflows/README.md.
When to Use
- User asks to generate images with Stable Diffusion, SDXL, Flux, SD3, etc.
- User wants to run a specific ComfyUI workflow file
- User wants to chain generative steps (txt2img → upscale → face restore)
- User needs ControlNet, inpainting, img2img, or other advanced pipelines
- User asks to manage ComfyUI queue, check models, or install custom nodes
- User wants video/audio/3D generation via AnimateDiff, Hunyuan, Wan, AudioCraft, etc.
Architecture: Two Layers
┌─────────────────────────────────────────────────────┐
│ Layer 1: comfy-cli (official lifecycle tool) │
│ Setup, server lifecycle, custom nodes, models │
│ → comfy install / launch / stop / node / model │
└─────────────────────────┬───────────────────────────┘
│
┌─────────────────────────▼───────────────────────────┐
│ Layer 2: REST/WebSocket API + skill scripts │
│ Workflow execution, param injection, monitoring │
│ POST /api/prompt, GET /api/view, WS /ws │
│ → run_workflow.py, run_batch.py, ws_monitor.py │
└─────────────────────────────────────────────────────┘Why two layers? The official CLI is excellent for installation and server management but has minimal workflow execution support. The REST/WS API fills that gap — the scripts handle param injection, execution monitoring, and output download that the CLI doesn't do.
Quick Start
Detect environment
# What's available?
command -v comfy >/dev/null 2>&1 && echo "comfy-cli: installed"
curl -s http://127.0.0.1:8188/system_stats 2>/dev/null && echo "server: running"
# Can this machine run ComfyUI locally? (GPU/VRAM/disk check)
python3 scripts/hardware_check.pyIf nothing is installed, see Setup & Onboarding below — but always run the hardware check first.
One-line health check
python3 scripts/health_check.py
# → JSON: comfy_cli on PATH? server reachable? at least one checkpoint? smoke-test passes?Core Workflow
Step 1: Get a workflow JSON in API format
Workflows must be in API format (each node has class_type). They come from:
- ComfyUI web UI → Workflow → Export (API) (newer UI) or
the legacy "Save (API Format)" button (older UI)
- This skill's
workflows/directory (ready-to-run examples) - Community downloads (civitai, Reddit, Discord) — usually editor format,
must be loaded into ComfyUI then re-exported
Editor format (top-level nodes and links arrays) is not directly executable. The scripts detect this and tell you to re-export.
Step 2: See what's controllable
python3 scripts/extract_schema.py workflow_api.json --summary-only
# → {"parameter_count": 12, "has_negative_prompt": true, "has_seed": true, ...}
python3 scripts/extract_schema.py workflow_api.json
# → full schema with parameters, model deps, embedding refsStep 3: Run with parameters
# Local (defaults to http://127.0.0.1:8188)
python3 scripts/run_workflow.py \
--workflow workflow_api.json \
--args '{"prompt": "a beautiful sunset over mountains", "seed": -1, "steps": 30}' \
--output-dir ./outputs
# Cloud (export API key once; uses correct /api routing automatically)
export COMFY_CLOUD_API_KEY="comfyui-..."
python3 scripts/run_workflow.py \
--workflow workflow_api.json \
--args '{"prompt": "..."}' \
--host https://cloud.comfy.org \
--output-dir ./outputs
# Real-time progress via WebSocket (requires `pip install websocket-client`)
python3 scripts/run_workflow.py \
--workflow flux_dev.json \
--args '{"prompt": "..."}' \
--ws
# img2img / inpaint: pass --input-image to upload + reference automatically
python3 scripts/run_workflow.py \
--workflow sdxl_img2img.json \
--input-image image=./photo.png \
--args '{"prompt": "make it watercolor", "denoise": 0.6}'
# Batch / sweep: 8 random seeds, parallel up to cloud tier limit
python3 scripts/run_batch.py \
--workflow sdxl.json \
--args '{"prompt": "abstract"}' \
--count 8 --randomize-seed --parallel 3 \
--output-dir ./outputs/batch-1 for seed (or omitting it with --randomize-seed) generates a fresh random seed per run.
Step 4: Present results
The scripts emit JSON to stdout describing every output file:
{
"status": "success",
"prompt_id": "abc-123",
"outputs": [
{"file": "./outputs/sdxl_00001_.png", "node_id": "9",
"type": "image", "filename": "sdxl_00001_.png"}
]
}Decision Tree
| User says | Tool | Command |
|---|---|---|
| Lifecycle (use comfy-cli) | ||
| "install ComfyUI" | comfy-cli | bash scripts/comfyui_setup.sh |
| "start ComfyUI" | comfy-cli | comfy launch --background |
| "stop ComfyUI" | comfy-cli | comfy stop |
| "install X node" | comfy-cli | comfy node install <name> |
| "download X model" | comfy-cli | comfy model download --url <url> --relative-path models/checkpoints |
| "list installed models" | comfy-cli | comfy model list |
| "list installed nodes" | comfy-cli | comfy node show installed |
| Execution (use scripts) | ||
| "is everything ready?" | script | health_check.py (optionally with --workflow X --smoke-test) |
| "what can I change in this workflow?" | script | extract_schema.py W.json |
| "check if W's deps are met" | script | check_deps.py W.json |
| "fix missing deps" | script | auto_fix_deps.py W.json |
| "generate an image" | script | run_workflow.py --workflow W --args '{...}' |
| "use this image" (img2img) | script | run_workflow.py --input-image image=./x.png ... |
| "8 variations with random seeds" | script | run_batch.py --count 8 --randomize-seed ... |
| "show me live progress" | script | ws_monitor.py --prompt-id <id> |
| "fetch the error from job X" | script | fetch_logs.py <prompt_id> |
| Direct REST | ||
| "what's in the queue?" | REST | curl http://HOST:8188/queue (local) or --host https://cloud.comfy.org |
| "cancel that" | REST | curl -X POST http://HOST:8188/interrupt |
| "free GPU memory" | REST | curl -X POST http://HOST:8188/free |
Setup & Onboarding
When a user asks to set up ComfyUI, the FIRST thing to do is ask whether they want Comfy Cloud (hosted, zero install, API key) or Local (install ComfyUI on their machine). Don't start running install commands or hardware checks until they've answered.
Official docs: https://docs.comfy.org/installation CLI docs: https://docs.comfy.org/comfy-cli/getting-started Cloud docs: https://docs.comfy.org/get_started/cloud Cloud API: https://docs.comfy.org/development/cloud/overview
Step 0: Ask Local vs Cloud (ALWAYS FIRST)
Suggested script:
"Do you want to run ComfyUI locally on your machine, or use Comfy Cloud?
>
- Comfy Cloud — hosted on RTX 6000 Pro GPUs, all common models pre-installed,
zero setup. Requires an API key (paid subscription required to actually run
workflows; free tier is read-only). Best if you don't have a capable GPU.
- Local — free, but your machine MUST meet the hardware requirements:
- NVIDIA GPU with ≥6 GB VRAM (≥8 GB for SDXL, ≥12 GB for Flux/video), OR
- AMD GPU with ROCm support (Linux), OR
- Apple Silicon Mac (M1+) with ≥16 GB unified memory (≥32 GB recommended).
- Intel Macs and machines with no GPU will NOT work — use Cloud instead.
>
Which would you like?"
Routing:
- Cloud → skip to Path A.
- Local → run hardware check first, then pick a path from Paths B–E based on the verdict.
- Unsure → run the hardware check and let the verdict decide.
Step 1: Verify Hardware (ONLY if user chose local)
python3 scripts/hardware_check.py --json
# Optional: also probe `torch` for actual CUDA/MPS:
python3 scripts/hardware_check.py --json --check-pytorch| Verdict | Meaning | Action |
|---|---|---|
ok | ≥8 GB VRAM (discrete) OR ≥32 GB unified (Apple Silicon) | Local install — use comfy_cli_flag from report |
marginal | SD1.5 works; SDXL tight; Flux/video unlikely | Local OK for light workflows, else Path A (Cloud) |
cloud | No usable GPU, <6 GB VRAM, <16 GB Apple unified, Intel Mac, Rosetta Python | Switch to Cloud unless user explicitly forces local |
The script also surfaces wsl: true (WSL2 with NVIDIA passthrough) and rosetta: true (x86_64 Python on Apple Silicon — must reinstall as ARM64).
If verdict is cloud but the user wants local, do not proceed silently. Show the notes array verbatim and ask whether they want to (a) switch to Cloud or (b) force a local install (will OOM or be unusably slow on modern models).
Choosing an Installation Path
Use the hardware check first. The table below is the fallback for when the user has already told you their hardware:
| Situation | Recommended Path |
|---|---|
verdict: cloud from hardware check | Path A: Comfy Cloud |
| No GPU / want to try without commitment | Path A: Comfy Cloud |
| Windows + NVIDIA + non-technical | Path B: ComfyUI Desktop |
| Windows + NVIDIA + technical | Path C: Portable or Path D: comfy-cli |
| Linux + any GPU | Path D: comfy-cli (easiest) |
| macOS + Apple Silicon | Path B: Desktop or Path D: comfy-cli |
| Headless / server / CI / agents | Path D: comfy-cli |
For the fully automated path (hardware check → install → launch → verify):
bash scripts/comfyui_setup.sh
# Or with overrides:
bash scripts/comfyui_setup.sh --m-series --port=8190 --workspace=/data/comfyIt runs hardware_check.py internally, refuses to install locally when the verdict is cloud (unless --force-cloud-override), picks the right comfy-cli flag, and prefers pipx/uvx over global pip to avoid polluting system Python.
---
Path A: Comfy Cloud (No Local Install)
For users without a capable GPU or who want zero setup. Hosted on RTX 6000 Pro.
Docs: https://docs.comfy.org/get_started/cloud
1. Sign up at https://comfy.org/cloud 2. Generate an API key at https://platform.comfy.org/login 3. Set the key:
export COMFY_CLOUD_API_KEY="comfyui-xxxxxxxxxxxx"4. Run workflows:
python3 scripts/run_workflow.py \
--workflow workflows/flux_dev_txt2img.json \
--args '{"prompt": "..."}' \
--host https://cloud.comfy.org \
--output-dir ./outputsPricing: https://www.comfy.org/cloud/pricing Concurrent jobs: Free/Standard 1, Creator 3, Pro 5. Free tier cannot run workflows via API — only browse models. Paid subscription required for /api/prompt, /api/upload/*, /api/view, etc.
---
Path B: ComfyUI Desktop (Windows / macOS)
One-click installer for non-technical users. Currently Beta.
Docs: https://docs.comfy.org/installation/desktop
- Windows (NVIDIA): https://download.comfy.org/windows/nsis/x64
- macOS (Apple Silicon): https://comfy.org
Linux is not supported for Desktop — use Path D.
---
Path C: ComfyUI Portable (Windows Only)
Docs: https://docs.comfy.org/installation/comfyui_portable_windows
Download from https://github.com/comfyanonymous/ComfyUI/releases, extract, run run_nvidia_gpu.bat. Update via update/update_comfyui_stable.bat.
---
Path D: comfy-cli (All Platforms — Recommended for Agents)
The official CLI is the best path for headless/automated setups.
Docs: https://docs.comfy.org/comfy-cli/getting-started
Install comfy-cli
# Recommended:
pipx install comfy-cli
# Or use uvx without installing:
uvx --from comfy-cli comfy --help
# Or (if pipx/uvx unavailable):
pip install --user comfy-cliDisable analytics non-interactively:
comfy --skip-prompt tracking disableInstall ComfyUI
comfy --skip-prompt install --nvidia # NVIDIA (CUDA)
comfy --skip-prompt install --amd # AMD (ROCm, Linux)
comfy --skip-prompt install --m-series # Apple Silicon (MPS)
comfy --skip-prompt install --cpu # CPU only (slow)
comfy --skip-prompt install --nvidia --fast-deps # uv-based dep resolutionDefault location: ~/comfy/ComfyUI (Linux), ~/Documents/comfy/ComfyUI (macOS/Win). Override with comfy --workspace /custom/path install.
Launch / verify
comfy launch --background # background daemon on :8188
comfy launch -- --listen 0.0.0.0 --port 8190 # LAN-accessible custom port
curl -s http://127.0.0.1:8188/system_stats # health check---
Path E: Manual Install (Advanced / Unsupported Hardware)
For Ascend NPU, Cambricon MLU, Intel Arc, or other unsupported hardware.
Docs: https://docs.comfy.org/installation/manual_install
git clone https://github.com/comfyanonymous/ComfyUI.git
cd ComfyUI
pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu130
pip install -r requirements.txt
python main.py---
Post-Install: Download Models
# SDXL (general purpose, ~6.5 GB)
comfy model download \
--url "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors" \
--relative-path models/checkpoints
# SD 1.5 (lighter, ~4 GB, good for 6 GB cards)
comfy model download \
--url "https://huggingface.co/stable-diffusion-v1-5/stable-diffusion-v1-5/resolve/main/v1-5-pruned-emaonly.safetensors" \
--relative-path models/checkpoints
# Flux Dev fp8 (smaller variant, ~12 GB)
comfy model download \
--url "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev-fp8.safetensors" \
--relative-path models/checkpoints
# CivitAI (set token first):
comfy model download \
--url "https://civitai.com/api/download/models/128713" \
--relative-path models/checkpoints \
--set-civitai-api-token "YOUR_TOKEN"List installed: comfy model list.
Post-Install: Install Custom Nodes
comfy node install comfyui-impact-pack # popular utility pack
comfy node install comfyui-animatediff-evolved # video generation
comfy node install comfyui-controlnet-aux # ControlNet preprocessors
comfy node install comfyui-essentials # common helpers
comfy node update all
comfy node install-deps --workflow=workflow.json # install everything a workflow needsPost-Install: Verify
python3 scripts/health_check.py
# → comfy_cli on PATH? server reachable? checkpoints? smoke test?
python3 scripts/check_deps.py my_workflow.json
# → are this workflow's nodes/models/embeddings installed?
python3 scripts/run_workflow.py \
--workflow workflows/sd15_txt2img.json \
--args '{"prompt": "test", "steps": 4}' \
--output-dir ./test-outputsImage Upload (img2img / Inpainting)
The simplest way is to use --input-image with run_workflow.py:
python3 scripts/run_workflow.py \
--workflow workflows/sdxl_img2img.json \
--input-image image=./photo.png \
--args '{"prompt": "make it cyberpunk", "denoise": 0.6}'The flag uploads photo.png, then injects its server-side filename into whatever schema parameter is named image. For inpainting, pass both:
python3 scripts/run_workflow.py \
--workflow workflows/sdxl_inpaint.json \
--input-image image=./photo.png \
--input-image mask_image=./mask.png \
--args '{"prompt": "fill with flowers"}'Manual upload via REST:
curl -X POST "http://127.0.0.1:8188/upload/image" \
-F "image=@photo.png" -F "type=input" -F "overwrite=true"
# Returns: {"name": "photo.png", "subfolder": "", "type": "input"}
# Cloud equivalent:
curl -X POST "https://cloud.comfy.org/api/upload/image" \
-H "X-API-Key: $COMFY_CLOUD_API_KEY" \
-F "image=@photo.png" -F "type=input" -F "overwrite=true"Cloud Specifics
- Base URL:
https://cloud.comfy.org - Auth:
X-API-Keyheader (or?token=KEYfor WebSocket) - API key: set
$COMFY_CLOUD_API_KEYonce and the scripts pick it up automatically - Output download:
/api/viewreturns a 302 to a signed URL; the scripts
follow it and strip X-API-Key before fetching from the storage backend (don't leak the API key to S3/CloudFront).
- Endpoint differences from local ComfyUI:
/api/object_info,/api/queue,/api/userdata— 403 on free tier;
paid only.
/historyis renamed to/history_v2on cloud (the scripts route
automatically).
/models/<folder>is renamed to/experiment/models/<folder>on cloud
(the scripts route automatically).
clientIdin WebSocket is currently ignored — all connections for a
user receive the same broadcast. Filter by prompt_id client-side.
subfolderis accepted on uploads but ignored — cloud has a flat namespace.- Concurrent jobs: Free/Standard: 1, Creator: 3, Pro: 5. Extras queue
automatically. Use run_batch.py --parallel N to saturate your tier.
Queue & System Management
# Local
curl -s http://127.0.0.1:8188/queue | python3 -m json.tool
curl -X POST http://127.0.0.1:8188/queue -d '{"clear": true}' # cancel pending
curl -X POST http://127.0.0.1:8188/interrupt # cancel running
curl -X POST http://127.0.0.1:8188/free \
-H "Content-Type: application/json" \
-d '{"unload_models": true, "free_memory": true}'
# Cloud — same paths under /api/, plus:
python3 scripts/fetch_logs.py --tail-queue --host https://cloud.comfy.orgPitfalls
1. API format required — every script and the /api/prompt endpoint expect API-format workflow JSON. The scripts detect editor format (top-level nodes and links arrays) and tell you to re-export via "Workflow → Export (API)" (newer UI) or "Save (API Format)" (older UI).
2. Server must be running — all execution requires a live server. comfy launch --background starts one. Verify with curl http://127.0.0.1:8188/system_stats.
3. Model names are exact — case-sensitive, includes file extension. check_deps.py does fuzzy matching (with/without extension and folder prefix), but the workflow itself must use the canonical name. Use comfy model list to discover what's installed.
4. Missing custom nodes — "class_type not found" means a required node isn't installed. check_deps.py reports which package to install; auto_fix_deps.py runs the install for you.
5. Working directory — comfy-cli auto-detects the ComfyUI workspace. If commands fail with "no workspace found", use comfy --workspace /path/to/ComfyUI <command> or comfy set-default /path/to/ComfyUI.
6. Cloud free-tier API limits — /api/prompt, /api/view, /api/upload/*, /api/object_info all return 403 on free accounts. health_check.py and check_deps.py handle this gracefully and surface a clear message.
7. Timeout for video/audio workflows — auto-detected when an output node is VHS_VideoCombine, SaveVideo, etc.; the default jumps from 300 s to 900 s. Override explicitly with --timeout 1800.
8. Path traversal in output filenames — server-supplied filenames are passed through safe_path_join to refuse anything escaping --output-dir. Keep this protection on — workflows with custom save nodes can produce arbitrary paths.
9. Workflow JSON is arbitrary code — custom nodes run Python, so submitting an unknown workflow has the same trust profile as eval. Inspect workflows from untrusted sources before running.
10. Auto-randomized seed — pass seed: -1 in --args (or use --randomize-seed and omit the seed) to get a fresh seed per run. The actual seed is logged to stderr.
11. `tracking` prompt — first run of comfy may prompt for analytics. Use comfy --skip-prompt tracking disable to skip non-interactively. comfyui_setup.sh does this for you.
Verification Checklist
Use python3 scripts/health_check.py to run the whole list at once. Manual:
- [ ]
hardware_check.pyverdict isokOR the user explicitly chose Comfy Cloud - [ ]
comfy --versionworks (oruvx --from comfy-cli comfy --help) - [ ]
curl http://HOST:PORT/system_statsreturns JSON - [ ]
comfy model listshows at least one checkpoint (local) OR
/api/experiment/models/checkpoints returns models (cloud)
- [ ] Workflow JSON is in API format
- [ ]
check_deps.pyreportsis_ready: true(or onlynode_check_skipped
on cloud free tier)
- [ ] Test run with a small workflow completes; outputs land in
--output-dir
comfy-cli Command Reference
Official CLI from Comfy-Org/comfy-cli. Docs: https://docs.comfy.org/comfy-cli/getting-started
Installation
Order of preference:
pipx install comfy-cli # recommended (isolated env)
uvx --from comfy-cli comfy --help # zero-install via uv
pip install --user comfy-cli # fallbackThe skill's comfyui_setup.sh picks the best available method.
First run may prompt for analytics. Disable non-interactively:
comfy --skip-prompt tracking disableGlobal Options
| Option | Description |
|---|---|
--workspace <path> | Target a specific ComfyUI workspace |
--recent | Use most recently used workspace |
--here | Use current directory as workspace |
--skip-prompt | No interactive prompts (use defaults) |
-v / --version | Print version |
Workspace resolution priority: 1. --workspace (explicit path) 2. --recent (from config) 3. --here (cwd) 4. comfy set-default path 5. Most recently used 6. ~/comfy/ComfyUI (Linux) or ~/Documents/comfy/ComfyUI (macOS/Win)
Lifecycle Commands
comfy install
Download and install ComfyUI + ComfyUI-Manager.
comfy install # interactive GPU selection
comfy install --nvidia
comfy install --amd # ROCm (Linux)
comfy install --m-series # Apple Silicon (MPS)
comfy install --cpu # CPU only (slow)
comfy install --fast-deps # use uv for deps
comfy install --skip-manager # skip ComfyUI-Manager| Option | Description |
|---|---|
--nvidia / --amd / --m-series / --cpu | GPU type |
--cuda-version | 11.8, 12.1, 12.4, 12.6, 12.8, 12.9, 13.0 |
--rocm-version | 6.1, 6.2, 6.3, 7.0, 7.1 |
--fast-deps | uv-based dependency resolution |
--skip-manager | Don't install ComfyUI-Manager |
--skip-torch-or-directml | Skip PyTorch install |
--version <ver> | 0.2.0, latest, nightly |
--commit <hash> | Install specific commit |
--pr "#1234" | Install from a PR |
--restore | Restore deps for existing install |
comfy launch
comfy launch # foreground :8188
comfy launch --background # background daemon
comfy launch -- --listen 0.0.0.0 # LAN-accessible
comfy launch -- --port 8190 # custom port
comfy launch -- --cpu # force CPU mode
comfy launch -- --lowvram # 6 GB cards
comfy launch --background -- --listen 0.0.0.0 --port 8190Common extra args after --: --listen, --port, --cpu, --lowvram, --novram, --fp16-vae, --force-fp32, --disable-cuda-malloc.
comfy stop
comfy stopcomfy run
Submit a raw workflow JSON to a running server. Limited — no parameter injection, no structured output download. For agents, use scripts/run_workflow.py instead.
comfy run --workflow workflow_api.json
comfy run --workflow workflow_api.json --host 10.0.0.5 --port 8188
comfy run --workflow workflow_api.json --timeout 300 --waitcomfy which
comfy which # show targeted workspace
comfy --recent whichcomfy set-default
comfy set-default /path/to/ComfyUI
comfy set-default /path/to/ComfyUI --launch-extras="--listen 0.0.0.0"comfy update
comfy update # update ComfyUI core
comfy node update all # update all custom nodes---
comfy node — Custom Node Management
All node operations use ComfyUI-Manager (cm-cli) under the hood.
comfy node show installed # list installed
comfy node show enabled # list enabled
comfy node show all # all available in registry
comfy node simple-show installed # compact list
comfy node install comfyui-impact-pack
comfy node install <name> --uv-compile # ComfyUI-Manager v4.1+ unified resolver
comfy node uninstall <name>
comfy node update <name> | all
comfy node enable <name>
comfy node disable <name>
comfy node fix <name> # fix broken deps
comfy node install-deps --workflow=workflow.json
comfy node deps-in-workflow --workflow=w.json --output=deps.json
comfy node save-snapshot
comfy node restore-snapshot <file>
comfy node bisect start # binary-search a culprit node
comfy node bisect good
comfy node bisect bad
comfy node bisect resetDependency Resolution Options
| Flag | Description |
|---|---|
--fast-deps | comfy-cli built-in uv resolver |
--uv-compile | ComfyUI-Manager v4.1+ unified resolver (recommended) |
--no-deps | Skip dep installation |
Make uv-compile default: comfy manager uv-compile-default true
---
comfy model — Model Management
comfy model list
comfy model list --relative-path models/checkpoints
comfy model download --url <URL>
comfy model download --url <URL> --relative-path models/loras
comfy model download --url <URL> --filename custom_name.safetensors
comfy model remove # interactive
comfy model remove --relative-path models/checkpoints --model-names "model.safetensors"| Option | Description |
|---|---|
--url | Download URL (CivitAI, HuggingFace, direct) |
--relative-path | Subdirectory under workspace (e.g. models/checkpoints) |
--filename | Custom save filename |
--set-civitai-api-token | Persist CivitAI token |
--set-hf-api-token | Persist HuggingFace token |
--downloader | httpx (default) or aria2 |
Standard model directories:
ComfyUI/models/
├── checkpoints/ # Full model files
├── loras/ # LoRA adapters
├── vae/ # VAE models
├── controlnet/ # ControlNet models
├── clip/ # CLIP / T5 text encoders
├── clip_vision/ # CLIP vision encoders
├── upscale_models/ # ESRGAN / SwinIR / etc.
├── embeddings/ # Textual inversion embeddings
├── unet/ # Standalone UNet weights
├── diffusion_models/ # Flux / SD3 / Wan diffusion models
├── animatediff_models/ # AnimateDiff motion modules
├── ipadapter/ # IPAdapter weights
└── style_models/ # Style adapters---
comfy manager — ComfyUI-Manager Settings
comfy manager disable # disable Manager completely
comfy manager enable-gui # enable new GUI
comfy manager disable-gui # API-only
comfy manager enable-legacy-gui # legacy GUI
comfy manager uv-compile-default true # make --uv-compile the default
comfy manager clear # clear startup action---
comfy pr-cache — Frontend PR Cache
comfy pr-cache list
comfy pr-cache clean
comfy pr-cache clean 456Cache expires after 7 days; max 10 builds.
---
Configuration
| OS | Path |
|---|---|
| Linux | ~/.config/comfy-cli/config.ini |
| macOS | ~/Library/Application Support/comfy-cli/config.ini |
| Windows | ~/AppData/Local/comfy-cli/config.ini |
Stores: default workspace, recent workspace, background server PID, API tokens, manager GUI mode, launch extras.
Discovery
Custom-node registry:
- https://registry.comfy.org/
Model browsers:
- https://huggingface.co/models
- https://civitai.com (NSFW; requires API token for many)
- https://comfyworkflows.com (community workflows)
ComfyUI REST + WebSocket API Reference
ComfyUI exposes a REST + WebSocket interface for workflow execution and management. The same surface is used locally and on Comfy Cloud, with auth/path differences.
Connection
| Local ComfyUI | Comfy Cloud | |
|---|---|---|
| Base URL | http://127.0.0.1:8188 | https://cloud.comfy.org |
| API path prefix | none (/prompt, /view, …) | /api/... (/api/prompt, /api/view, …) |
| Auth | none (or bearer token if configured) | X-API-Key header |
| WebSocket | ws://host:port/ws?clientId={uuid} | wss://cloud.comfy.org/ws?clientId={uuid}&token={API_KEY} |
/api/view response | direct bytes | 302 redirect → signed URL (use curl -L) |
The skill scripts route URLs automatically via _common.resolve_url().
Endpoint differences on Comfy Cloud
The cloud surface diverges from local ComfyUI in several ways. The skill scripts handle these transparently; document them here so anyone calling curl directly knows.
| Local path | Cloud path | Notes |
|---|---|---|
/system_stats | /api/system_stats | Cloud version is public (no auth required) |
/object_info | /api/object_info | Paid tier only — free returns 403 |
/queue | /api/queue | Paid tier only |
/userdata | /api/userdata | Paid tier only |
/prompt (POST) | /api/prompt (POST) | Paid tier only |
/upload/image | /api/upload/image | Paid tier only; subfolder accepted but ignored |
/upload/mask | /api/upload/mask | Same as above |
/view | /api/view | Paid tier only; returns 302 to signed URL |
/history | /api/history_v2 | Renamed; old path returns 404 |
/history/{id} | /api/history_v2/{id} or /api/jobs/{id} | Both work; /jobs returns full job |
/models | /api/experiment/models | Renamed |
/models/{folder} | /api/experiment/models/{folder} | Renamed; response shape differs (see below) |
Cloud model-list response shape
- Local:
["a.safetensors", "b.safetensors", …]— flat list of strings. - Cloud:
[{"name": "a.safetensors", "pathIndex": 0}, …]— list of objects. - Cloud 404 with `code: "folder_not_found"` — folder is empty or unknown,
not an "endpoint missing" error. Distinguish by reading the body.
The skill helper _common.parse_model_list() normalizes both.
Workflow Execution
Submit Workflow
# Local
curl -X POST "http://127.0.0.1:8188/prompt" \
-H "Content-Type: application/json" \
-d '{"prompt": '"$(cat workflow_api.json)"', "client_id": "'"$(uuidgen)"'"}'
# Cloud
curl -X POST "https://cloud.comfy.org/api/prompt" \
-H "X-API-Key: $COMFY_CLOUD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": '"$(cat workflow_api.json)"'}'Response:
{"prompt_id": "abc-123-def", "number": 1, "node_errors": {}}If node_errors is non-empty, the workflow has validation errors (missing nodes, bad inputs).
Check Job Status (Cloud)
curl -X GET "https://cloud.comfy.org/api/job/{prompt_id}/status" \
-H "X-API-Key: $COMFY_CLOUD_API_KEY"| Status | Description |
|---|---|
pending | Job is queued and waiting to start |
in_progress | Job is currently executing |
completed | Job finished successfully |
failed | Job encountered an error |
cancelled | Job was cancelled by user |
Job detail with outputs (Cloud)
curl -X GET "https://cloud.comfy.org/api/jobs/{prompt_id}" \
-H "X-API-Key: $COMFY_CLOUD_API_KEY"Response includes outputs keyed by node ID. Cloud uses video (singular) in the output structure; local uses videos (plural). The skill scripts accept both.
Get History (Local)
curl -s "http://127.0.0.1:8188/history" # all
curl -s "http://127.0.0.1:8188/history/{id}" # one prompt_idLocal entry shape:
{
"<prompt_id>": {
"prompt": [...],
"outputs": {"<node_id>": {"images": [...]}},
"status": {
"status_str": "success" | "error",
"completed": true | false,
"messages": [["execution_start", {...}], ["execution_error", {...}], …]
}
}
}Important: when reading status, check status_str == "error" BEFORE checking completed, because both can be true for failed runs.
Download Output
# Local (direct bytes)
curl -s "http://127.0.0.1:8188/view?filename=ComfyUI_00001_.png&subfolder=&type=output" \
-o output.png
# Cloud (302 → signed URL; -L follows; STRIP X-API-Key for the second hop)
curl -L "https://cloud.comfy.org/api/view?filename=...&type=output" \
-H "X-API-Key: $COMFY_CLOUD_API_KEY" \
-o output.pngThe skill's run_workflow.py strips X-API-Key automatically on the cross-host redirect, so the signed URL never sees your auth.
WebSocket Monitoring
Connect for real-time execution events.
# Local
wscat -c "ws://127.0.0.1:8188/ws?clientId=MY-UUID"
# Cloud
wscat -c "wss://cloud.comfy.org/ws?clientId=MY-UUID&token=$COMFY_CLOUD_API_KEY"Note: on Cloud the clientId is currently ignored — all messages for a user are broadcast to every connection. Filter messages client-side by data.prompt_id.
JSON Message Types
| Type | When | Key Fields |
|---|---|---|
status | Queue change | status.exec_info.queue_remaining |
notification | User-friendly status string | value |
execution_start | Workflow begins | prompt_id |
executing | Node running (or end-of-run if node is null on local) | node, prompt_id |
progress | Sampling steps | node, value, max |
progress_state | Extended progress with per-node metadata | nodes (dict) |
executed | Node output ready | node, output (with images/video/etc.) |
execution_cached | Nodes skipped because of cache | nodes (list of IDs) |
execution_success | All done | prompt_id |
execution_error | Failure | exception_type, exception_message, traceback, node_id |
execution_interrupted | Cancelled | prompt_id |
Binary Frames (Preview Images)
| Type code | Meaning |
|---|---|
0x00000001 | PREVIEW_IMAGE — [type:4][image_type:4][data] (image_type 1=JPEG, 2=PNG) |
0x00000003 | TEXT — [type:4][nid_len:4][nid][text] (UTF-8) |
0x00000004 | PREVIEW_IMAGE_WITH_METADATA — [type:4][meta_len:4][json][image_data] |
scripts/ws_monitor.py --previews <dir> saves preview frames to disk.
File Upload
# Image
curl -X POST "http://127.0.0.1:8188/upload/image" \
-F "image=@photo.png" -F "type=input" -F "overwrite=true"
# Returns: {"name": "photo.png", "subfolder": "", "type": "input"}
# Mask (linked to a previously uploaded image)
curl -X POST "http://127.0.0.1:8188/upload/mask" \
-F "image=@mask.png" -F "type=input" \
-F 'original_ref={"filename":"photo.png","subfolder":"","type":"input"}'Cloud equivalent: prepend https://cloud.comfy.org/api and add -H "X-API-Key: $COMFY_CLOUD_API_KEY".
Node & Model Discovery
# All node types and their input specs
curl -s "http://127.0.0.1:8188/object_info" | python3 -m json.tool
# Specific node
curl -s "http://127.0.0.1:8188/object_info/KSampler"
# Models per folder (local)
curl -s "http://127.0.0.1:8188/models/checkpoints"
curl -s "http://127.0.0.1:8188/models/loras"
# Models per folder (cloud — note the experimental prefix)
curl -s "https://cloud.comfy.org/api/experiment/models/checkpoints" \
-H "X-API-Key: $COMFY_CLOUD_API_KEY"Queue Management
# View queue
curl -s "http://127.0.0.1:8188/queue"
# Clear all pending
curl -X POST "http://127.0.0.1:8188/queue" \
-H "Content-Type: application/json" \
-d '{"clear": true}'
# Delete specific items
curl -X POST "http://127.0.0.1:8188/queue" \
-H "Content-Type: application/json" \
-d '{"delete": ["prompt_id_1", "prompt_id_2"]}'
# Cancel currently-running job
curl -X POST "http://127.0.0.1:8188/interrupt"System Management
# Stats (VRAM, RAM, GPU, ComfyUI version)
curl -s "http://127.0.0.1:8188/system_stats"
# Free GPU memory
curl -X POST "http://127.0.0.1:8188/free" \
-H "Content-Type: application/json" \
-d '{"unload_models": true, "free_memory": true}'ComfyUI-Manager Endpoints (Optional)
These require ComfyUI-Manager installed. Useful for installing nodes/models via the API instead of comfy-cli.
# Install a custom node from a git URL
curl -X POST "http://127.0.0.1:8188/manager/queue/install" \
-H "Content-Type: application/json" \
-d '{"git_url": "https://github.com/user/comfyui-node.git"}'
# Check install queue status
curl -s "http://127.0.0.1:8188/manager/queue/status"
# Install model
curl -X POST "http://127.0.0.1:8188/manager/queue/install_model" \
-H "Content-Type: application/json" \
-d '{"url": "https://...", "path": "models/checkpoints", "filename": "model.safetensors"}'POST /prompt Payload Format
{
"prompt": {
"3": {
"class_type": "KSampler",
"inputs": {
"seed": 42,
"steps": 20,
"cfg": 7.5,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0]
}
}
},
"client_id": "unique-uuid-for-ws-filtering",
"extra_data": {
"api_key_comfy_org": "optional-PARTNER-NODE-key (NOT the cloud auth key)"
}
}prompt: workflow graph in API formatclient_id: UUID — local server uses it to filter WebSocket events; cloud
ignores it.
extra_data.api_key_comfy_org: ONLY required when the workflow uses
partner nodes (Flux Pro, Ideogram, etc.). Don't conflate with X-API-Key.
Error Categories (cloud execution_error exception_type)
| Type | Meaning |
|---|---|
ValidationError | Bad workflow / inputs (often nicer to surface from node_errors) |
ModelDownloadError | Required model not available |
ImageDownloadError | Failed to fetch input image from URL |
OOMError | Out of GPU memory |
InsufficientFundsError | Account balance too low (partner nodes) |
InactiveSubscriptionError | Subscription not active |
ComfyUI Workflow-Template Integrity
Authored by [@purzbeats](https://github.com/purzbeats) — adapted from
purzbeats/hermes-agent-comfyui-helper.
Use this reference when converting workflows from the official
comfyui-workflow-templates package (editor format) into API format forsubmission via /api/prompt. The conversion has subtle gotchas that causehard-to-diagnose validation errors if you don't follow these rules.
Background
The official ComfyUI template package (comfyui-workflow-templates, currently v0.9.69) is installed inside the ComfyUI venv at a path like:
<comfy-install>/.venv/lib/python3.*/site-packages/comfyui_workflow_templates_*/templates/The exact path depends on how ComfyUI was installed (comfy-cli default, Comfy Desktop, manual venv, etc.). Find it once with:
comfy --workspace <ws> run-python -c "import comfyui_workflow_templates, pathlib; print(pathlib.Path(comfyui_workflow_templates.__file__).parent / 'templates')"Templates ship in editor format — nodes / links arrays inside data['definitions']['subgraphs'][0]. They must be converted to API format (a node_id -> {class_type, inputs} mapping) before submission.
---
RULE #1: Use templates AS CLOSE TO ORIGINAL AS POSSIBLE
- Never strip, simplify, or "minimize" nodes from a template.
- Full template architecture (dual-pass pipelines, LoRA chains, distilled
sigmas, conditioning paths) is intentional — removing any part breaks quality.
- If an image-dependent path exists but the task is text-to-video, **leave
it wired with the bypass toggle enabled** — don't remove the nodes.
- Only change: prompt text, seed, and dimensions (when explicitly requested).
RULE #2: Server validation errors are the source of truth
When a workflow submission fails, the server response looks like:
{
"node_errors": {
"238": {
"errors": [{
"message": "Required input is missing",
"details": "width",
"extra_info": { "input_name": "resize_type.width" }
}]
}
}
}The `extra_info.input_name` field tells you EXACTLY what JSON key the server wants. Use it literally. If it says "values.a" or "resize_type.width", those are the actual key names in the JSON object. Do not "simplify" them to flat names based on assumptions about what the field "should" be called.
RULE #3: Don't rebuild from scratch — patch the failing nodes
Every regeneration from the template reintroduces the same bugs. Instead:
1. Submit the workflow once. 2. Read the server error details for exact key names. 3. Use targeted patch/fix calls against the workflow file on disk. 4. Resubmit and check if errors resolved.
---
Reroute nodes: bypass, don't delete
Most servers (local, Cloud) don't have a Reroute node type. When converting a template:
1. Find what feeds into the Reroute by looking at links where target_id = the Reroute node ID. 2. Replace all inputs referencing the Reroute with [source_node_id, source_slot]. 3. Delete the Reroute node from the API mapping.
Real example — LTX 2.3 t2v template:
- Reroute node 255 receives VAE from
CheckpointLoaderSimple 236slot 2. - Three nodes reference Reroute 255 for their VAE input:
LTXVImgToVideoInplace (230), LTXVLatentUpsampler (253), VAEDecodeTiled (251).
- Fix: replace all occurrences of
vae: ["255", 0]withvae: ["236", 2]. CheckpointLoaderSimpleslot 2 = VAE (not slot 0 = MODEL).
| ❌ Wrong | vae: ["236", 0] → MODELV mismatch input_type(VAE) |
| ✅ Correct | vae: ["236", 2] |
---
Dynamic template nodes: dotted key names are correct
ComfyMathExpression (COMFY_AUTOGROW_V3)
{
"class_type": "ComfyMathExpression",
"inputs": {
"expression": "a/2",
"values.a": ["257", 0]
}
}valuesis aCOMFY_AUTOGROW_V3template.- Input names in links are
values.a,values.b, etc. - Keep the dotted format as JSON keys.
- Do NOT convert to
{"values": {"a": ...}}or flatten to just"a".
ResizeImageMaskNode (COMFY_DYNAMICCOMBO_V3)
{
"class_type": "ResizeImageMaskNode",
"inputs": {
"input": ["276", 0],
"scale_method": "lanczos",
"resize_type": "scale dimensions",
"resize_type.width": 1920,
"resize_type.height": 1088,
"resize_type.crop": "center"
}
}resize_typeis aCOMFY_DYNAMICCOMBO_V3.- Mode-specific fields:
resize_type.width,resize_type.height,resize_type.crop. scale_methodoptions:"nearest-exact","bilinear","area","bicubic","lanczos".- Keep the dotted format as JSON keys.
- Do NOT flatten
resize_type.widthto just"width".
---
Conversion recipe
1. Load template from the installed package path. 2. Parse data['definitions']['subgraphs'][0]. 3. For each node (skip Reroute):
- Resolve linked inputs from
sg['links']dict. - Map
widgets_valuesto input field names. - Keep all dotted key names as-is from the template.
4. Bypass Reroute: trace source, replace references. 5. Change only: prompt text, seed values, and user-requested parameters. 6. Add SaveVideo terminal node if template uses only CreateVideo. 7. Submit → read errors → patch specific nodes → resubmit.
What to NEVER change in a template
| Element | Why |
|---|---|
| Node topology | Graph is designed for the specific model |
| Sigmas values | Tuned for the model/sampler combination |
| LoRA/distilled paths | Required for quality, even if they look unused |
| Model parameters (cfg, steps, shifts) | Model-specific |
| Conditioning chains (zero-out, crop guides) | Required for correct conditioning |
| Pass-through wiring | Don't remove nodes, bypass them |
---
Cloud compatibility (verified May 2025)
The full LTX 2.3 T2V template (video_ltx2_3_t2v.json) runs without modification on Comfy Cloud.
Confirmed working on Cloud (all custom nodes available): ComfyMathExpression, ResizeImageMaskNode, ResizeImagesByLongerEdge, PrimitiveInt, PrimitiveStringMultiline, PrimitiveBoolean, SaveVideo, LTXVCropGuides, LTXVImgToVideoInplace, LTXVConcatAVLatent, LTXVSeparateAVLatent, LTXVLatentUpsampler, LTXVAudioVAELoader, LTXVAudioVAEDecode, LTXVEmptyLatentAudio, LTXVPreprocess, LTXVConditioning, ManualSigmas, LTXAVTextEncoderLoader, plus all core nodes.
Cloud vs Local for LTX 2.3 (768x512):
- Cloud: ~39s per video (4x faster).
- Local (RTX 5090): ~160s per video.
example.pngplaceholder works on Cloud for bypassed image-dependent paths.- Submission format is identical between local and Cloud:
{"prompt": wf, "extra_data": {}} to /api/prompt.
- Free tier = 1 concurrent job.
Cloud submission pitfalls:
/api/object_info/<node>returns 404 on free tier — can't query node
schemas remotely, but the workflow runs fine anyway. Always probe object_info locally before building workflows.
- Cloud is ~4x faster — prefer Cloud for batch runs unless local is needed
for debugging.
- Cloud
/api/viewreturns 302 redirect to signed GCS URL — use
curl -s -L to follow and download. Python urllib fails with 401 (forwards auth headers to GCS CDN).
COMFY_CLOUD_API_KEYis only in the terminal/bash env, not in the Python
sandbox. Use subprocess or terminal scripts for Cloud API calls.
- Cloud free tier processes jobs sequentially (1 at a time). Submit all,
then poll history.
- LTX 2.3 at 1920x1080 OOMs locally (even RTX 5090) — upscaler pass
exceeds VRAM. Prefer Cloud for 1080p; use 1280x720 locally (~90s/video).
---
FFmpeg stitch settings (Discord-compatible)
Generated ComfyUI videos often use yuv444p pixel format which does NOT work on Discord. Re-encode with:
ffmpeg -y -i input.mp4 \
-c:v libx264 -profile:v main -preset medium -crf 13 -pix_fmt yuv420p \
-c:a aac -b:a 192k \
output_discord.mp4Key settings:
-pix_fmt yuv420p— required for Discord, ComfyUI outputsyuv444pby default.-crf 13— high quality without massive file size (default 23 is too lossy).-profile:v main— widely compatible.
For multi-video crossfade stitching, chain xfade (video) and acrossfade (audio):
ffmpeg -y -i a.mp4 -i b.mp4 -i c.mp4 \
-filter_complex "[0:v][1:v]xfade=transition=fade:duration=1:offset=3.04[v1];[v1][2:v]xfade=transition=fade:duration=1:offset=6.08[vout];[0:a][1:a]acrossfade=duration=1:c1=tri:c2=tri[a1];[a1][2:a]acrossfade=duration=1:c1=tri:c2=tri[aout]" \
-map "[vout]" -map "[aout]" \
-c:v libx264 -profile:v main -crf 13 -pix_fmt yuv420p \
-c:a aac -b:a 192k \
output.mp4Offset for xfade #N = (N+1) × duration - N × overlap.
ComfyUI Workflow JSON Format
Two Formats — Only API Format Is Executable
API format is required for /api/prompt and every script in this skill. The web UI also produces an "editor format" used for visual editing, which cannot be submitted directly.
API Format
Top-level keys are string node IDs. Each node has class_type and inputs:
{
"3": {
"class_type": "KSampler",
"inputs": {
"seed": 156680208700286,
"steps": 20,
"cfg": 8,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0]
},
"_meta": {"title": "KSampler"}
},
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "v1-5-pruned-emaonly.safetensors"}
}
}Detection: every top-level value has class_type. The skill's _common.is_api_format() does this check.
Editor Format (not directly executable)
Has nodes[] and links[] arrays — the visual graph. To convert: open in ComfyUI's web UI and use Workflow → Export (API) (newer UI) or the "Save (API Format)" button (older UI).
Detection: top-level has "nodes" and "links" keys.
Inputs: Literals vs Links
"inputs": {
"text": "a cat", // literal — modifiable
"seed": 42, // literal — modifiable
"clip": ["4", 1] // link — wiring; do NOT overwrite
}Links are length-2 arrays of [upstream_node_id, output_slot]. The skill's parameter injector refuses to overwrite a link with a literal (logs a warning and skips).
Common Node Types and Their Controllable Parameters
The full catalog lives in scripts/_common.py (PARAM_PATTERNS and MODEL_LOADERS). Highlights:
Text Prompts
| Node Class | Key Fields |
|---|---|
CLIPTextEncode | text |
CLIPTextEncodeSDXL | text_g, text_l, width, height |
CLIPTextEncodeFlux | clip_l, t5xxl, guidance |
To distinguish positive from negative the skill traces KSampler.negative back through Reroute / Primitive nodes to the source CLIPTextEncode. Falls back to _meta.title heuristics ("negative", "neg", "anti").
Sampling
| Node Class | Key Fields |
|---|---|
KSampler | seed, steps, cfg, sampler_name, scheduler, denoise |
KSamplerAdvanced | noise_seed, steps, cfg, start_at_step, end_at_step |
SamplerCustom | noise_seed, cfg, sampler, sigmas |
SamplerCustomAdvanced | noise_seed (via RandomNoise input) |
RandomNoise | noise_seed |
BasicScheduler | steps, scheduler, denoise |
KSamplerSelect | sampler_name |
BasicGuider / CFGGuider | cfg |
ModelSamplingFlux | max_shift, base_shift, width, height |
SDTurboScheduler | steps, denoise |
Latent / Dimensions
| Node Class | Key Fields |
|---|---|
EmptyLatentImage | width, height, batch_size |
EmptySD3LatentImage | width, height, batch_size |
EmptyHunyuanLatentVideo | width, height, length, batch_size |
EmptyMochiLatentVideo | width, height, length, batch_size |
EmptyLTXVLatentVideo | width, height, length, batch_size |
Model Loading
| Node Class | Key Fields | Folder |
|---|---|---|
CheckpointLoaderSimple | ckpt_name | checkpoints |
LoraLoader | lora_name, strength_model, strength_clip | loras |
LoraLoaderModelOnly | lora_name, strength_model | loras |
VAELoader | vae_name | vae |
ControlNetLoader | control_net_name | controlnet |
CLIPLoader | clip_name | clip |
DualCLIPLoader | clip_name1, clip_name2 | clip |
TripleCLIPLoader | clip_name1/2/3 | clip |
UNETLoader | unet_name | unet |
DiffusionModelLoader | model_name | diffusion_models |
UpscaleModelLoader | model_name | upscale_models |
IPAdapterModelLoader | ipadapter_file | ipadapter |
ADE_AnimateDiffLoaderWithContext | model_name, motion_scale | animatediff_models |
Image Input/Output
| Node Class | Key Fields |
|---|---|
LoadImage | image (server-side filename, after upload) |
LoadImageMask | image, channel (red / green / blue / alpha) |
VAEEncode / VAEDecode | (no controllable fields) |
VAEEncodeForInpaint | grow_mask_by |
SaveImage | filename_prefix |
VHS_VideoCombine | frame_rate, format, filename_prefix, loop_count, pingpong |
ControlNet
| Node Class | Key Fields |
|---|---|
ControlNetApply | strength |
ControlNetApplyAdvanced | strength, start_percent, end_percent |
IPAdapter (community pack comfyui_ipadapter_plus)
| Node Class | Key Fields |
|---|---|
IPAdapterAdvanced | weight, start_at, end_at |
IPAdapter | weight |
Embeddings (referenced inside prompt strings)
ComfyUI scans prompt text for embedding:NAME syntax. The skill's _common.iter_embedding_refs() extracts these as model dependencies.
"a beautiful cat, embedding:goodvibes:1.2, embedding:art-style"extract_schema.py and check_deps.py surface these in embedding_dependencies / missing_embeddings.
Parameter Injection Pattern
import json, copy
with open("workflow_api.json") as f:
workflow = json.load(f)
wf = copy.deepcopy(workflow)
wf["6"]["inputs"]["text"] = "a beautiful sunset"
wf["7"]["inputs"]["text"] = "ugly, blurry"
wf["3"]["inputs"]["seed"] = 42
wf["3"]["inputs"]["steps"] = 30
wf["5"]["inputs"]["width"] = 1024
wf["5"]["inputs"]["height"] = 1024scripts/extract_schema.py automates discovering which node IDs/fields correspond to which user-facing parameters. It returns a parameters dict that run_workflow.py reads to inject values from --args.
Identifying Controllable Parameters (Heuristics)
For unknown workflows:
1. Prompt text — any CLIPTextEncode.text. Use connection tracing back from KSampler.positive / .negative to disambiguate (don't trust meta-title alone). 2. Seed — KSampler.seed / KSamplerAdvanced.noise_seed / RandomNoise.noise_seed. 3. Dimensions — Empty*LatentImage.width/height (must be multiples of 8). 4. Steps / CFG — KSampler.steps, KSampler.cfg. Steps 20–50 typical. CFG 5–15 typical (Flux uses guidance, not CFG). 5. Model / checkpoint — CheckpointLoaderSimple.ckpt_name. Filename must match an installed file exactly. 6. LoRA — LoraLoader.lora_name, .strength_model. 7. Images for img2img / inpaint — LoadImage.image. Server-side filename after upload. 8. Denoise — KSampler.denoise. 0.0–1.0; 1.0 = ignore input image, 0.0 = pass through. Sweet spot for img2img: 0.4–0.7.
Output Nodes
Output is produced by these node types. The skill's OUTPUT_NODES set extends to common community packs.
| Node | Output Key | Content |
|---|---|---|
SaveImage | images | List of {filename, subfolder, type} |
PreviewImage | images | Temporary preview (not saved) |
VHS_VideoCombine | gifs (older) or videos/video (newer cloud) | Video file refs |
SaveAudio | audio | Audio file refs |
SaveAnimatedWEBP / SaveAnimatedPNG | images | Animated images |
Save3D | 3d | 3D asset refs |
After execution, fetch outputs from /history/{prompt_id} (local) or /api/jobs/{prompt_id} (cloud) → outputs → {node_id} → {key}.
Wrapper Variants
Some saved JSON files wrap the workflow under a "prompt" key (matching the /api/prompt payload shape). The skill's _common.unwrap_workflow() handles this — pass any of:
- raw API format:
{"3": {...}, "4": {...}} - wrapped:
{"prompt": {"3": {...}}, "client_id": "..."}
It rejects editor format with a clear error and a re-export instruction.
"""
_common.py — Shared logic for ComfyUI skill scripts.
Single source of truth for:
- HTTP transport (with retry/backoff, streaming, timeout handling)
- Cloud detection and endpoint mapping (local ComfyUI vs Comfy Cloud)
- Workflow node-type catalogs (param patterns, model loaders, output nodes)
- API-format validation
- Path-traversal-safe file writes
- API-key loading from env / CLI
Stdlib-only by design (with optional `requests` upgrade if installed). Python 3.10+.
"""
from __future__ import annotations
import json
import os
import random
import re
import sys
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterator
from urllib.parse import urlparse
# Optional: prefer `requests` if installed (better redirects, streaming, header handling)
try:
import requests # type: ignore[import-not-found]
HAS_REQUESTS = True
except ImportError: # pragma: no cover - exercised via stdlib fallback
HAS_REQUESTS = False
import urllib.error
import urllib.request
# =============================================================================
# Constants & catalogs
# =============================================================================
DEFAULT_LOCAL_HOST = "http://127.0.0.1:8188"
DEFAULT_CLOUD_HOST = "https://cloud.comfy.org"
ENV_API_KEY = "COMFY_CLOUD_API_KEY"
# Connection / retry defaults
DEFAULT_HTTP_TIMEOUT = 60 # seconds — single-attempt request timeout
DEFAULT_RETRIES = 3 # total attempts including the first
RETRY_BASE_DELAY = 1.0 # seconds — exponential backoff base
RETRY_MAX_DELAY = 30.0 # seconds — cap on backoff
RETRY_STATUS_CODES = {408, 429, 500, 502, 503, 504, 522, 524}
# Streaming download chunk size (bytes)
DOWNLOAD_CHUNK_SIZE = 1 << 16 # 64 KiB
# Heuristic: workflows with these node types tend to be slow → larger default timeout
SLOW_OUTPUT_NODES = {
"VHS_VideoCombine", "SaveAnimatedWEBP", "SaveAnimatedPNG",
"SaveVideo", "SaveAudio", "SaveAnimateDiffVideo",
"SVD_img2vid_Conditioning",
"WanVideoSampler", "HunyuanVideoSampler",
"CogVideoSampler", "LTXVideoSampler",
}
# ---------------------------------------------------------------------------
# Output node catalog (extensible — community packs add their own)
# ---------------------------------------------------------------------------
OUTPUT_NODES: set[str] = {
# Built-in
"SaveImage", "PreviewImage",
"SaveAudio", "SaveVideo", "PreviewAudio", "PreviewVideo",
"SaveAnimatedWEBP", "SaveAnimatedPNG",
# Common community packs
"VHS_VideoCombine", # Video Helper Suite
"ImageSave", # Was Node Suite
"Image Save", # Was Node Suite (alt name)
"easy imageSave", # easy-use
"Image Save With Metadata",
"PreviewImage|pysssss", # pysssss preview
"ShowText|pysssss",
"SaveLatent",
"SaveGLB", # 3D
"Save3D",
}
# ---------------------------------------------------------------------------
# Folder aliases — handle ComfyUI's gradual folder renames
# ---------------------------------------------------------------------------
# When `check_deps.py` queries `/models/<folder>` and gets 404 / empty,
# it tries each alias in turn. Critical for Comfy Cloud which has fully
# migrated to the new naming (unet → diffusion_models, clip → text_encoders).
FOLDER_ALIASES: dict[str, list[str]] = {
"unet": ["unet", "diffusion_models"],
"diffusion_models": ["diffusion_models", "unet"],
"clip": ["clip", "text_encoders"],
"text_encoders": ["text_encoders", "clip"],
"controlnet": ["controlnet", "control_net"],
}
def folder_aliases_for(folder: str) -> list[str]:
"""Return the search order of folder names (primary first)."""
return FOLDER_ALIASES.get(folder, [folder])
# ---------------------------------------------------------------------------
# Model-loader catalog: class_type -> (input field, model folder)
# ---------------------------------------------------------------------------
# A loader can have multiple fields (e.g., DualCLIPLoader has clip_name1 and
# clip_name2). We list them with explicit entries. The folder name is the
# *canonical* one; FOLDER_ALIASES is consulted when querying.
MODEL_LOADERS: dict[str, list[tuple[str, str]]] = {
# Checkpoints
"CheckpointLoaderSimple": [("ckpt_name", "checkpoints")],
"CheckpointLoader": [("ckpt_name", "checkpoints")],
"CheckpointLoader (Simple)": [("ckpt_name", "checkpoints")],
"ImageOnlyCheckpointLoader": [("ckpt_name", "checkpoints")],
"unCLIPCheckpointLoader": [("ckpt_name", "checkpoints")],
# LoRA
"LoraLoader": [("lora_name", "loras")],
"LoraLoaderModelOnly": [("lora_name", "loras")],
"LoraLoaderTagsQuery": [("lora_name", "loras")],
# VAE
"VAELoader": [("vae_name", "vae")],
# ControlNet
"ControlNetLoader": [("control_net_name", "controlnet")],
"DiffControlNetLoader": [("control_net_name", "controlnet")],
"ControlNetLoaderAdvanced": [("control_net_name", "controlnet")],
# CLIP / text encoders (primary "clip" folder; check_deps tries text_encoders too)
"CLIPLoader": [("clip_name", "clip")],
"DualCLIPLoader": [("clip_name1", "clip"), ("clip_name2", "clip")],
"TripleCLIPLoader": [("clip_name1", "clip"), ("clip_name2", "clip"), ("clip_name3", "clip")],
"CLIPVisionLoader": [("clip_name", "clip_vision")],
# UNET / Diffusion model (primary "unet"; check_deps tries diffusion_models too)
"UNETLoader": [("unet_name", "unet")],
"DiffusionModelLoader": [("model_name", "diffusion_models")],
"UNETLoaderGGUF": [("unet_name", "unet")],
# Upscaler
"UpscaleModelLoader": [("model_name", "upscale_models")],
# Style / GLIGEN / Hypernetwork
"StyleModelLoader": [("style_model_name", "style_models")],
"GLIGENLoader": [("gligen_name", "gligen")],
"HypernetworkLoader": [("hypernetwork_name", "hypernetworks")],
# IPAdapter family (community).
# Note: IPAdapterUnifiedLoader's `preset` and IPAdapterInsightFaceLoader's
# `provider` are enums (not file paths), so they're intentionally omitted —
# check_deps would otherwise treat enum values as missing model files.
"IPAdapterModelLoader": [("ipadapter_file", "ipadapter")],
"InstantIDModelLoader": [("instantid_file", "instantid")],
# AnimateDiff / video
"ADE_LoadAnimateDiffModel": [("model_name", "animatediff_models")],
"ADE_AnimateDiffLoaderWithContext": [("model_name", "animatediff_models")],
"ADE_AnimateDiffLoaderGen1": [("model_name", "animatediff_models")],
# Photomaker
"PhotoMakerLoader": [("photomaker_model_name", "photomaker")],
# Sampler / scheduler models
"ModelSamplingFlux": [], # parametric only
}
# ---------------------------------------------------------------------------
# Param patterns: (class_type, field_name) -> friendly_name
# Order matters — first match wins for naming. Use _meta.title for disambiguation.
# ---------------------------------------------------------------------------
PARAM_PATTERNS: list[tuple[str, str, str]] = [
# ---- Prompts ----
("CLIPTextEncode", "text", "prompt"),
("CLIPTextEncodeSDXL", "text_g", "prompt"),
("CLIPTextEncodeSDXL", "text_l", "prompt_l"),
("CLIPTextEncodeSDXLRefiner", "text", "refiner_prompt"),
("CLIPTextEncodeFlux", "clip_l", "prompt_l"),
("CLIPTextEncodeFlux", "t5xxl", "prompt"),
("CLIPTextEncodeFlux", "guidance", "guidance"),
("smZ CLIPTextEncode", "text", "prompt"),
("BNK_CLIPTextEncodeAdvanced", "text", "prompt"),
# ---- Standard sampling ----
("KSampler", "seed", "seed"),
("KSampler", "steps", "steps"),
("KSampler", "cfg", "cfg"),
("KSampler", "sampler_name", "sampler_name"),
("KSampler", "scheduler", "scheduler"),
("KSampler", "denoise", "denoise"),
("KSamplerAdvanced", "noise_seed", "seed"),
("KSamplerAdvanced", "steps", "steps"),
("KSamplerAdvanced", "cfg", "cfg"),
("KSamplerAdvanced", "sampler_name", "sampler_name"),
("KSamplerAdvanced", "scheduler", "scheduler"),
("KSamplerAdvanced", "start_at_step", "start_at_step"),
("KSamplerAdvanced", "end_at_step", "end_at_step"),
# ---- Modern sampler chain (Flux / SD3 / SDXL refiner via SamplerCustom) ----
("RandomNoise", "noise_seed", "seed"),
("BasicScheduler", "steps", "steps"),
("BasicScheduler", "scheduler", "scheduler"),
("BasicScheduler", "denoise", "denoise"),
("KSamplerSelect", "sampler_name", "sampler_name"),
# NB: BasicGuider has no cfg input (it just bundles model+conditioning).
("CFGGuider", "cfg", "cfg"),
("DualCFGGuider", "cfg_conds", "cfg"),
("DualCFGGuider", "cfg_cond2_negative", "cfg_negative"),
("ModelSamplingFlux", "max_shift", "max_shift"),
("ModelSamplingFlux", "base_shift", "base_shift"),
("ModelSamplingFlux", "width", "model_width"),
("ModelSamplingFlux", "height", "model_height"),
("ModelSamplingSD3", "shift", "shift"),
("ModelSamplingDiscrete", "sampling", "sampling"),
("SDTurboScheduler", "steps", "steps"),
("SDTurboScheduler", "denoise", "denoise"),
("SamplerCustom", "noise_seed", "seed"),
("SamplerCustom", "cfg", "cfg"),
# NB: SamplerCustomAdvanced takes a NOISE input (from RandomNoise) — no seed field directly.
# ---- Dimensions / latent ----
("EmptyLatentImage", "width", "width"),
("EmptyLatentImage", "height", "height"),
("EmptyLatentImage", "batch_size", "batch_size"),
("EmptySD3LatentImage", "width", "width"),
("EmptySD3LatentImage", "height", "height"),
("EmptySD3LatentImage", "batch_size", "batch_size"),
("EmptyHunyuanLatentVideo", "width", "width"),
("EmptyHunyuanLatentVideo", "height", "height"),
("EmptyHunyuanLatentVideo", "length", "length"),
("EmptyHunyuanLatentVideo", "batch_size", "batch_size"),
("EmptyMochiLatentVideo", "width", "width"),
("EmptyMochiLatentVideo", "height", "height"),
("EmptyMochiLatentVideo", "length", "length"),
("EmptyLTXVLatentVideo", "width", "width"),
("EmptyLTXVLatentVideo", "height", "height"),
("EmptyLTXVLatentVideo", "length", "length"),
("LatentUpscale", "width", "upscale_width"),
("LatentUpscale", "height", "upscale_height"),
("LatentUpscaleBy", "scale_by", "scale_by"),
("ImageScale", "width", "width"),
("ImageScale", "height", "height"),
# ---- Image input ----
("LoadImage", "image", "image"),
("LoadImageMask", "image", "mask_image"),
("LoadImageOutput", "image", "image"),
("VHS_LoadVideo", "video", "video"),
("VHS_LoadAudio", "audio", "audio"),
# ---- Model selection (sometimes useful to swap per run) ----
("CheckpointLoaderSimple", "ckpt_name", "ckpt_name"),
("CheckpointLoader", "ckpt_name", "ckpt_name"),
("ImageOnlyCheckpointLoader", "ckpt_name", "ckpt_name"),
("VAELoader", "vae_name", "vae_name"),
("UNETLoader", "unet_name", "unet_name"),
("DiffusionModelLoader", "model_name", "diffusion_model_name"),
("UpscaleModelLoader", "model_name", "upscale_model_name"),
("CLIPLoader", "clip_name", "clip_name"),
("DualCLIPLoader", "clip_name1", "clip_name1"),
("DualCLIPLoader", "clip_name2", "clip_name2"),
("ControlNetLoader", "control_net_name", "controlnet_name"),
# ---- LoRA ----
("LoraLoader", "lora_name", "lora_name"),
("LoraLoader", "strength_model", "lora_strength"),
("LoraLoader", "strength_clip", "lora_strength_clip"),
("LoraLoaderModelOnly", "lora_name", "lora_name"),
("LoraLoaderModelOnly", "strength_model", "lora_strength"),
# ---- ControlNet ----
("ControlNetApply", "strength", "controlnet_strength"),
("ControlNetApplyAdvanced", "strength", "controlnet_strength"),
("ControlNetApplyAdvanced", "start_percent", "controlnet_start"),
("ControlNetApplyAdvanced", "end_percent", "controlnet_end"),
# ---- IPAdapter ----
("IPAdapterAdvanced", "weight", "ipadapter_weight"),
("IPAdapterAdvanced", "start_at", "ipadapter_start"),
("IPAdapterAdvanced", "end_at", "ipadapter_end"),
("IPAdapter", "weight", "ipadapter_weight"),
# ---- Upscale ----
("ImageUpscaleWithModel", "upscale_method", "upscale_method"),
# ---- AnimateDiff ----
("ADE_AnimateDiffLoaderWithContext", "motion_scale", "motion_scale"),
("ADE_AnimateDiffLoaderGen1", "motion_scale", "motion_scale"),
# ---- Video / Save ----
("VHS_VideoCombine", "frame_rate", "frame_rate"),
("VHS_VideoCombine", "format", "video_format"),
("VHS_VideoCombine", "filename_prefix", "filename_prefix"),
("SaveImage", "filename_prefix", "filename_prefix"),
# ---- Hunyuan / Wan / LTX video ----
("HunyuanVideoSampler", "seed", "seed"),
("HunyuanVideoSampler", "steps", "steps"),
("HunyuanVideoSampler", "cfg", "cfg"),
("WanVideoSampler", "seed", "seed"),
("WanVideoSampler", "steps", "steps"),
("WanVideoSampler", "cfg", "cfg"),
("LTXVScheduler", "max_shift", "max_shift"),
("LTXVScheduler", "base_shift", "base_shift"),
# ---- rgthree primitives (often used as user-facing inputs) ----
("Seed (rgthree)", "seed", "seed"),
("Image Comparer (rgthree)", "image_a", "image"),
("Power Lora Loader (rgthree)", "PowerLoraLoaderHeaderWidget", "_lora_header"),
# ---- Easy-use / utility primitives ----
("PrimitiveNode", "value", "primitive_value"),
("easy seed", "seed", "seed"),
("easy positive", "positive", "prompt"),
("easy negative", "negative", "negative_prompt"),
("easy fullLoader", "ckpt_name", "ckpt_name"),
("easy fullLoader", "vae_name", "vae_name"),
("easy fullLoader", "lora_name", "lora_name"),
("easy fullLoader", "positive", "prompt"),
("easy fullLoader", "negative", "negative_prompt"),
]
# Prompt-like fields whose value should be scanned for embedding references
PROMPT_FIELDS = {"text", "text_g", "text_l", "t5xxl", "clip_l", "positive", "negative"}
# Pattern matches: embedding:name, embedding:name.pt, embedding:name:1.2, (embedding:name:1.2)
# Word-boundary at start avoids matching things like "no_embedding:foo".
EMBEDDING_REGEX = re.compile(
r"(?:^|[\s,(\[])embedding\s*:\s*([A-Za-z0-9_\-\./\\]+?)(?:\.(?:pt|safetensors|bin))?(?=[\s:,)\(\]]|$)",
re.IGNORECASE,
)
# =============================================================================
# Cloud detection & endpoint routing
# =============================================================================
CLOUD_DOMAIN_SUFFIXES = (".comfy.org",)
CLOUD_DOMAIN_EXACT = {"cloud.comfy.org"}
def is_cloud_host(host: str) -> bool:
"""True if the host points at Comfy Cloud (or staging/preview subdomain)."""
parsed = urlparse(host if "://" in host else f"http://{host}")
hostname = (parsed.hostname or "").lower()
if hostname in CLOUD_DOMAIN_EXACT:
return True
return any(hostname.endswith(s) for s in CLOUD_DOMAIN_SUFFIXES)
def build_cloud_aware_url(base: str, path: str, *, force_cloud: bool | None = None) -> str:
"""Build a URL that adds /api prefix when targeting Comfy Cloud.
Local ComfyUI accepts both `/foo` and `/api/foo` for many endpoints.
Cloud requires `/api/foo`.
`path` should be a path component (e.g. "/prompt") or full path with query
(e.g. "/view?filename=x").
"""
base = base.rstrip("/")
cloud = is_cloud_host(base) if force_cloud is None else force_cloud
if not path.startswith("/"):
path = "/" + path
if cloud and not path.startswith("/api/"):
path = "/api" + path
return base + path
def cloud_endpoint(path: str) -> str:
"""Map a cloud endpoint path to its current canonical form.
Handles known renames documented in the Comfy Cloud API:
/history -> /history_v2
/models/<f> -> /experiment/models/<f>
/models -> /experiment/models
"""
if path.startswith("/history") and not path.startswith("/history_v2"):
return "/history_v2" + path[len("/history"):]
if path.startswith("/models/"):
return "/experiment/models/" + path[len("/models/"):]
if path == "/models":
return "/experiment/models"
return path
def resolve_url(base: str, path: str, *, is_cloud: bool | None = None) -> str:
"""Top-level URL resolver. Applies cloud rename + /api prefix as needed."""
cloud = is_cloud_host(base) if is_cloud is None else is_cloud
if cloud:
path = cloud_endpoint(path)
return build_cloud_aware_url(base, path, force_cloud=cloud)
# =============================================================================
# API key resolution
# =============================================================================
def resolve_api_key(explicit: str | None) -> str | None:
"""Look up API key from CLI flag → env var. Strips whitespace and quotes."""
val = explicit if explicit else os.environ.get(ENV_API_KEY)
if val is None:
return None
val = val.strip().strip("'\"")
return val or None
# =============================================================================
# HTTP transport
# =============================================================================
@dataclass
class HTTPResponse:
status: int
headers: dict[str, str]
body: bytes
url: str # final URL after redirects
def text(self, encoding: str = "utf-8") -> str:
return self.body.decode(encoding, errors="replace")
def json(self) -> Any:
return json.loads(self.body.decode("utf-8", errors="replace"))
def _sleep_backoff(attempt: int, base: float = RETRY_BASE_DELAY, cap: float = RETRY_MAX_DELAY) -> None:
"""Sleep with full-jitter exponential backoff."""
delay = min(cap, base * (2 ** attempt))
delay = random.uniform(0, delay)
time.sleep(delay)
def http_request(
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
json_body: Any = None,
data: bytes | None = None,
files: dict | None = None,
form: dict | None = None,
timeout: float = DEFAULT_HTTP_TIMEOUT,
follow_redirects: bool = True,
retries: int = DEFAULT_RETRIES,
stream: bool = False,
sink: Path | None = None,
) -> HTTPResponse:
"""Single entry point for all HTTP traffic.
Behavior:
- Retries on connection errors and on HTTP statuses in RETRY_STATUS_CODES,
with exponential backoff + jitter.
- For cross-host redirects, drops Authorization-style headers (so signed
URLs don't leak the API key to S3/CloudFront).
- When `stream=True` and `sink` is a Path, streams the response body to
disk in 64 KiB chunks instead of buffering.
Either `json_body`, `data`, or `files`+`form` may be supplied (mutually exclusive).
"""
if headers is None:
headers = {}
headers = dict(headers) # copy
headers.setdefault("User-Agent", "hermes-comfyui-skill/5.0")
if files or form is not None:
# Multipart upload — needs `requests`. The stdlib fallback lacks
# multipart encoding helpers; raise a clear error.
if not HAS_REQUESTS:
raise RuntimeError(
"Multipart upload requires the `requests` package. "
"Install with: pip install requests"
)
last_exc: Exception | None = None
for attempt in range(retries):
try:
resp = _http_once(
method=method, url=url, headers=headers,
json_body=json_body, data=data, files=files, form=form,
timeout=timeout, follow_redirects=follow_redirects,
stream=stream, sink=sink,
)
if resp.status in RETRY_STATUS_CODES and attempt + 1 < retries:
_sleep_backoff(attempt)
continue
return resp
except (TimeoutError, ConnectionError, OSError) as e:
last_exc = e
if attempt + 1 < retries:
_sleep_backoff(attempt)
continue
raise
# Should not reach here unless retries was 0
if last_exc:
raise last_exc
raise RuntimeError("http_request: retries exhausted with no response")
_SENSITIVE_HEADERS = ("x-api-key", "authorization", "cookie")
if HAS_REQUESTS:
class _StripSensitiveOnRedirectSession(requests.Session):
"""Session that drops sensitive headers on cross-host redirects.
`requests` already strips `Authorization` cross-host (rebuild_auth),
but it does NOT strip custom headers like `X-API-Key`. We override
`rebuild_auth` to additionally strip every header in
`_SENSITIVE_HEADERS` when the destination is a different host —
critical when ComfyUI Cloud's `/api/view` redirects to a signed S3 URL.
"""
def rebuild_auth(self, prepared_request, response): # type: ignore[override]
super().rebuild_auth(prepared_request, response)
try:
old_url = response.request.url
new_url = prepared_request.url
old_host = (urlparse(old_url).hostname or "").lower()
new_host = (urlparse(new_url).hostname or "").lower()
if old_host and new_host and old_host != new_host:
headers = prepared_request.headers
for key in list(headers.keys()):
if key.lower() in _SENSITIVE_HEADERS:
del headers[key]
except Exception:
# Defensive: never let header stripping break a redirect.
pass
def _http_once(
*, method: str, url: str, headers: dict[str, str],
json_body: Any, data: bytes | None, files: dict | None, form: dict | None,
timeout: float, follow_redirects: bool,
stream: bool, sink: Path | None,
) -> HTTPResponse:
"""One HTTP attempt. No retry."""
if HAS_REQUESTS:
kwargs: dict[str, Any] = {
"method": method, "url": url, "headers": headers,
"timeout": timeout, "allow_redirects": follow_redirects,
}
if json_body is not None:
kwargs["json"] = json_body
elif data is not None:
kwargs["data"] = data
elif files is not None or form is not None:
kwargs["files"] = files
kwargs["data"] = form
if stream:
kwargs["stream"] = True
# Use the subclass that strips sensitive headers cross-host
with _StripSensitiveOnRedirectSession() as s:
try:
r = s.request(**kwargs)
if stream and sink is not None:
sink.parent.mkdir(parents=True, exist_ok=True)
with sink.open("wb") as f:
for chunk in r.iter_content(DOWNLOAD_CHUNK_SIZE):
if chunk:
f.write(chunk)
body = b"" # already drained
else:
body = r.content
return HTTPResponse(
status=r.status_code,
headers={k: v for k, v in r.headers.items()},
body=body,
url=r.url,
)
except requests.exceptions.RequestException as e:
# Convert to TimeoutError / ConnectionError so the retry loop
# picks them up uniformly with the stdlib path.
if isinstance(e, requests.exceptions.Timeout):
raise TimeoutError(str(e)) from e
raise ConnectionError(str(e)) from e
# ---------- stdlib fallback ----------
if json_body is not None:
body_bytes = json.dumps(json_body).encode("utf-8")
headers.setdefault("Content-Type", "application/json")
else:
body_bytes = data
req = urllib.request.Request(url, data=body_bytes, headers=headers, method=method)
# urllib follows redirects by default. We need to:
# 1) intercept cross-host redirects and drop X-API-Key
# 2) optionally NOT follow redirects when follow_redirects=False
class _RedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, original_host: str, follow: bool):
self.original_host = original_host
self.follow = follow
def redirect_request(self, req2, fp, code, msg, hdrs, newurl):
if not self.follow:
return None
new_host = (urlparse(newurl).hostname or "").lower()
if new_host != self.original_host:
# Build a new request with cleaned headers
clean_headers = {
k: v for k, v in req2.header_items()
if k.lower() not in {"x-api-key", "authorization", "cookie"}
}
new_req = urllib.request.Request(newurl, headers=clean_headers, method="GET")
return new_req
return super().redirect_request(req2, fp, code, msg, hdrs, newurl)
original_host = (urlparse(url).hostname or "").lower()
opener = urllib.request.build_opener(_RedirectHandler(original_host, follow_redirects))
try:
resp = opener.open(req, timeout=timeout)
except urllib.error.HTTPError as e:
return HTTPResponse(
status=e.code,
headers=dict(e.headers) if e.headers else {},
body=e.read() or b"",
url=getattr(e, "url", url),
)
final_url = resp.geturl()
final_status = resp.status
final_headers = dict(resp.headers)
if stream and sink is not None:
sink.parent.mkdir(parents=True, exist_ok=True)
with sink.open("wb") as f:
while True:
chunk = resp.read(DOWNLOAD_CHUNK_SIZE)
if not chunk:
break
f.write(chunk)
return HTTPResponse(status=final_status, headers=final_headers, body=b"", url=final_url)
return HTTPResponse(status=final_status, headers=final_headers, body=resp.read(), url=final_url)
def http_get(url: str, **kwargs: Any) -> HTTPResponse:
return http_request("GET", url, **kwargs)
def http_post(url: str, **kwargs: Any) -> HTTPResponse:
return http_request("POST", url, **kwargs)
# =============================================================================
# Workflow validation & helpers
# =============================================================================
def is_api_format(workflow: Any) -> bool:
"""API format = top-level dict where each value has `class_type`."""
if not isinstance(workflow, dict):
return False
if "nodes" in workflow and "links" in workflow:
return False
for v in workflow.values():
if isinstance(v, dict) and "class_type" in v:
return True
return False
def unwrap_workflow(payload: Any) -> dict:
"""Unwrap common wrapper variants. Returns API-format workflow or raises ValueError."""
if isinstance(payload, dict) and is_api_format(payload):
return payload
# Some files wrap workflow under "prompt" key (e.g. saved /prompt payloads)
if isinstance(payload, dict) and "prompt" in payload and is_api_format(payload["prompt"]):
return payload["prompt"]
# Editor format
if isinstance(payload, dict) and "nodes" in payload and "links" in payload:
raise ValueError(
"Workflow is in editor format (has top-level 'nodes' and 'links' arrays). "
"Re-export from ComfyUI using 'Workflow → Export (API)' (newer UI) "
"or 'Save (API Format)' (older UI)."
)
raise ValueError(
"Workflow is not in API format. Each top-level entry must have a 'class_type' field."
)
def is_link(value: Any) -> bool:
"""True if `value` is a [node_id, output_index] connection (length-2 list)."""
return (
isinstance(value, list)
and len(value) == 2
and isinstance(value[0], str)
and isinstance(value[1], int)
)
def iter_nodes(workflow: dict) -> Iterator[tuple[str, dict]]:
"""Yield (node_id, node) for each valid API-format node."""
for node_id, node in workflow.items():
if isinstance(node, dict) and "class_type" in node:
yield node_id, node
def iter_model_deps(workflow: dict) -> Iterator[dict]:
"""Yield {node_id, class_type, field, value, folder} for each model dependency."""
for node_id, node in iter_nodes(workflow):
cls = node["class_type"]
if cls not in MODEL_LOADERS:
continue
inputs = node.get("inputs", {}) or {}
for field_name, folder in MODEL_LOADERS[cls]:
val = inputs.get(field_name)
if val and isinstance(val, str) and not is_link(val):
yield {
"node_id": node_id,
"class_type": cls,
"field": field_name,
"value": val,
"folder": folder,
}
def iter_embedding_refs(workflow: dict) -> Iterator[tuple[str, str]]:
"""Yield (node_id, embedding_name) for every embedding mention in prompts."""
for node_id, node in iter_nodes(workflow):
inputs = node.get("inputs", {}) or {}
for field_name, val in inputs.items():
if field_name not in PROMPT_FIELDS:
continue
if not isinstance(val, str):
continue
for m in EMBEDDING_REGEX.finditer(val):
yield node_id, m.group(1)
# =============================================================================
# Path safety
# =============================================================================
def safe_path_join(base: Path, *parts: str) -> Path:
"""Join paths, raising if the result escapes `base`.
Server-supplied filenames may contain `../` etc. This guards against
path-traversal attacks when downloading outputs.
"""
base_resolved = base.resolve()
candidate = base.joinpath(*parts).resolve()
try:
candidate.relative_to(base_resolved)
except ValueError as e:
raise ValueError(
f"Refusing path traversal: {candidate} is outside {base_resolved}"
) from e
return candidate
def media_type_from_filename(filename: str) -> str:
ext = Path(filename).suffix.lower()
if ext in {".mp4", ".webm", ".avi", ".mov", ".mkv", ".gif", ".webp"}:
return "video"
if ext in {".wav", ".mp3", ".flac", ".ogg", ".m4a"}:
return "audio"
if ext in {".glb", ".obj", ".ply", ".gltf"}:
return "3d"
if ext in {".json", ".txt", ".md"}:
return "text"
return "image"
def looks_like_video_workflow(workflow: dict) -> bool:
"""Used to bump default timeout for video workflows."""
for _, node in iter_nodes(workflow):
if node["class_type"] in SLOW_OUTPUT_NODES:
return True
if node["class_type"].lower().startswith(("animatediff", "ade_", "wanvideo", "hunyuanvideo", "ltxvideo", "cogvideo")):
return True
return False
# =============================================================================
# Seed handling
# =============================================================================
# ComfyUI's max seed range. Many UIs treat `-1` as "randomize on submit".
SEED_MAX = 2**63 - 1
SEED_MIN = 0
def coerce_seed(value: Any) -> int:
"""Convert -1 or None to a fresh random seed; otherwise return int(value).
Accepts numeric -1 OR string "-1" (both treated as "randomize"). Other
parse failures raise TypeError/ValueError for the caller to surface.
"""
if value is None:
return random.randint(SEED_MIN, SEED_MAX)
# Stringly-typed -1 from CLI / JSON should also randomize
if isinstance(value, str) and value.strip() == "-1":
return random.randint(SEED_MIN, SEED_MAX)
if value == -1:
return random.randint(SEED_MIN, SEED_MAX)
return int(value)
# =============================================================================
# Cloud model-list normalization
# =============================================================================
def parse_model_list(payload: Any) -> set[str]:
"""Normalize model-list responses from local ComfyUI vs Comfy Cloud.
Local: `["a.safetensors", "b.safetensors"]`
Cloud: `[{"name": "a.safetensors", "pathIndex": 0}, ...]`
"""
if not isinstance(payload, list):
return set()
out: set[str] = set()
for item in payload:
if isinstance(item, str):
out.add(item)
elif isinstance(item, dict):
name = item.get("name") or item.get("filename") or item.get("path")
if isinstance(name, str):
out.add(name)
return out
# =============================================================================
# Misc utilities
# =============================================================================
def new_client_id() -> str:
return str(uuid.uuid4())
def fmt_kv(d: dict) -> str:
"""Pretty key=value for log lines."""
return " ".join(f"{k}={v!r}" for k, v in d.items())
def emit_json(obj: Any, *, indent: int = 2) -> None:
"""Print JSON to stdout. Centralised so behavior can be tweaked (e.g., --raw)."""
print(json.dumps(obj, indent=indent, default=str))
def log(msg: str) -> None:
"""stderr log with consistent prefix (so JSON stdout stays clean)."""
print(f"[comfyui-skill] {msg}", file=sys.stderr)
#!/usr/bin/env python3
"""
auto_fix_deps.py — Run check_deps.py, then attempt to install whatever is missing.
For local servers:
- Missing custom nodes → `comfy node install <package>`
- Missing models → `comfy model download` (only if a URL is supplied via
--model-source-file or detected via well-known names)
For cloud: prints what would be needed but cannot install (cloud preinstalls
custom nodes and most models server-side; if something genuinely isn't there,
ask Comfy support).
This is conservative: it never installs without an explicit URL for models
(downloading the wrong model is hard to undo). Custom nodes from the registry
are auto-installed by name.
Usage:
python3 auto_fix_deps.py workflow_api.json
python3 auto_fix_deps.py workflow_api.json --models-from-file urls.json
python3 auto_fix_deps.py workflow_api.json --dry-run
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _common import ( # noqa: E402
DEFAULT_LOCAL_HOST, ENV_API_KEY, emit_json, log, resolve_api_key,
)
from check_deps import check_deps # noqa: E402
from _common import unwrap_workflow # noqa: E402
def comfy_cli_available() -> str | None:
"""Return command prefix for comfy-cli, or None."""
if shutil.which("comfy"):
return "comfy"
if shutil.which("uvx"):
return "uvx --from comfy-cli comfy"
return None
def run_cmd(cmd: list[str], *, dry_run: bool = False) -> tuple[int, str]:
if dry_run:
return 0, "[dry-run]"
log(f"$ {' '.join(cmd)}")
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
out = (proc.stdout or "") + (proc.stderr or "")
return proc.returncode, out
def install_node(package: str, *, dry_run: bool = False, comfy_cmd: str = "comfy") -> bool:
cmd = comfy_cmd.split() + ["--skip-prompt", "node", "install", package]
code, _ = run_cmd(cmd, dry_run=dry_run)
return code == 0
def install_model(url: str, folder: str, filename: str | None = None,
*, dry_run: bool = False, comfy_cmd: str = "comfy",
hf_token: str | None = None, civitai_token: str | None = None) -> bool:
cmd = comfy_cmd.split() + [
"--skip-prompt", "model", "download",
"--url", url,
"--relative-path", f"models/{folder}",
]
if filename:
cmd.extend(["--filename", filename])
if hf_token:
cmd.extend(["--set-hf-api-token", hf_token])
if civitai_token:
cmd.extend(["--set-civitai-api-token", civitai_token])
code, _ = run_cmd(cmd, dry_run=dry_run)
return code == 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Run check_deps and install whatever is missing")
p.add_argument("workflow")
p.add_argument("--host", default=DEFAULT_LOCAL_HOST)
p.add_argument("--api-key", help=f"or set ${ENV_API_KEY}")
p.add_argument("--models-from-file",
help="JSON file mapping {model_filename: download_url} for models that need install")
p.add_argument("--hf-token", help="HuggingFace token for downloads")
p.add_argument("--civitai-token", help="CivitAI token for downloads")
p.add_argument("--dry-run", action="store_true",
help="Show what would be installed without doing it")
p.add_argument("--no-restart", action="store_true",
help="Don't suggest restarting the server after node install")
args = p.parse_args(argv)
api_key = resolve_api_key(args.api_key)
wf_path = Path(args.workflow).expanduser()
if not wf_path.exists():
emit_json({"error": f"Workflow not found: {args.workflow}"})
return 1
try:
with wf_path.open() as f:
workflow = unwrap_workflow(json.load(f))
except (ValueError, json.JSONDecodeError) as e:
emit_json({"error": str(e)})
return 1
report = check_deps(workflow, host=args.host, api_key=api_key)
if report["is_ready"]:
emit_json({"status": "ready", "report": report})
return 0
if report["is_cloud"]:
emit_json({
"status": "cannot_fix_cloud",
"reason": "Comfy Cloud preinstalls nodes; if something is genuinely missing, contact support.",
"report": report,
})
return 1
comfy_cmd = comfy_cli_available()
if not comfy_cmd:
emit_json({
"status": "cannot_fix",
"reason": "comfy-cli not on PATH; install with `pip install comfy-cli` or `pipx install comfy-cli`",
"report": report,
})
return 1
actions: list[dict] = []
failures: list[dict] = []
# ---- Install missing custom nodes ----
seen_packages: set[str] = set()
for entry in report["missing_nodes"]:
cmd = entry.get("fix_command", "")
if cmd.startswith("comfy node install "):
package = cmd.split(" ")[-1]
if package in seen_packages:
continue
seen_packages.add(package)
ok = install_node(package, dry_run=args.dry_run, comfy_cmd=comfy_cmd)
(actions if ok else failures).append({
"kind": "node", "package": package, "node_class": entry["class_type"],
"ok": ok,
})
else:
failures.append({
"kind": "node", "node_class": entry["class_type"],
"ok": False, "reason": "No registry mapping known. " + entry.get("fix_hint", ""),
})
# ---- Install missing models (only when URL provided) ----
sources: dict[str, str] = {}
if args.models_from_file:
try:
sources = json.loads(Path(args.models_from_file).read_text())
except (OSError, json.JSONDecodeError) as e:
log(f"Could not read --models-from-file: {e}")
for entry in report["missing_models"]:
filename = entry["value"]
url = sources.get(filename)
if not url:
failures.append({
"kind": "model", "filename": filename, "folder": entry["folder"],
"ok": False, "reason": "No URL provided in --models-from-file. "
"Refusing to guess.",
})
continue
ok = install_model(
url, entry["folder"], filename,
dry_run=args.dry_run, comfy_cmd=comfy_cmd,
hf_token=args.hf_token, civitai_token=args.civitai_token,
)
(actions if ok else failures).append({
"kind": "model", "filename": filename, "folder": entry["folder"],
"url": url, "ok": ok,
})
# ---- Embeddings ----
for entry in report["missing_embeddings"]:
emb_name = entry["embedding_name"]
# Try common extensions in user-supplied source map
url = (sources.get(f"{emb_name}.pt")
or sources.get(f"{emb_name}.safetensors")
or sources.get(emb_name))
if not url:
failures.append({
"kind": "embedding", "name": emb_name,
"ok": False, "reason": "No URL provided in --models-from-file.",
})
continue
target_filename = (
f"{emb_name}.safetensors" if url.endswith(".safetensors")
else f"{emb_name}.pt"
)
ok = install_model(
url, "embeddings", target_filename,
dry_run=args.dry_run, comfy_cmd=comfy_cmd,
hf_token=args.hf_token, civitai_token=args.civitai_token,
)
(actions if ok else failures).append({
"kind": "embedding", "name": emb_name, "url": url, "ok": ok,
})
needs_restart = any(a["kind"] == "node" and a.get("ok") for a in actions)
emit_json({
"status": "fixed" if not failures else "partial",
"actions_taken": actions,
"failures": failures,
"needs_server_restart": needs_restart and not args.no_restart,
"restart_hint": "comfy stop && comfy launch --background",
"dry_run": args.dry_run,
})
return 0 if not failures else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
check_deps.py — Verify a ComfyUI workflow's dependencies (custom nodes, models,
embeddings) against a running server.
Improvements over v1:
- Cloud-aware endpoint mapping (handles `/api/experiment/models/{folder}` and
`/api/object_info` variants verified against live cloud API)
- Distinguishes 200-empty (genuinely no models in folder) vs 404
(folder doesn't exist) vs 403 (auth/tier issue) — no silent passes
- Outputs concrete remediation commands (e.g. `comfy node install <name>`)
when nodes are missing
- Detects embedding references inside prompt strings as model deps
- Skips check on cloud free tier `/api/object_info` (403) without false alarm
- Accepts API key from CLI flag OR $COMFY_CLOUD_API_KEY env var
Usage:
python3 check_deps.py workflow_api.json
python3 check_deps.py workflow_api.json --host 127.0.0.1 --port 8188
python3 check_deps.py workflow_api.json --host https://cloud.comfy.org
Stdlib-only. Python 3.10+.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _common import ( # noqa: E402
DEFAULT_LOCAL_HOST, ENV_API_KEY,
emit_json, folder_aliases_for, http_get, is_cloud_host,
iter_embedding_refs, iter_model_deps, iter_nodes, parse_model_list,
resolve_api_key, resolve_url, unwrap_workflow,
)
# Known node → custom-node-package map. When a workflow needs a node we don't
# recognize, suggesting the right `comfy node install ...` makes the difference
# between a working agent and a stuck one.
NODE_TO_PACKAGE: dict[str, str] = {
# rgthree (Reroute is JS-only and doesn't appear in /object_info)
"Power Lora Loader (rgthree)": "rgthree-comfy",
"Image Comparer (rgthree)": "rgthree-comfy",
"Seed (rgthree)": "rgthree-comfy",
"Display Any (rgthree)": "rgthree-comfy",
"Display Int (rgthree)": "rgthree-comfy",
# Impact pack
"FaceDetailer": "comfyui-impact-pack",
"DetailerForEach": "comfyui-impact-pack",
"BboxDetectorSEGS": "comfyui-impact-pack",
"SAMLoader": "comfyui-impact-pack",
"ImpactWildcardProcessor": "comfyui-impact-pack",
# Impact subpack (separate package)
"UltralyticsDetectorProvider": "comfyui-impact-subpack",
# Was Node Suite
"Image Save": "was-node-suite-comfyui",
"Number Counter": "was-node-suite-comfyui",
"Text String": "was-node-suite-comfyui",
# easy-use
"easy fullLoader": "comfyui-easy-use",
"easy positive": "comfyui-easy-use",
"easy negative": "comfyui-easy-use",
"easy seed": "comfyui-easy-use",
"easy imageSave": "comfyui-easy-use",
# Video Helper Suite
"VHS_VideoCombine": "comfyui-videohelpersuite",
"VHS_LoadVideo": "comfyui-videohelpersuite",
"VHS_LoadAudio": "comfyui-videohelpersuite",
# AnimateDiff
"ADE_AnimateDiffLoaderWithContext": "comfyui-animatediff-evolved",
"ADE_AnimateDiffLoaderGen1": "comfyui-animatediff-evolved",
"ADE_LoadAnimateDiffModel": "comfyui-animatediff-evolved",
# ControlNet aux preprocessors (full class names)
"CannyEdgePreprocessor": "comfyui_controlnet_aux",
"DWPreprocessor": "comfyui_controlnet_aux",
"OpenposePreprocessor": "comfyui_controlnet_aux",
"DepthAnythingPreprocessor": "comfyui_controlnet_aux",
"Zoe_DepthAnythingPreprocessor": "comfyui_controlnet_aux",
"AnimalPosePreprocessor": "comfyui_controlnet_aux",
# IPAdapter Plus
"IPAdapterAdvanced": "comfyui_ipadapter_plus",
"IPAdapterUnifiedLoader": "comfyui_ipadapter_plus",
"IPAdapterModelLoader": "comfyui_ipadapter_plus",
"IPAdapterInsightFaceLoader": "comfyui_ipadapter_plus",
# InstantID
"InstantIDModelLoader": "comfyui_instantid",
"ApplyInstantID": "comfyui_instantid",
# Comfy essentials (note: registry slug uses underscore, not hyphen)
"GetImageSize+": "comfyui_essentials",
"ImageBatchMultiple+": "comfyui_essentials",
# pysssss
"ShowText|pysssss": "comfyui-custom-scripts",
"PreviewImage|pysssss": "comfyui-custom-scripts",
# SUPIR
"SUPIR_Upscale": "comfyui-supir",
"SUPIR_first_stage": "comfyui-supir",
# GGUF (case-sensitive registry slug)
"UNETLoaderGGUF": "ComfyUI-GGUF",
"DualCLIPLoaderGGUF": "ComfyUI-GGUF",
# Florence2
"Florence2Run": "comfyui-florence2",
# WAS
"Image Filter Adjustments": "was-node-suite-comfyui",
# Photomaker (case-sensitive)
"PhotoMakerLoader": "ComfyUI-PhotoMaker-Plus",
# Wan video (case-sensitive)
"WanVideoSampler": "ComfyUI-WanVideoWrapper",
"WanVideoModelLoader": "ComfyUI-WanVideoWrapper",
}
# Nodes whose package isn't on the comfy registry — need git-URL install via
# ComfyUI-Manager. We surface a helpful hint instead of an unrunnable command.
NODE_TO_GIT_URL: dict[str, str] = {
"HunyuanVideoSampler": "https://github.com/kijai/ComfyUI-HunyuanVideoWrapper",
"HunyuanVideoModelLoader": "https://github.com/kijai/ComfyUI-HunyuanVideoWrapper",
}
def fetch_object_info(url: str, headers: dict) -> tuple[set[str] | None, dict | None]:
"""Returns (installed_node_set, error_info). Error info is a dict if we
couldn't query (e.g. cloud free tier), else None.
"""
r = http_get(url, headers=headers, retries=2, timeout=30)
if r.status == 200:
try:
data = r.json()
if isinstance(data, dict):
return set(data.keys()), None
except Exception:
pass
return None, {"http_status": 200, "reason": "non-dict response"}
if r.status == 403:
try:
body = r.json()
except Exception:
body = {"raw": r.text()[:200]}
return None, {"http_status": 403, "reason": "forbidden", "body": body}
if r.status == 404:
return None, {"http_status": 404, "reason": "endpoint not found"}
return None, {"http_status": r.status, "reason": "unexpected", "body": r.text()[:200]}
def _fetch_one_folder(
base: str, folder: str, headers: dict, *, is_cloud: bool,
) -> tuple[set[str] | None, dict | None]:
"""Single-folder fetch, no aliasing. Returns (installed_set, error_info)."""
url = resolve_url(base, f"/models/{folder}", is_cloud=is_cloud)
r = http_get(url, headers=headers, retries=2, timeout=30)
if r.status == 200:
try:
return parse_model_list(r.json()), None
except Exception:
return set(), {"http_status": 200, "reason": "non-list response"}
if r.status == 404:
body_text = r.text()
try:
body = r.json()
except Exception:
body = {"raw": body_text[:200]}
code = body.get("code") if isinstance(body, dict) else None
if code == "folder_not_found":
# Folder is genuinely empty/missing on server — not the same as
# "endpoint missing". Return empty set with informational error.
return set(), {"http_status": 404, "reason": "folder_empty_or_unknown", "body": body}
return None, {"http_status": 404, "reason": "endpoint not found", "body": body}
if r.status == 403:
try:
body = r.json()
except Exception:
body = {}
return None, {"http_status": 403, "reason": "forbidden", "body": body}
return None, {"http_status": r.status, "reason": "unexpected"}
def fetch_models_for_folder(
base: str, folder: str, headers: dict, *, is_cloud: bool,
) -> tuple[set[str] | None, dict | None]:
"""Fetch installed models for a folder, trying aliases.
Folder renames over time (e.g. unet → diffusion_models, clip → text_encoders)
mean a workflow asking for a model in `unet` may need to look in
`diffusion_models`. We union models from every reachable alias.
Returns (combined_set | None, last_error | None).
"""
aliases = folder_aliases_for(folder)
combined: set[str] = set()
any_success = False
last_err: dict | None = None
for alias in aliases:
models, err = _fetch_one_folder(base, alias, headers, is_cloud=is_cloud)
if models is not None:
combined.update(models)
any_success = True
last_err = None
else:
last_err = err
if not any_success:
return None, last_err
return combined, None
def fetch_embeddings(base: str, headers: dict, *, is_cloud: bool) -> tuple[set[str] | None, dict | None]:
"""Local ComfyUI exposes /embeddings; cloud uses /experiment/models/embeddings."""
if is_cloud:
return fetch_models_for_folder(base, "embeddings", headers, is_cloud=True)
# Local: dedicated /embeddings returns a flat list of names
r = http_get(resolve_url(base, "/embeddings", is_cloud=False), headers=headers, retries=2)
if r.status == 200:
try:
data = r.json()
if isinstance(data, list):
# Strip extensions from the registered names since prompt syntax
# usually omits them ("embedding:goodvibes" vs "goodvibes.pt")
names = set()
for n in data:
if isinstance(n, str):
names.add(n)
# Also store stem for fuzzy matching
names.add(Path(n).stem)
return names, None
except Exception:
pass
return None, {"http_status": r.status, "reason": "unexpected"}
def normalize_for_match(name: str) -> set[str]:
"""Generate matching variants of a model name (with/without extension, slashes, etc.)"""
s = {name}
s.add(Path(name).stem)
s.add(Path(name).name)
# ComfyUI sometimes strips/keeps the leading folder
if "/" in name or "\\" in name:
flat = name.replace("\\", "/").split("/")[-1]
s.add(flat)
s.add(Path(flat).stem)
return {x for x in s if x}
def model_present(needed: str, installed: set[str]) -> bool:
if not installed:
return False
needed_variants = normalize_for_match(needed)
installed_norm: set[str] = set()
for inst in installed:
installed_norm.update(normalize_for_match(inst))
return bool(needed_variants & installed_norm)
def suggest_install_command(node_class: str) -> str | None:
pkg = NODE_TO_PACKAGE.get(node_class)
if pkg:
return f"comfy node install {pkg}"
return None
def suggest_git_url(node_class: str) -> str | None:
"""For nodes not on the registry, return a git URL the user can hand to
ComfyUI-Manager's `/manager/queue/install` endpoint."""
return NODE_TO_GIT_URL.get(node_class)
def check_deps(
workflow: dict, host: str, *, api_key: str | None = None,
) -> dict:
headers: dict[str, str] = {}
if api_key:
headers["X-API-Key"] = api_key
is_cloud = is_cloud_host(host)
base = host.rstrip("/")
# ---- 1. Required nodes ----
required_nodes: set[str] = set()
for _, node in iter_nodes(workflow):
required_nodes.add(node["class_type"])
object_info_url = resolve_url(base, "/object_info", is_cloud=is_cloud)
installed_nodes, obj_err = fetch_object_info(object_info_url, headers)
missing_nodes: list[dict] = []
node_check_skipped = False
if installed_nodes is None:
# Couldn't query (e.g. cloud free tier). Don't false-alarm; mark skipped.
node_check_skipped = True
else:
for cls in sorted(required_nodes):
if cls not in installed_nodes:
entry = {"class_type": cls}
cmd = suggest_install_command(cls)
git_url = suggest_git_url(cls)
if cmd:
entry["fix_command"] = cmd
elif git_url:
entry["fix_git_url"] = git_url
entry["fix_hint"] = (
f"Not on registry. Install via Manager with this git URL: {git_url}"
)
else:
entry["fix_hint"] = (
"Search https://registry.comfy.org or "
"use ComfyUI-Manager UI to find the package providing this node."
)
missing_nodes.append(entry)
# ---- 2. Required models ----
model_cache: dict[str, tuple[set[str] | None, dict | None]] = {}
missing_models: list[dict] = []
folder_errors: dict[str, dict] = {}
for dep in iter_model_deps(workflow):
folder = dep["folder"]
if folder not in model_cache:
model_cache[folder] = fetch_models_for_folder(
base, folder, headers, is_cloud=is_cloud,
)
installed, err = model_cache[folder]
if installed is None:
# Couldn't enumerate this folder — record once
folder_errors.setdefault(folder, err or {})
# Don't flag as missing (we don't know); the folder_errors block surfaces this
continue
if not model_present(dep["value"], installed):
entry = dict(dep)
entry["fix_hint"] = (
f"comfy model download --url <URL> --relative-path models/{folder} "
f"--filename {dep['value']!r}"
)
missing_models.append(entry)
# ---- 3. Embedding refs in prompts ----
emb_installed, emb_err = fetch_embeddings(base, headers, is_cloud=is_cloud)
missing_embeddings: list[dict] = []
seen_emb: set[tuple[str, str]] = set()
for nid, emb_name in iter_embedding_refs(workflow):
if (nid, emb_name) in seen_emb:
continue
seen_emb.add((nid, emb_name))
if emb_installed is None:
# Couldn't enumerate — skip silently here, surface the error in the
# folder_errors block
continue
if not model_present(emb_name, emb_installed):
missing_embeddings.append({
"node_id": nid,
"embedding_name": emb_name,
"folder": "embeddings",
"fix_hint": (
f"Download {emb_name}.pt or .safetensors and place in "
f"models/embeddings/, or `comfy model download --url <URL> "
f"--relative-path models/embeddings`"
),
})
if emb_err and emb_installed is None:
folder_errors.setdefault("embeddings", emb_err)
is_ready = (
not node_check_skipped
and not missing_nodes
and not missing_models
and not missing_embeddings
)
return {
"is_ready": is_ready,
"node_check_skipped": node_check_skipped,
"node_check_skip_reason": obj_err if node_check_skipped else None,
"missing_nodes": missing_nodes,
"missing_models": missing_models,
"missing_embeddings": missing_embeddings,
"folder_errors": folder_errors,
# 0 is a legitimate count (e.g. empty server). Use None only when not queried.
"installed_node_count": len(installed_nodes) if installed_nodes is not None else None,
"required_node_count": len(required_nodes),
"required_nodes": sorted(required_nodes),
"host": base,
"is_cloud": is_cloud,
}
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Check ComfyUI workflow dependencies against a running server")
p.add_argument("workflow", help="Path to workflow API JSON file")
p.add_argument("--host", default=DEFAULT_LOCAL_HOST, help="ComfyUI server URL")
p.add_argument("--port", type=int, help="Server port (overrides --host port)")
p.add_argument("--api-key", help=f"API key for cloud (or set ${ENV_API_KEY} env var)")
p.add_argument("--strict", action="store_true",
help="Exit non-zero if node check is skipped (e.g. on cloud free tier)")
args = p.parse_args(argv)
host = args.host
if args.port is not None:
# Strip any port from host and append --port
from urllib.parse import urlparse, urlunparse
parsed = urlparse(host if "://" in host else f"http://{host}")
new_netloc = f"{parsed.hostname}:{args.port}"
host = urlunparse(parsed._replace(netloc=new_netloc))
api_key = resolve_api_key(args.api_key)
wf_path = Path(args.workflow).expanduser()
if not wf_path.exists():
emit_json({"error": f"Workflow file not found: {args.workflow}"})
return 1
try:
with wf_path.open() as f:
payload = json.load(f)
workflow = unwrap_workflow(payload)
except ValueError as e:
emit_json({"error": str(e)})
return 1
except json.JSONDecodeError as e:
emit_json({"error": f"Invalid JSON: {e}"})
return 1
try:
result = check_deps(workflow, host=host, api_key=api_key)
except Exception as e:
emit_json({"error": f"Dep check failed: {e}", "host": host})
return 1
emit_json(result)
if not result["is_ready"]:
return 1
if args.strict and result["node_check_skipped"]:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env bash
# ComfyUI Setup — Install, launch, and verify using the official comfy-cli.
#
# Improvements over v1:
# - Prefers `pipx` / `uvx` over global `pip install` (avoids polluting system Python)
# - Idempotent: detects already-running server and skips re-launch
# - Configurable port via --port=N (default 8188)
# - Configurable workspace via --workspace=PATH
# - Persistent log file in /tmp/comfyui_setup.<pid>.log for debugging
# - SIGINT trap cleans up partial state
# - Refuses local install when hardware_check.py verdict is "cloud"
# - Forwards extra flags to comfy-cli (e.g. --cuda-version=12.4)
#
# Usage:
# bash scripts/comfyui_setup.sh
# (auto-detects GPU; uses recommendation from hardware_check.py)
# bash scripts/comfyui_setup.sh --nvidia
# bash scripts/comfyui_setup.sh --m-series --port=8190
# bash scripts/comfyui_setup.sh --amd --workspace=/data/comfy
#
# Flags:
# --nvidia | --amd | --m-series | --cpu GPU selection (skips hw check)
# --port=N HTTP port (default 8188)
# --workspace=PATH ComfyUI install location
# --skip-launch Install only, don't start server
# --force-cloud-override Install locally even if hw says cloud
# -- Pass remaining args to `comfy install`
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HARDWARE_CHECK="$SCRIPT_DIR/hardware_check.py"
LOG_FILE="/tmp/comfyui_setup.$$.log"
PORT=8188
WORKSPACE=""
GPU_FLAG=""
SKIP_LAUNCH=0
FORCE_CLOUD_OVERRIDE=0
EXTRA_INSTALL_ARGS=()
cleanup() {
local exit_code=$?
if [ $exit_code -ne 0 ]; then
echo "==> Setup exited with status $exit_code. Log: $LOG_FILE" >&2
fi
exit $exit_code
}
trap cleanup EXIT INT TERM
log() { echo "==> $*" | tee -a "$LOG_FILE" >&2; }
err() { echo "ERROR: $*" | tee -a "$LOG_FILE" >&2; }
# --- Argument parsing ---
PASSTHROUGH=0
for arg in "$@"; do
if [ "$PASSTHROUGH" -eq 1 ]; then
EXTRA_INSTALL_ARGS+=("$arg")
continue
fi
case "$arg" in
--nvidia|--amd|--m-series|--cpu)
GPU_FLAG="$arg"
;;
--port=*)
PORT="${arg#*=}"
;;
--workspace=*)
WORKSPACE="${arg#*=}"
;;
--skip-launch)
SKIP_LAUNCH=1
;;
--force-cloud-override)
FORCE_CLOUD_OVERRIDE=1
;;
--)
PASSTHROUGH=1
;;
--help|-h)
# Print the leading comment block, stripping the `# ` prefix.
# Stops at the first blank line which separates docs from code.
awk '
NR == 1 { next } # skip shebang
/^[^#]/ { exit } # stop at first non-comment line
/^$/ { exit } # ...or first blank line
{ sub(/^# ?/, ""); print }
' "$0"
exit 0
;;
*)
err "Unknown argument: $arg"
exit 64
;;
esac
done
log "Logging to $LOG_FILE"
# --- Step 0: Hardware check (skipped if user gave an explicit GPU flag) ---
if [ -z "$GPU_FLAG" ]; then
if [ ! -f "$HARDWARE_CHECK" ]; then
log "hardware_check.py not found — defaulting to --nvidia"
GPU_FLAG="--nvidia"
else
log "Running hardware check…"
set +e
HW_JSON="$(python3 "$HARDWARE_CHECK" --json 2>>"$LOG_FILE")"
HW_EXIT=$?
set -e
if [ -z "$HW_JSON" ]; then
err "hardware_check.py produced no output (exit $HW_EXIT). Pass an explicit flag."
exit 1
fi
echo "$HW_JSON" | tee -a "$LOG_FILE" >&2
VERDICT="$(echo "$HW_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("verdict",""))')"
FLAG="$(echo "$HW_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("comfy_cli_flag") or "")')"
if [ "$VERDICT" = "cloud" ] && [ "$FORCE_CLOUD_OVERRIDE" -ne 1 ]; then
log ""
log "Hardware check: this machine is not suitable for local ComfyUI."
log "Recommended: Comfy Cloud — https://platform.comfy.org"
log ""
log "To override and force a local install, re-run with --force-cloud-override"
log "or pass an explicit GPU flag (--nvidia|--amd|--m-series|--cpu)."
exit 2
fi
if [ "$VERDICT" = "marginal" ]; then
log "Hardware check: verdict is MARGINAL."
log " SD1.5 should work; SDXL/Flux may be slow or OOM."
log " Consider Comfy Cloud for heavier workflows: https://platform.comfy.org"
fi
if [ -z "$FLAG" ]; then
log "hardware_check could not pick a comfy-cli flag. Defaulting to --nvidia."
log "(For Intel Arc or unsupported hardware, use the manual install path.)"
GPU_FLAG="--nvidia"
else
GPU_FLAG="$FLAG"
fi
fi
fi
log "GPU flag: $GPU_FLAG"
log "Port: $PORT"
[ -n "$WORKSPACE" ] && log "Workspace: $WORKSPACE"
[ "${#EXTRA_INSTALL_ARGS[@]}" -gt 0 ] && log "Extra install args: ${EXTRA_INSTALL_ARGS[*]}"
# --- Step 1: Install comfy-cli (prefer pipx / uvx over global pip) ---
COMFY_BIN=""
if command -v comfy >/dev/null 2>&1; then
COMFY_BIN="comfy"
log "comfy-cli already on PATH: $(comfy -v 2>/dev/null || echo 'unknown version')"
elif command -v uvx >/dev/null 2>&1; then
log "Using uvx (no install needed)"
COMFY_BIN="uvx --from comfy-cli comfy"
elif command -v pipx >/dev/null 2>&1; then
log "Installing comfy-cli via pipx…"
pipx install comfy-cli >>"$LOG_FILE" 2>&1
COMFY_BIN="comfy"
# pipx adds shims to ~/.local/bin which may need to be on PATH
if ! command -v comfy >/dev/null 2>&1; then
if [ -x "$HOME/.local/bin/comfy" ]; then
export PATH="$HOME/.local/bin:$PATH"
COMFY_BIN="$HOME/.local/bin/comfy"
fi
fi
else
log "Neither pipx nor uvx found. Falling back to pip install --user…"
log " (Recommend installing pipx: https://pipx.pypa.io)"
if ! pip install --user comfy-cli >>"$LOG_FILE" 2>&1; then
# macOS: PEP 668 externally-managed-environment may block --user
log "pip install --user failed. Retrying with --break-system-packages…"
pip install --user --break-system-packages comfy-cli >>"$LOG_FILE" 2>&1 || {
err "Could not install comfy-cli. Install pipx or uv first."
exit 1
}
fi
# Resolve the actual `comfy` script — pip --user puts it in:
# Linux: ~/.local/bin/comfy
# macOS: ~/Library/Python/<ver>/bin/comfy OR ~/.local/bin/comfy
COMFY_BIN=""
for candidate in "$HOME/.local/bin/comfy" \
"$HOME/Library/Python/3.13/bin/comfy" \
"$HOME/Library/Python/3.12/bin/comfy" \
"$HOME/Library/Python/3.11/bin/comfy" \
"$HOME/Library/Python/3.10/bin/comfy"; do
if [ -x "$candidate" ]; then
COMFY_BIN="$candidate"
export PATH="$(dirname "$candidate"):$PATH"
break
fi
done
if [ -z "$COMFY_BIN" ]; then
if command -v comfy >/dev/null 2>&1; then
COMFY_BIN="comfy"
else
err "Installed comfy-cli but couldn't find the 'comfy' script."
err "Add the right Python user-bin directory to PATH and retry."
exit 1
fi
fi
fi
# --- Step 2: Disable analytics tracking (avoid interactive prompt) ---
log "Disabling analytics tracking…"
$COMFY_BIN --skip-prompt tracking disable >>"$LOG_FILE" 2>&1 || true
# --- Step 3: Install ComfyUI ---
WORKSPACE_ARG=()
if [ -n "$WORKSPACE" ]; then
WORKSPACE_ARG=(--workspace "$WORKSPACE")
fi
if $COMFY_BIN "${WORKSPACE_ARG[@]}" which 2>/dev/null | grep -q "ComfyUI"; then
EXISTING_WS="$($COMFY_BIN "${WORKSPACE_ARG[@]}" which 2>/dev/null || true)"
log "ComfyUI already installed at: $EXISTING_WS"
else
log "Installing ComfyUI ($GPU_FLAG)…"
if ! $COMFY_BIN "${WORKSPACE_ARG[@]}" --skip-prompt install "$GPU_FLAG" "${EXTRA_INSTALL_ARGS[@]}" >>"$LOG_FILE" 2>&1; then
err "Install failed. Tail of log:"
tail -20 "$LOG_FILE" >&2
exit 1
fi
fi
if [ "$SKIP_LAUNCH" -eq 1 ]; then
log "Setup complete (--skip-launch). Run \`$COMFY_BIN launch --background -- --port $PORT\` when ready."
exit 0
fi
# --- Step 4: Detect already-running server ---
if curl -fsS "http://127.0.0.1:$PORT/system_stats" >/dev/null 2>&1; then
log "Server already running on port $PORT — skipping launch."
log "Stop with \`$COMFY_BIN stop\` if you want a fresh start."
curl -fsS "http://127.0.0.1:$PORT/system_stats" | python3 -m json.tool 2>/dev/null || true
log "Done."
exit 0
fi
# --- Step 5: Launch ---
log "Launching ComfyUI in background on port $PORT…"
LAUNCH_EXTRAS=("--" "--port" "$PORT")
if ! $COMFY_BIN "${WORKSPACE_ARG[@]}" launch --background "${LAUNCH_EXTRAS[@]}" >>"$LOG_FILE" 2>&1; then
err "Background launch failed. Tail of log:"
tail -20 "$LOG_FILE" >&2
err "Try foreground launch to see real-time errors: $COMFY_BIN launch -- --port $PORT"
exit 1
fi
# --- Step 6: Wait for server ---
log "Waiting for server…"
MAX_WAIT=60
ELAPSED=0
while [ $ELAPSED -lt $MAX_WAIT ]; do
if curl -fsS "http://127.0.0.1:$PORT/system_stats" >/dev/null 2>&1; then
log "Server is running!"
curl -fsS "http://127.0.0.1:$PORT/system_stats" | python3 -m json.tool 2>/dev/null || true
break
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
done
if [ $ELAPSED -ge $MAX_WAIT ]; then
err "Server did not start within ${MAX_WAIT}s."
err "Inspect log: $LOG_FILE"
err "Or run foreground: $COMFY_BIN launch -- --port $PORT"
exit 1
fi
log ""
log "Setup complete!"
log " Server: http://127.0.0.1:$PORT"
log " Web UI: http://127.0.0.1:$PORT (open in browser)"
log " Stop: $COMFY_BIN stop"
log " Log: $LOG_FILE (kept until shell closes)"
log ""
log "Next steps:"
log " - Download a model: $COMFY_BIN model download --url <URL> --relative-path models/checkpoints"
log " - Run a workflow: python3 $SCRIPT_DIR/run_workflow.py --workflow <file.json> --args '{...}'"
# Disable trap on success path
trap - EXIT
"""Pytest configuration for the comfyui skill test suite.
Adds `scripts/` to sys.path so tests can `from _common import ...`, and
provides a few common fixtures.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SCRIPTS = ROOT / "scripts"
WORKFLOWS = ROOT / "workflows"
sys.path.insert(0, str(SCRIPTS))
@pytest.fixture
def sd15_workflow() -> dict:
return json.loads((WORKFLOWS / "sd15_txt2img.json").read_text())
@pytest.fixture
def flux_workflow() -> dict:
return json.loads((WORKFLOWS / "flux_dev_txt2img.json").read_text())
@pytest.fixture
def video_workflow() -> dict:
return json.loads((WORKFLOWS / "wan_video_t2v.json").read_text())
@pytest.fixture
def workflows_dir() -> Path:
return WORKFLOWS
@pytest.fixture
def scripts_dir() -> Path:
return SCRIPTS
@pytest.fixture
def cloud_key() -> str | None:
"""Cloud API key if set, otherwise None.
Tests that need cloud connectivity should skip when this is None.
"""
return os.environ.get("COMFY_CLOUD_API_KEY")
def pytest_collection_modifyitems(config, items):
"""Auto-skip cloud tests when no API key is set."""
if os.environ.get("COMFY_CLOUD_API_KEY"):
return
skip_cloud = pytest.mark.skip(reason="Set COMFY_CLOUD_API_KEY to run cloud tests")
for item in items:
if "cloud" in item.keywords:
item.add_marker(skip_cloud)
[pytest]
markers =
cloud: tests that hit live Comfy Cloud API (require COMFY_CLOUD_API_KEY)
testpaths = .
addopts = -p no:xdist