Skip to content
15 changes: 10 additions & 5 deletions comfy_cli/command/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

import requests
import typer
from rich.markup import escape

from comfy_cli import constants, download_state, tracking, ui
from comfy_cli.config_manager import ConfigManager
Expand All @@ -23,6 +22,7 @@
)
from comfy_cli.output import get_renderer
from comfy_cli.output import rprint as print # context-aware: stderr in JSON mode
from comfy_cli.output.sanitize import sanitize_markup
from comfy_cli.workspace_manager import WorkspaceManager

app = typer.Typer()
Expand Down Expand Up @@ -396,9 +396,12 @@ def download(
try:
download_file(url, local_filepath, headers, downloader=resolved_downloader)
except DownloadException as e:
# escape() so a dynamic error message containing "[/]" or similar
# rich-markup syntax doesn't trigger MarkupError or get mis-rendered.
print(f"[bold red]{escape(str(e))}[/bold red]")
# sanitize_markup() is escape() plus ANSI/control-byte stripping: the
# markup half stops "[/]" in a dynamic message from raising
# MarkupError, and the strip stops a server-chosen message (the 401
# branch of guess_status_code_reason echoes one, and aria2 relays its
# own) from clearing the screen or repainting earlier output.
print(f"[bold red]{sanitize_markup(e)}[/bold red]")
raise typer.Exit(code=1) from None

elapsed = time.monotonic() - start_time
Expand Down Expand Up @@ -701,7 +704,9 @@ def _size(value) -> str:
ui.display_table(data, ["ID", "Status", "%", "Bytes", "Elapsed", "Destination"])
for row in rows:
if row.get("error"):
print(f"[bold red]{row['id']}: {escape(str(row['error']))}[/bold red]")
# The state file is written by a detached worker, so this string is
# server-influenced text read back from disk — sanitize on the way out.
print(f"[bold red]{row['id']}: {sanitize_markup(row['error'])}[/bold red]")


def _reconciled(state: download_state.DownloadState) -> tuple[download_state.DownloadState, bool]:
Expand Down
36 changes: 23 additions & 13 deletions comfy_cli/command/models/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

from comfy_cli import tracking
from comfy_cli.output import get_renderer, rprint
from comfy_cli.output.sanitize import sanitize_markup

app = typer.Typer(no_args_is_help=True, help="Discover models — folders, files, and the cloud asset catalog.")

Expand Down Expand Up @@ -208,7 +209,11 @@ def list_folders_cmd(
tbl.add_column("folder")
tbl.add_column("subfolders", style="dim")
for r in rows[:200]:
tbl.add_row(r["name"], ", ".join(r["subfolders"]) if r["subfolders"] else "")
# ``add_row`` parses markup in ``str`` cells; these are server names.
tbl.add_row(
sanitize_markup(r["name"]),
sanitize_markup(", ".join(r["subfolders"])) if r["subfolders"] else "",
)
renderer.console().print(tbl)
rprint(f"[dim]{len(rows)} folders ({payload['mode']})[/dim]")
renderer.emit(payload, command="models list-folders")
Expand Down Expand Up @@ -296,7 +301,7 @@ def list_folder_cmd(
tbl.add_column("name")
tbl.add_column("pathIndex", style="dim", justify="right")
for f in files:
tbl.add_row(f["name"], str(f["pathIndex"]))
tbl.add_row(sanitize_markup(f["name"]), sanitize_markup(f["pathIndex"]))
renderer.console().print(tbl)
tail = f" (of {total})" if total != len(files) else ""
rprint(f"[dim]{len(files)} files in {folder!r}{tail} ({payload['mode']})[/dim]")
Expand Down Expand Up @@ -509,11 +514,14 @@ def search_cmd(
tbl.add_column("base_model", style="dim")
tbl.add_column("source", style="dim")
for r in rows:
# Truncate first, then sanitize: escaping last keeps the markup
# escapes balanced, and a sequence cut in half by the slice is
# cleaned up rather than left dangling.
tbl.add_row(
r["name"][:60],
r["type"] or "",
r["base_model"] or "",
(r["source_url"] or "")[:48],
sanitize_markup(r["name"][:60]),
sanitize_markup(r["type"] or ""),
sanitize_markup(r["base_model"] or ""),
sanitize_markup((r["source_url"] or "")[:48]),
)
renderer.console().print(tbl)
tail = f" (of {total} total)" if total != len(rows) else ""
Expand Down Expand Up @@ -603,19 +611,21 @@ def show_cmd(
}
if renderer.is_pretty():
row = payload["row"]
rprint(f"[bold]{row['name']}[/bold]")
rprint(f" type: {row['type']}")
# Every field below is catalog text the server chose, interpolated into
# a markup-parsing sink — sanitize each one (see comfy_cli.output.sanitize).
rprint(f"[bold]{sanitize_markup(row['name'])}[/bold]")
rprint(f" type: {sanitize_markup(row['type'])}")
if row.get("base_model"):
rprint(f" base_model: {row['base_model']}")
rprint(f" base_model: {sanitize_markup(row['base_model'])}")
if row.get("tags"):
rprint(f" tags: {', '.join(row['tags'])}")
rprint(f" tags: {sanitize_markup(', '.join(row['tags']))}")
if row.get("source_url"):
rprint(f" source: {row['source_url']}")
rprint(f" source: {sanitize_markup(row['source_url'])}")
if row.get("preview_url"):
rprint(f" preview: {row['preview_url']}")
rprint(f" preview: {sanitize_markup(row['preview_url'])}")
if row.get("size"):
rprint(f" size: {row['size']:,} bytes")
trained = row.get("trained_words")
if trained:
rprint(f" trained: {', '.join(trained)}")
rprint(f" trained: {sanitize_markup(', '.join(trained))}")
renderer.emit(payload, command="models show")
51 changes: 30 additions & 21 deletions comfy_cli/command/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from comfy_cli import tracking
from comfy_cli.cql.engine import Graph, LoadError
from comfy_cli.output import get_renderer, rprint
from comfy_cli.output.sanitize import sanitize_markup

app = typer.Typer(no_args_is_help=True, help="Introspect ComfyUI node classes (inputs, outputs, categories).")

Expand Down Expand Up @@ -281,8 +282,10 @@ def ls_cmd(
tbl.add_column("category", style="dim")
tbl.add_column("outputs")
for m in nodes:
outs = ", ".join(m.output_types()) or "[dim]—[/dim]"
tbl.add_row(m.id, m.category or "", outs)
# object_info text into a markup sink; the em-dash fallback is
# ours, so only the joined server values are escaped.
outs = sanitize_markup(", ".join(m.output_types())) or "[dim]—[/dim]"
tbl.add_row(sanitize_markup(m.id), sanitize_markup(m.category or ""), outs)
renderer.console().print(tbl)
rprint(f"[dim]{len(nodes)} node(s)[/dim]")
renderer.emit(payload, command="nodes ls")
Expand Down Expand Up @@ -356,18 +359,18 @@ def show_cmd(
from rich.table import Table

rprint(
f"[bold]{payload['name']}[/bold]"
f"[bold]{sanitize_markup(payload['name'])}[/bold]"
+ (
f" [dim]({payload['display_name']})[/dim]"
f" [dim]({sanitize_markup(payload['display_name'])})[/dim]"
if payload["display_name"] and payload["display_name"] != payload["name"]
else ""
)
)
if payload["category"]:
rprint(f"[dim]category[/dim] {payload['category']}")
rprint(f"[dim]category[/dim] {sanitize_markup(payload['category'])}")
if payload["description"]:
rprint(f"[dim]{payload['description']}[/dim]")
outs = ", ".join(payload["output_types"]) or "(none)"
rprint(f"[dim]{sanitize_markup(payload['description'])}[/dim]")
outs = sanitize_markup(", ".join(payload["output_types"])) or "(none)"
rprint(f"[dim]outputs[/dim] {outs}")
rprint("")
if payload["inputs"]:
Expand All @@ -380,10 +383,10 @@ def show_cmd(
opts = i.get("options") or {}
default = opts.get("default")
tbl.add_row(
str(i.get("name") or ""),
str(i.get("type") or ""),
str(i.get("section") or ""),
"" if default is None else str(default),
sanitize_markup(i.get("name") or ""),
sanitize_markup(i.get("type") or ""),
sanitize_markup(i.get("section") or ""),
"" if default is None else sanitize_markup(default),
)
renderer.console().print(tbl)
renderer.emit(payload, command="nodes show")
Expand Down Expand Up @@ -487,8 +490,9 @@ def search_cmd(
tbl.add_column("category", style="dim")
tbl.add_column("description", style="dim")
for m in matched:
desc = m.description[:60]
tbl.add_row(m.id, m.category or "", desc)
# Truncate first, then escape, so the escapes stay balanced.
desc = sanitize_markup(m.description[:60])
tbl.add_row(sanitize_markup(m.id), sanitize_markup(m.category or ""), desc)
renderer.console().print(tbl)
rprint(f"[dim]{len(matched)} node(s)[/dim]")
renderer.emit(payload, command="nodes search")
Expand Down Expand Up @@ -559,8 +563,8 @@ def upstream_cmd(
tbl.add_column("category", style="dim")
tbl.add_column("outputs")
for r in rows:
outs = ", ".join(r["output_types"]) or "[dim]—[/dim]"
tbl.add_row(r["name"] or "", r["category"] or "", outs)
outs = sanitize_markup(", ".join(r["output_types"])) or "[dim]—[/dim]"
tbl.add_row(sanitize_markup(r["name"] or ""), sanitize_markup(r["category"] or ""), outs)
renderer.console().print(tbl)
tail = f" of {total_upstream}" if total_upstream != len(rows) else ""
rprint(f"[dim]{len(rows)} upstream node(s){tail}[/dim]")
Expand Down Expand Up @@ -617,8 +621,8 @@ def downstream_cmd(
tbl.add_column("category", style="dim")
tbl.add_column("outputs")
for r in rows:
outs = ", ".join(r["output_types"]) or "[dim]—[/dim]"
tbl.add_row(r["name"] or "", r["category"] or "", outs)
outs = sanitize_markup(", ".join(r["output_types"])) or "[dim]—[/dim]"
tbl.add_row(sanitize_markup(r["name"] or ""), sanitize_markup(r["category"] or ""), outs)
renderer.console().print(tbl)
tail = f" of {total_downstream}" if total_downstream != len(rows) else ""
rprint(f"[dim]{len(rows)} downstream node(s){tail}[/dim]")
Expand Down Expand Up @@ -700,8 +704,13 @@ def path_cmd(
)
else:
for p in paths:
chain = " [dim]→[/dim] ".join(f"[bold]{s.get('node')}[/bold]" for s in (p.get("steps") or []))
rprint(f"[cyan]{p.get('from')}[/cyan] {chain} [cyan]{p.get('to')}[/cyan]")
chain = " [dim]→[/dim] ".join(
f"[bold]{sanitize_markup(s.get('node'))}[/bold]" for s in (p.get("steps") or [])
)
rprint(
f"[cyan]{sanitize_markup(p.get('from'))}[/cyan] {chain} "
f"[cyan]{sanitize_markup(p.get('to'))}[/cyan]"
)
rprint(f"[dim]{len(paths)} path(s)[/dim]")
renderer.emit(payload, command="nodes path")

Expand Down Expand Up @@ -750,7 +759,7 @@ def types_cmd(
if renderer.is_pretty():
from rich.columns import Columns

renderer.console().print(Columns([f"[cyan]{t}[/cyan]" for t in types], expand=True))
renderer.console().print(Columns([f"[cyan]{sanitize_markup(t)}[/cyan]" for t in types], expand=True))
rprint(f"[dim]{len(types)} type(s)[/dim]")
renderer.emit(payload, command="nodes types")

Expand Down Expand Up @@ -844,7 +853,7 @@ def categories_cmd(
tbl.add_column("category")
tbl.add_column("nodes", justify="right", style="dim")
for p, c in flat:
tbl.add_row(p, str(c))
tbl.add_row(sanitize_markup(p), sanitize_markup(c))
renderer.console().print(tbl)
rprint(f"[dim]{len(flat)} categories[/dim]")
renderer.emit(payload, command="nodes categories")
Expand Down
8 changes: 3 additions & 5 deletions comfy_cli/command/outdated.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,13 +501,11 @@ def _status(outdated: bool, latest: Any) -> str:

def execute(renderer, comfy_path: str | None, *, refresh: bool = False) -> None:
"""Entry point wired from ``comfy outdated`` in cmdline.py."""
from rich.markup import escape

report, warnings = build_report(comfy_path, refresh=refresh)
if renderer.is_pretty():
_render_pretty(renderer, report)
for w in warnings:
# Warnings embed pack names/error text; escape so a name like ``foo[/]``
# can't trip renderer.warn's markup pass.
renderer.warn(escape(w))
# Warnings embed pack names/error text, but ``renderer.warn`` escapes
# markup itself now — escaping again here would render the backslashes.
renderer.warn(w)
renderer.emit(report, command="outdated")
10 changes: 6 additions & 4 deletions comfy_cli/command/run/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from comfy_cli.env_checker import check_comfy_server_running
from comfy_cli.output import get_renderer
from comfy_cli.output import rprint as pprint
from comfy_cli.output.sanitize import sanitize_markup
from comfy_cli.workflow_to_api import WorkflowConversionError, convert_ui_to_api
from comfy_cli.workspace_manager import WorkspaceManager

Expand Down Expand Up @@ -468,7 +469,7 @@ def execute(
)
raise typer.Exit(code=130) from e
if renderer.is_pretty():
pprint(f"[bold red]Error: Lost connection to ComfyUI server: {e}[/bold red]")
pprint(f"[bold red]Error: Lost connection to ComfyUI server: {sanitize_markup(e)}[/bold red]")
# The server died with a prompt of ours in flight: record that on the
# submit-time state file and name the prompt in the emitted error, so
# `comfy jobs status <id>` has something to consult afterwards.
Expand Down Expand Up @@ -512,7 +513,8 @@ def execute(
if completed_payload["outputs"]:
pprint("[bold green]\nOutputs:[/bold green]")
for f in completed_payload["outputs"]:
pprint(f)
# Output paths are built from server-chosen filenames.
pprint(sanitize_markup(f))
elapsed = timedelta(seconds=completed_payload["elapsed_seconds"])
pprint(f"[bold green]\nWorkflow execution completed ({elapsed})[/bold green]")
renderer.emit(completed_payload, command="run", where="local")
Expand Down Expand Up @@ -957,9 +959,9 @@ def _probe():
if output_urls:
pprint("[bold green]\nOutputs:[/bold green]")
for u in output_urls:
pprint(u)
pprint(sanitize_markup(u))
for w in warnings:
pprint(f"[yellow]⚠ {w['message']}[/yellow]")
pprint(f"[yellow]⚠ {sanitize_markup(w['message'])}[/yellow]")
pprint(f"[bold green]\nCloud workflow completed ({timedelta(seconds=end - start)})[/bold green]")

# Grouped views of the same artifacts: by producing node always, and by
Expand Down
25 changes: 17 additions & 8 deletions comfy_cli/command/run/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from comfy_cli.command.run.loader import _MAX_BODY_PREVIEW, _node_errors_to_list
from comfy_cli.output import get_renderer
from comfy_cli.output import rprint as pprint
from comfy_cli.output.sanitize import sanitize_markup
from comfy_cli.workspace_manager import WorkspaceManager

workspace_manager = WorkspaceManager()
Expand Down Expand Up @@ -190,7 +191,10 @@ def queue(self):
if isinstance(node_errors_raw, dict) and node_errors_raw:
node_errors = _node_errors_to_list(node_errors_raw)
if self.renderer.is_pretty():
pprint(f"[bold red]Error running workflow\n{json.dumps(node_errors_raw, indent=2)}[/bold red]")
pprint(
"[bold red]Error running workflow\n"
f"{sanitize_markup(json.dumps(node_errors_raw, indent=2))}[/bold red]"
)
self.renderer.error(
code="prompt_rejected",
message=f"Workflow has {len(node_errors_raw)} validation error(s)",
Expand All @@ -200,7 +204,7 @@ def queue(self):
raise typer.Exit(code=1)

if self.renderer.is_pretty():
pprint(f"[bold red]Error running workflow (HTTP {e.status})\n{body_text}[/bold red]")
pprint(f"[bold red]Error running workflow (HTTP {e.status})\n{sanitize_markup(body_text)}[/bold red]")
if e.status < 500:
self.renderer.error(
code="client_error",
Expand All @@ -221,7 +225,7 @@ def queue(self):
self.progress.stop()
reason = str(e.reason) if isinstance(e, urllib.error.URLError) else str(e)
if self.renderer.is_pretty():
pprint(f"[bold red]Error: Failed to submit workflow: {reason}[/bold red]")
pprint(f"[bold red]Error: Failed to submit workflow: {sanitize_markup(reason)}[/bold red]")
self.renderer.error(
code="connection_error",
message=f"Failed to submit workflow: {reason}",
Expand Down Expand Up @@ -323,15 +327,20 @@ def log_node(self, type, node_id):
return

node = self.workflow.get(node_id)
# ``node_id`` arrives on the wire and ``class_type``/``_meta.title`` come
# from a workflow the server may have echoed back, so each leaf is
# sanitized before it is interpolated into this markup string. ``type``
# is a caller-side literal ("Executing"/"Cached").
safe_node_id = sanitize_markup(node_id)
if node is None:
pprint(f"{type} : [bright_black]({node_id})[/]")
pprint(f"{type} : [bright_black]({safe_node_id})[/]")
return
class_type = node["class_type"]
title = self.get_node_title(node_id)
class_type = sanitize_markup(node["class_type"])
title = sanitize_markup(self.get_node_title(node_id))

if title != class_type:
title += f"[bright_black] - {class_type}[/]"
title += f"[bright_black] ({node_id})[/]"
title += f"[bright_black] ({safe_node_id})[/]"

pprint(f"{type} : {title}")

Expand Down Expand Up @@ -541,7 +550,7 @@ def on_error(self, data):
data = data if isinstance(data, dict) else {}
node_id = str(data.get("node_id", ""))
if self.renderer.is_pretty():
pprint(f"[bold red]Error running workflow\n{json.dumps(data, indent=2)}[/bold red]")
pprint(f"[bold red]Error running workflow\n{sanitize_markup(json.dumps(data, indent=2))}[/bold red]")
# The event keeps the full server payload (incl. complete traceback);
# the error envelope carries the classified one-line verdict.
self.renderer.event("execution_error", prompt_id=self.prompt_id, details=data)
Expand Down
5 changes: 4 additions & 1 deletion comfy_cli/command/run/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from comfy_cli.command.run.loader import _MAX_BODY_PREVIEW
from comfy_cli.output import get_renderer
from comfy_cli.output import rprint as pprint
from comfy_cli.output.sanitize import sanitize_markup

# Partner-API nodes live under `partner/...` in ComfyUI/cloud object_info
# (e.g. `partner/video/ByteDance`). The category prefix is only the fallback —
Expand Down Expand Up @@ -108,7 +109,9 @@ def _preflight_validate(renderer, workflow: dict, object_info: dict, *, target_l
warnings = validation.get("warnings", [])
if warnings and renderer.is_pretty():
for w in warnings:
pprint(f"[yellow]⚠ {w.get('field', '?')}: {w.get('message', '')}[/yellow]")
pprint(
f"[yellow]⚠ {sanitize_markup(w.get('field', '?'))}: {sanitize_markup(w.get('message', ''))}[/yellow]"
)


def _fetch_object_info(host: str, port: int) -> dict:
Expand Down
Loading
Loading