UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

537 lines • 24.9 kB
import * as childProcess from "node:child_process"; import { existsSync } from "node:fs"; import { delimiter, dirname, extname, join } from "node:path"; import { isRemoteMode } from "../config.js"; import { resolveEffectiveComfyUIBase, resolveEffectiveComfyUICodeBase, } from "./workspace-env.js"; /** * The ComfyUI install a comfy-cli invocation TARGETS when the caller passed no explicit * `workspace`. That is a DATA/base-root question: `--workspace` is where comfy-cli * looks for custom_nodes/ and models/. On `--base-directory` runtimes that is the * data root, not the main.py checkout (#1715/#1770). `resolveEffectiveComfyUIBase` * answers it. The CLI *executable* may still live in the code checkout's .venv. * * This used to read `config.comfyuiPath ?? resolveEffectiveComfyUIBase()`, which failed * twice over (#490): the resolver did not yet enforce the mode check this comment * claimed for it, AND the `??` short-circuited past the resolver entirely whenever * COMFYUI_PATH was set — the ordinary local configuration — so fixing the resolver alone * would not have reached here. The two operands answer different questions ("where is * the user's local install" vs "which install does this act on") and the `??` silently * substituted the first for the second. * * It matters most here: this module runs `comfy-cli uninstall` and `comfy-cli disable`. * With a remote `--comfyui-url` session and a stale local COMFYUI_PATH, those commands * ran against the local install while the reply described only the remote server. * * Returning null when nothing is resolvable is the point — callers refuse rather than * guess. Do not reintroduce a fallback here; a `??` at this seam is the bug. */ function defaultWorkspace() { return resolveEffectiveComfyUIBase() ?? null; } /** * Await a spawned child process, killing it only after `idleTimeoutMs` elapses * with no output on either stream. Any stdout/stderr chunk is treated as * liveness and resets the idle timer. Exported for direct testing. */ export function awaitProcessWithIdleTimeout(child, idleTimeoutMs, timers = { setTimeout, clearTimeout }) { return new Promise((resolve, reject) => { let stdout = ""; let stderr = ""; let timedOut = false; let settled = false; let idleTimer = null; const clearIdle = () => { if (idleTimer) { timers.clearTimeout(idleTimer); idleTimer = null; } }; const armIdle = () => { clearIdle(); idleTimer = timers.setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, idleTimeoutMs); }; child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); armIdle(); }); child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); armIdle(); }); child.on("error", (error) => { if (settled) return; settled = true; clearIdle(); reject(error); }); child.on("close", (code) => { if (settled) return; settled = true; clearIdle(); resolve({ stdout, stderr, exitCode: timedOut ? 1 : code ?? 0, timedOut }); }); armIdle(); }); } const MIN_COMFY_CLI_VERSION = [1, 11, 1]; const versionCache = new Map(); function executableNames() { return process.platform === "win32" ? ["comfy.exe", "comfy"] : ["comfy"]; } function workspaceCandidates(...workspaces) { const roots = []; const seen = new Set(); for (const workspace of workspaces) { if (!workspace) continue; for (const root of [workspace, dirname(workspace)]) { if (seen.has(root)) continue; seen.add(root); roots.push(root); } } const dirs = roots.flatMap((root) => [ join(root, ".venv", process.platform === "win32" ? "Scripts" : "bin"), join(root, "venv", process.platform === "win32" ? "Scripts" : "bin"), ]); return dirs.flatMap((dir) => executableNames().map((name) => join(dir, name))); } /** Resolve comfy-cli without invoking a shell. COMFY_CLI_PATH is authoritative. */ export function resolveComfyCliExecutable(options = {}) { const explicit = process.env.COMFY_CLI_PATH?.trim(); if (explicit) { if (process.platform === "win32" && [".cmd", ".bat"].includes(extname(explicit).toLowerCase())) { return null; } return existsSync(explicit) ? explicit : null; } const workspace = options.workspace ?? defaultWorkspace(); // --workspace is the pack/data root; the executable often lives in the // checkout's .venv on a split install. Search both. for (const candidate of workspaceCandidates(workspace, resolveEffectiveComfyUICodeBase())) { if (existsSync(candidate)) { return candidate; } } for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) { for (const name of executableNames()) { const candidate = join(dir.replace(/^"|"$/g, ""), name); if (existsSync(candidate)) { return candidate; } } } return null; } function buildArgs(args, options) { const result = ["--json"]; const workspace = options.workspace === undefined ? defaultWorkspace() : options.workspace; if (workspace) result.push("--workspace", workspace); if (options.where) result.push("--where", options.where); result.push("--skip-prompt", ...args); return result; } export function parseComfyCliEnvelope(stdout, stderr = "", exitCode) { const lines = stdout.trim().split(/\r?\n/).filter(Boolean); let parsed; for (let index = lines.length - 1; index >= 0; index--) { try { parsed = JSON.parse(lines[index]); break; } catch { // JSON streaming commands may emit events before the final envelope. } } if (!parsed || typeof parsed !== "object") { throw new Error(`comfy-cli did not return a JSON envelope${exitCode == null ? "" : ` (exit ${exitCode})`}: ${stderr || stdout}`); } const envelope = parsed; if (envelope.schema !== "envelope/1" || envelope.type !== "envelope" || typeof envelope.ok !== "boolean" || typeof envelope.command !== "string" || typeof envelope.version !== "string") { throw new Error("comfy-cli returned JSON that does not match envelope/1"); } return envelope; } function hasJsonRecord(stdout) { return stdout.trim().split(/\r?\n/).some((line) => { try { JSON.parse(line); return true; } catch { return false; } }); } /** * Output lines that are PROGRESS, not diagnosis (#417). * * ENUMERATED ON PURPOSE, and this is the whole safety argument. The failure this guards * against is a non-zero exit whose captured output says nothing about why — the reporter * was handed `Start downloading URL ... into ...` in the place a reason belongs. But a * filter that is too eager does something strictly worse: it discards a REAL error message * and replaces it with "no reason available", un-shipping the fix while looking like it * worked. A keyword test ("downloading", "%") would do exactly that to * `HTTP 403 while downloading ...`. * * So each entry matches one KNOWN emitter, anchored, and anything unrecognised is treated * as a real message and preserved. Being wrong in the direction of showing the user too * much is recoverable; being wrong the other way hides the only evidence they had. */ const PROGRESS_ONLY_LINE_PATTERNS = [ // comfy-cli's own pre-download announcement — the line from the report. The real // emitter is `print(f"Start downloading URL: {url} into {local_filepath}")` // (comfy_cli/command/models/models.py) — WITH a colon, which my first fixtures omitted. /^start downloading url:?\s.*\binto\b/i, // huggingface_hub's tqdm, which ALWAYS carries a desc: // "y.safetensors: 45%|████▌ | 1.20G/2.70G [00:30<00:40, 40.0MB/s]" // Every earlier pattern was anchored `^\s*\d`, so a desc-prefixed bar — the only tqdm // this tool actually produces — matched none of them. #417 was therefore still live on // the gated-Hugging-Face path, which is the path the new hint tells the user to suspect. // The desc is bounded and may not contain a `%`, so it cannot swallow a sentence that // merely happens to precede a percentage. /^[^%]{0,120}:\s*\d{1,3}%\|/, // Bare tqdm: " 45%|█████| 1.2G/2.7G [00:30<00:40, 40.0MB/s]". The size quantifiers are // BOUNDED: `[\d.]+\s*\S*\/` is ambiguous, and on a pathological single line it // backtracks quadratically — measured 13.3s at 200 KB, which on a 16 MB capture // (execFile's maxBuffer) would block the event loop for hours. /^\s*\d{1,3}%\|[^|]{0,200}\|\s*[\d.]{1,20}\s*\S{0,10}\/[\d.]{1,20}\s*\S{0,10}/i, // tqdm with no bar drawn (non-tty): " 45%| | 1.2G/2.7G" /^\s*\d{1,3}%\|/, // A bare percentage or a "Downloading: 45%" heartbeat. /^\s*(downloading:?\s*)?\d{1,3}(\.\d+)?%\s*$/i, // A drawn bar with no other text. /^[\s█▉▊▋▌▍▎▏#=>.\-\[\]|]+$/, ]; /** * Does this captured output contain anything that could explain a failure? * * Returns the surviving text, or null when every line was recognised progress. Null means * "I could not determine the reason" — a distinct third state from "the reason is X", and * it must never be reported as one (#796). */ export function failureReasonFrom(text) { const meaningful = text // A progress bar redraws with \r; without splitting on it the whole bar is ONE line // ending in a real error, or one line of pure progress, depending on the emitter. .split(/\r\n|\r|\n/) .map((line) => line.trim()) .filter((line) => line.length > 0) .filter((line) => !PROGRESS_ONLY_LINE_PATTERNS.some((re) => re.test(line))); return meaningful.length ? meaningful.join("\n") : null; } export function normalizeComfyCliResult(args, options, result, version) { try { return parseComfyCliEnvelope(result.stdout, result.stderr, result.exitCode); } catch (error) { if (hasJsonRecord(result.stdout)) throw error; } const command = args.join(" "); const details = { stdout: result.stdout.trim(), stderr: result.stderr.trim() }; const alreadyStopped = args.length === 1 && args[0] === "stop" && /no comfyui is running in the background/i.test(details.stderr || details.stdout); if (alreadyStopped) { return { schema: "envelope/1", type: "envelope", ok: true, command, version, where: options.where ?? null, data: { ...details, already_stopped: true }, error: null, }; } if (result.exitCode !== 0) { // #417 — A PROGRESS LINE IS NOT A FAILURE REASON. // // This used to report `stderr || stdout` verbatim. When comfy-cli dies mid-download // its stderr often holds nothing but the progress it had printed so far, so the // reporter's error message was `Start downloading URL ... into ...` — a sentence that // reads like a diagnosis, names no cause, and sent them looking at their URL and // destination directory (both fine) instead of at the download. // // The three states are: a reason, no reason, and — the one that caused this — no // reason DRESSED AS one. Saying plainly that the command produced no error output is // less satisfying and far more useful, because it redirects the search instead of // misdirecting it. The raw output stays in `details` either way; nothing is dropped. const reason = failureReasonFrom(details.stderr) ?? failureReasonFrom(details.stdout); // Only a DOWNLOAD may be described as having produced download progress. Read from the // argv this call actually ran, not from the tool that happened to call us. const isDownload = args.includes("download") || args.includes("--url"); return { schema: "envelope/1", type: "envelope", ok: false, command, version, where: options.where ?? null, data: null, error: { code: "legacy_command_failed", message: reason ?? `comfy-cli exited with code ${result.exitCode} and printed no error, so the cause is ` + `not visible from here${isDownload ? " — the output was download progress only" : ""}.`, ...(reason ? {} : { // THE DIAGNOSIS IS SCOPED TO THE COMMAND THAT EARNED IT. This function // normalizes the ENTIRE comfy-cli surface — stop, launch, run, node // install/update, models list/search/remove, skills_*, env — and the first // version asserted "its output was download progress only" and offered // disk-space-and-gated-HF-auth advice for every one of them. A failing // `comfy node install` would have been handed a fabricated download story. // // Which is this issue's own defect, inverted: #417 is about stating a cause // that was never established. Replacing a progress line with a confident // guess about a different command is the same error with better prose. hint: isDownload ? "Nothing in the command's output says why it stopped. Check, in this order: " + "free disk space on the destination drive; whether the source needs auth (a " + "gated Hugging Face repo returns 401/403 and some downloaders exit without " + "printing it); and whether an antivirus or the OS killed the process. " + "Re-running with the same arguments will show the same message — the missing " + "information is on comfy-cli's side, not this tool's." : "Nothing in the command's output says why it stopped, and this tool cannot " + "infer it. Re-run the same command directly in a terminal, where comfy-cli " + "may print more than it does when its output is captured.", }), details: { ...details, exit_code: result.exitCode }, }, }; } return { schema: "envelope/1", type: "envelope", ok: true, command, version, where: options.where ?? null, data: details, error: null, }; } function requireExecutable(options) { // REFUSE BEFORE RESOLVING, in remote mode (codex gate P0). // // comfy-cli acts on a LOCAL install. With `--comfyui-url` the workspace // resolver now correctly returns nothing — but that is exactly what makes this // dangerous rather than safe: `buildArgs` then omits `--workspace`, the PATH // fallback below still finds a global `comfy`, and the CLI falls back to // WHATEVER workspace it defaults to. `models_remove` therefore deletes models // from some unrelated local install while the session is connected elsewhere. // // "No workspace could be resolved" was being treated as "no workspace will be // used". It is not: it hands the choice to the CLI. This is the one choke-point // both `runComfyCli` and `runComfyCliSync` pass through, which is why the // refusal belongs here — nothing has run at this point. if (isRemoteMode()) { throw new Error("This session targets a REMOTE ComfyUI (--comfyui-url), and comfy-cli only acts on a " + "LOCAL install — so there is no install here that this session is about. Nothing was " + "run. Run comfy-cli on the machine the install lives on, or point this session at the " + "local install first."); } const executable = resolveComfyCliExecutable({ workspace: options.workspace }); if (!executable) { throw new Error("comfy-cli was not found. Install comfy-cli>=1.11.1 and ensure `comfy` is on PATH, " + "set COMFY_CLI_PATH, or install it in the selected ComfyUI workspace's .venv " + "(the COMFYUI_CODE_PATH checkout on split installs)."); } return executable; } function unsupportedVersionEnvelope(args, options, version) { return { schema: "envelope/1", type: "envelope", ok: false, command: args.join(" "), version: version ?? "unknown", where: options.where ?? null, data: null, error: { code: "unsupported_version", message: `comfy-cli >=1.11.1 is required; found ${version ?? "an unrecognized version"}.`, hint: "Upgrade with: python -m pip install --upgrade comfy-cli", }, }; } export async function runComfyCli(args, options = {}) { const executable = requireExecutable(options); const detectedVersion = getExecutableVersion(executable); if (!isSupportedComfyCliVersion(detectedVersion)) { return unsupportedVersionEnvelope(args, options, detectedVersion); } const version = detectedVersion; if (options.idleTimeoutMs != null) { try { const child = childProcess.spawn(executable, buildArgs(args, options), { windowsHide: true, env: { ...process.env, PYTHONUTF8: "1", ...options.env }, cwd: options.cwd, }); child.stdout?.setEncoding("utf8"); child.stderr?.setEncoding("utf8"); const result = await awaitProcessWithIdleTimeout(child, options.idleTimeoutMs); if (result.timedOut) { return { schema: "envelope/1", type: "envelope", ok: false, command: args.join(" "), version, where: options.where ?? null, data: null, error: { code: "idle_timeout", message: `comfy-cli produced no output for ${Math.round(options.idleTimeoutMs / 1000)}s and was terminated as stalled.`, hint: "The download appears stuck. Check network connectivity and the source URL, then retry.", details: { stdout: result.stdout.trim(), stderr: result.stderr.trim() }, }, }; } return normalizeComfyCliResult(args, options, result, version); } catch (error) { const spawnError = error; if (spawnError.code === "ENOENT") throw error; return normalizeComfyCliResult(args, options, { stdout: "", stderr: spawnError.message, exitCode: 1 }, version); } } try { const result = await new Promise((resolve, reject) => { childProcess.execFile(executable, buildArgs(args, options), { encoding: "utf8", timeout: options.timeoutMs ?? 120_000, windowsHide: true, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, PYTHONUTF8: "1", ...options.env }, cwd: options.cwd, }, (error, stdout, stderr) => error ? reject(Object.assign(error, { stdout, stderr })) : resolve({ stdout, stderr })); }); return normalizeComfyCliResult(args, options, { ...result, exitCode: 0 }, version); } catch (error) { const processError = error; if (processError.code === "ENOENT") throw error; const exitCode = typeof processError.code === "number" ? processError.code : 1; return normalizeComfyCliResult(args, options, { stdout: processError.stdout ?? "", stderr: processError.stderr || processError.message, exitCode, }, version); } } export function runComfyCliSync(args, options = {}) { const executable = requireExecutable(options); const detectedVersion = getExecutableVersion(executable); if (!isSupportedComfyCliVersion(detectedVersion)) { return unsupportedVersionEnvelope(args, options, detectedVersion); } const version = detectedVersion; try { const stdout = childProcess.execFileSync(executable, buildArgs(args, options), { encoding: "utf8", timeout: options.timeoutMs ?? 120_000, windowsHide: true, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, PYTHONUTF8: "1", ...options.env }, cwd: options.cwd, }); return normalizeComfyCliResult(args, options, { stdout, stderr: "", exitCode: 0 }, version); } catch (error) { const processError = error; if (processError.code === "ENOENT") throw error; const stdout = processError.stdout?.toString() ?? ""; const stderr = processError.stderr?.toString() || processError.message; return normalizeComfyCliResult(args, options, { stdout, stderr, exitCode: processError.status ?? 1 }, version); } } function getExecutableVersion(executable) { if (versionCache.has(executable)) return versionCache.get(executable) ?? null; const result = childProcess.spawnSync(executable, ["--json", "--version"], { encoding: "utf8", windowsHide: true, timeout: 10_000, maxBuffer: 1024 * 1024, env: { ...process.env, PYTHONUTF8: "1" }, }); try { const version = parseComfyCliEnvelope(result.stdout ?? "", result.stderr ?? "", result.status ?? undefined).version; versionCache.set(executable, version); return version; } catch { versionCache.set(executable, null); return null; } } export function getComfyCliVersion(options = {}) { // Read-only, but it still SPAWNS a local `comfy` (codex gate). In remote mode // there is no local install this session is about, so probing one and reporting // its version would describe a CLI that must never be used from here — and // `isComfyCliUsable` below would then advertise it as available. "Not usable" // is the honest answer, and it is the same answer `requireExecutable` gives. if (isRemoteMode()) return null; const executable = resolveComfyCliExecutable({ workspace: options.workspace }); return executable ? getExecutableVersion(executable) : null; } /** * Whether a usable comfy-cli (found AND version-supported) is available for the * given workspace. A found-but-unrecognized/too-old CLI is NOT usable — read-only * tools treat that identically to "absent" so they can fall back to the connected * server instead of surfacing `unsupported_version` (#487). */ export function isComfyCliUsable(options = {}) { const executable = resolveComfyCliExecutable({ workspace: options.workspace }); if (!executable) return false; return isSupportedComfyCliVersion(getExecutableVersion(executable)); } export function isSupportedComfyCliVersion(version) { if (!version) return false; const parts = version.split(".").slice(0, 3).map((part) => Number.parseInt(part, 10)); if (parts.some((part) => !Number.isFinite(part))) return false; for (let index = 0; index < MIN_COMFY_CLI_VERSION.length; index++) { if ((parts[index] ?? 0) > MIN_COMFY_CLI_VERSION[index]) return true; if ((parts[index] ?? 0) < MIN_COMFY_CLI_VERSION[index]) return false; } return true; } export function shouldUseComfyCli(explicit, localMode, executable, version) { if (explicit !== undefined) return explicit; return localMode && executable !== null && isSupportedComfyCliVersion(version); } export function assertComfyCliOk(envelope) { if (!envelope.ok) { const error = envelope.error; throw new Error(`${error?.code ? `${error.code}: ` : ""}${error?.message ?? "comfy-cli command failed"}${error?.hint ? ` (${error.hint})` : ""}`); } return envelope; } //# sourceMappingURL=comfy-cli.js.map