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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 62 additions & 22 deletions comfy_cli/command/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,9 @@ def set_slot_cmd(
typer.Option(
"--stdout/--in-place",
show_default=False,
help="Print the result to stdout instead of writing back to <file>.",
help="Return the result instead of writing back to <file>: `data.workflow_json` in the "
"envelope under --json, or the raw workflow on stdout with --no-json. Redirecting "
"stdout selects JSON mode, so `--stdout > new.json` needs --no-json to get a raw workflow.",
),
] = False,
input_path: Annotated[str | None, typer.Option("--input", show_default=False)] = None,
Expand Down Expand Up @@ -266,43 +268,64 @@ def set_slot_cmd(
)
raise typer.Exit(code=1) from e

serialized = json.dumps(new_workflow, indent=2)
# Fold the stale-cache note into `warnings` up front so every exit path —
# including the `--stdout` early return below — reports it.
if _stale:
warnings = list(warnings) + [
{"code": "object_info_stale", "message": f"served from cache ({_stale['source']}): {_stale['reason']}"}
]

if stdout:
# `--stdout` in human mode is a pipe target: print the raw workflow so
# `comfy workflow set-slot ... --stdout --no-json > new.json` keeps working.
# `--no-json` is required there: a redirect makes stdout a non-TTY, which
# `Renderer.resolve` reads as JSON mode. In JSON mode stdout is reserved
# for the envelope (see docs/json-output.md), so the modified workflow
# rides in `data.workflow_json` instead — a bare workflow object is not an
# `envelope/1` and machine callers reject it.
if stdout and renderer.is_pretty():
Comment thread
mattmillerai marked this conversation as resolved.
import sys

sys.stdout.write(serialized)
sys.stdout.write(json.dumps(new_workflow, indent=2))
sys.stdout.write("\n")
sys.stdout.flush()
# stdout now holds exactly the workflow; warnings would corrupt it, so
# they go to stderr rather than being dropped.
for w in warnings:
renderer.stderr_console().print(f"[yellow]warning:[/yellow] {w}")
return

_atomic_write_text(p, serialized)
if not stdout:
_atomic_write_text(p, json.dumps(new_workflow, indent=2))

payload = {
payload: dict[str, Any] = {
"workflow": str(p),
"applied": list(overrides_dict.keys()),
"warnings": warnings,
"wrote": str(p),
"wrote": None if stdout else str(p),
}
if stdout:
payload["out"] = "stdout"
payload["workflow_json"] = new_workflow
if _stale:
payload["stale"] = True
payload["warnings"] = list(warnings) + [
{"code": "object_info_stale", "message": f"served from cache ({_stale['source']}): {_stale['reason']}"}
]
if renderer.is_pretty():
rprint(f"[bold green]✓[/bold green] applied {len(overrides_dict)} slot(s) → [dim]{p}[/dim]")
for addr in overrides_dict:
rprint(f" [dim]·[/dim] {addr}")
for w in warnings:
rprint(f" [yellow]warning:[/yellow] {w}")
renderer.emit(payload, command="workflow set-slot", changed=True)
renderer.emit(payload, command="workflow set-slot", changed=not stdout)


# ---------------------------------------------------------------------------
# vary
# ---------------------------------------------------------------------------


@app.command("vary", help="Produce N workflow variants from a per-slot value list. Emits NDJSON.")
@app.command(
"vary",
help="Produce N workflow variants from a per-slot value list. Emits NDJSON (or `data.variants` under --json).",
)
@tracking.track_command("workflow")
def vary_cmd(
file: Annotated[str, typer.Argument(help="Frontend-format workflow JSON.")],
Expand All @@ -321,7 +344,9 @@ def vary_cmd(
typer.Option(
"--out-dir",
show_default=False,
help="If set, write each variation to <out-dir>/<stem>_<N>.json. Otherwise emit NDJSON to stdout.",
help="If set, write each variation to <out-dir>/<stem>_<N>.json. Otherwise return the "
"variants: `data.variants` in the envelope under --json, or NDJSON on stdout with "
"--no-json. Redirecting stdout selects JSON mode, so `> out.ndjson` needs --no-json.",
),
] = None,
):
Expand Down Expand Up @@ -366,43 +391,58 @@ def vary_cmd(
renderer.error(code="workflow_slot_invalid", message=str(e))
raise typer.Exit(code=1) from e

if _stale:
warnings = list(warnings) + [
{"code": "object_info_stale", "message": f"served from cache ({_stale['source']}): {_stale['reason']}"}
]

written: list[str] = []
# Same envelope contract as set-slot --stdout: raw NDJSON on stdout is the
# human/pipe form; in JSON mode stdout belongs to the envelope, so the
# variants ride in `data.variants` instead.
variants: list[dict[str, Any]] | None = None
# True once stdout carries the NDJSON stream — the human summary below must
# then go to stderr, or `comfy workflow vary ... --no-json > out.ndjson`
# ends with a non-JSON line and breaks strict line-delimited consumers.
piped_ndjson = False
if out_dir:
out = Path(out_dir).expanduser()
out.mkdir(parents=True, exist_ok=True)
for i, wf in enumerate(workflows):
target = out / f"{p.stem}_{i:03d}.json"
_atomic_write_text(target, json.dumps(wf, indent=2))
written.append(str(target))
else:
elif renderer.is_pretty():
import sys

for wf in workflows:
sys.stdout.write(json.dumps(wf))
sys.stdout.write("\n")
sys.stdout.flush()
Comment thread
mattmillerai marked this conversation as resolved.
piped_ndjson = True
else:
variants = list(workflows)
Comment thread
mattmillerai marked this conversation as resolved.

payload = {
payload: dict[str, Any] = {
"workflow": str(p),
"count": len(workflows),
"warnings": warnings,
"out_dir": str(Path(out_dir).expanduser()) if out_dir else None,
"written": written,
"variants": variants,
}
if _stale:
payload["stale"] = True
payload["warnings"] = list(warnings) + [
{"code": "object_info_stale", "message": f"served from cache ({_stale['source']}): {_stale['reason']}"}
]
if renderer.is_pretty():
rprint(f"[bold green]✓[/bold green] produced {len(workflows)} variation(s)")
say = renderer.stderr_console().print if piped_ndjson else rprint
say(f"[bold green]✓[/bold green] produced {len(workflows)} variation(s)")
if written:
for path in written[:5]:
rprint(f" [dim]→[/dim] {path}")
say(f" [dim]→[/dim] {path}")
if len(written) > 5:
rprint(f" [dim]… and {len(written) - 5} more[/dim]")
say(f" [dim]… and {len(written) - 5} more[/dim]")
for w in warnings:
rprint(f" [yellow]warning:[/yellow] {w}")
say(f" [yellow]warning:[/yellow] {w}")
renderer.emit(payload, command="workflow vary", changed=bool(written))


Expand Down
7 changes: 6 additions & 1 deletion comfy_cli/schemas/workflow.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
"slots": { "type": "array" },
"applied": { "type": "array" },
"warnings": { "type": "array" },
"wrote": { "type": "string" },
"wrote": { "type": ["string", "null"], "description": "file written, or null when nothing was written (set-slot --stdout)" },
"out": { "type": "string", "description": "set-slot: where the result went — \"stdout\" when --stdout returned it instead of writing the file" },
"workflow_json": { "type": "object", "description": "set-slot --stdout: the modified workflow itself (human mode prints it raw on stdout instead)" },
"variants": { "type": ["array", "null"], "description": "vary without --out-dir: the produced workflows (human mode prints them as NDJSON on stdout instead); null when they were written to --out-dir" },
"written": { "type": "array" },
"out_dir": { "type": ["string", "null"] },
"variations_summary": { "type": "array" },
"item_map": {
"type": "object",
Expand Down
119 changes: 108 additions & 11 deletions tests/comfy_cli/command/test_workflow_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ def _force_json_renderer():
return r


def _force_pretty_renderer():
r = Renderer.resolve(
is_stdout_tty=True,
env={},
caller=Caller(kind="user", agentic=False, source_env=None),
no_json_flag=True,
)
r.mode = OutputMode.PRETTY
set_renderer(r)
return r


def _object_info():
return {
"KSampler": {
Expand Down Expand Up @@ -266,13 +278,20 @@ def _template_workflow():
}


def _run(args: list[str], capsys) -> dict[str, Any]:
_force_json_renderer()
def _invoke(args: list[str], capsys, renderer_factory=_force_json_renderer) -> tuple[str, str, Any]:
"""Run the command and return (stdout text, stderr text, CliRunner result)."""
renderer_factory()
runner = CliRunner()
result = runner.invoke(workflow_cmd.app, args, standalone_mode=False)
captured = capsys.readouterr().out
streams = capsys.readouterr()
captured = streams.out
if not captured.strip():
captured = result.stdout or ""
return captured, streams.err, result


def _run(args: list[str], capsys) -> dict[str, Any]:
captured, _err, result = _invoke(args, capsys)
lines = [ln for ln in captured.strip().splitlines() if ln.strip()]
for line in reversed(lines):
try:
Expand Down Expand Up @@ -345,17 +364,53 @@ def test_set_slot_stdout_mode(self, patched_graph, tmp_path, capsys):
wf = _direct_workflow()
path = _write_workflow(tmp_path, wf)
original_text = path.read_text()
# --stdout prints to stdout instead of modifying file
_force_json_renderer()
runner = CliRunner()
runner.invoke(
workflow_cmd.app,
["set-slot", str(path), '6.text="a dog"', "--stdout"],
standalone_mode=False,
)
# --stdout returns the result instead of modifying the file
_invoke(["set-slot", str(path), '6.text="a dog"', "--stdout"], capsys)
# File should be unchanged
assert path.read_text() == original_text

def test_set_slot_stdout_emits_envelope_under_json(self, patched_graph, tmp_path, capsys):
"""`--json ... --stdout` must emit an envelope/1 carrying the modified workflow.

Regression: it used to write the bare workflow object to stdout and skip
`renderer.emit`, so every machine caller (the local MCP's default
`set_workflow_slot`) saw "no JSON" and failed.
"""
path = _write_workflow(tmp_path, _direct_workflow())
original_text = path.read_text()
captured, _stderr, _ = _invoke(["set-slot", str(path), '6.text="a dog"', "--stdout"], capsys)

lines = [ln for ln in captured.strip().splitlines() if ln.strip()]
assert len(lines) == 1, f"JSON mode must put exactly one envelope on stdout, got {lines[:3]}"
env = json.loads(lines[0])
assert env["schema"] == "envelope/1"
assert env["type"] == "envelope"
assert env["ok"] is True
assert env["changed"] is False, "--stdout writes nothing, so the envelope must not claim a change"

data = env["data"]
assert data["out"] == "stdout"
assert data["wrote"] is None
assert data["applied"] == ["6.text"]
# data round-trips the applied override…
clip = next(n for n in data["workflow_json"]["nodes"] if n["id"] == 6)
assert clip["widgets_values"][0] == "a dog"
# …and the source file is untouched.
assert path.read_text() == original_text

def test_set_slot_stdout_prints_raw_workflow_in_human_mode(self, patched_graph, tmp_path, capsys):
"""Without --json, --stdout still prints the bare workflow so it stays pipeable."""
path = _write_workflow(tmp_path, _direct_workflow())
captured, _stderr, _ = _invoke(
["set-slot", str(path), '6.text="a dog"', "--stdout"],
capsys,
_force_pretty_renderer,
)
wf = json.loads(captured)
assert "nodes" in wf and "type" not in wf, "human mode must print the workflow, not an envelope"
clip = next(n for n in wf["nodes"] if n["id"] == 6)
assert clip["widgets_values"][0] == "a dog"

def test_set_slot_invalid_format(self, patched_graph, tmp_path, capsys):
path = _write_workflow(tmp_path, _direct_workflow())
env = _run(["set-slot", str(path), "bad_no_equals"], capsys)
Expand Down Expand Up @@ -391,9 +446,51 @@ def test_vary_produces_files(self, patched_graph, tmp_path, capsys):
)
assert env["ok"] is True
assert env["data"]["count"] == 3
# Variants went to disk, so they are not inlined in the envelope.
assert env["data"]["variants"] is None
files = sorted(out_dir.glob("*.json"))
assert len(files) == 3

def test_vary_without_out_dir_puts_variants_in_envelope(self, patched_graph, tmp_path, capsys):
"""Same envelope contract as set-slot --stdout: under --json the variants
ride in `data.variants` instead of being dumped raw onto stdout."""
path = _write_workflow(tmp_path, _direct_workflow())
captured, _stderr, _ = _invoke(["vary", str(path), "--slot", "3.seed=[1,2]"], capsys)

lines = [ln for ln in captured.strip().splitlines() if ln.strip()]
assert len(lines) == 1, f"JSON mode must put exactly one envelope on stdout, got {lines[:3]}"
env = json.loads(lines[0])
assert env["ok"] is True
assert env["data"]["count"] == 2
variants = env["data"]["variants"]
assert len(variants) == 2
seeds = [next(n for n in wf["nodes"] if n["id"] == 3)["widgets_values"][0] for wf in variants]
assert seeds == [1, 2]

def test_vary_without_out_dir_still_ndjson_in_human_mode(self, patched_graph, tmp_path, capsys):
"""Without --json, the variants stay raw NDJSON on stdout for piping.

stdout must be *strictly* line-delimited JSON: the `✓ produced N
variation(s)` summary used to be appended to it, which broke
`comfy workflow vary ... --no-json > out.ndjson` for strict consumers.
It belongs on stderr.
"""
path = _write_workflow(tmp_path, _direct_workflow())
_force_pretty_renderer()
# Called directly rather than through CliRunner: CliRunner merges
# stderr into stdout, and the stdout/stderr split is what's under test.
workflow_cmd.vary_cmd(str(path), ["3.seed=[1,2]"])
streams = capsys.readouterr()
captured, stderr = streams.out, streams.err
lines = [ln for ln in captured.splitlines() if ln.strip()]
# Every non-blank stdout line parses as JSON — no summary/footer.
wfs = [json.loads(ln) for ln in lines]
assert len(wfs) == 2
seeds = [next(n for n in wf["nodes"] if n["id"] == 3)["widgets_values"][0] for wf in wfs]
assert seeds == [1, 2]
# The human summary is still shown, just not on the NDJSON stream.
assert "produced 2 variation(s)" in stderr

def test_vary_mismatched_lengths_rejected(self, patched_graph, tmp_path, capsys):
path = _write_workflow(tmp_path, _direct_workflow())
env = _run(
Expand Down
Loading