Skip to content
Draft
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
4 changes: 2 additions & 2 deletions src/keboola_agent_cli/commands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import typer

from ..errors import ConfigError, ErrorCode
from ..output import OutputFormatter
from ..output import OutputFormatter, write_machine_output
from ..server.agents_store import AgentAction, Trigger
from ..services.agent_service import AgentService
from ._helpers import check_cli_permission, get_formatter, get_service
Expand Down Expand Up @@ -394,7 +394,7 @@ def _render_stream_event(formatter: OutputFormatter, evt: dict[str, Any]) -> Non
multi-line summary with exit code, elapsed time, response preview.
"""
if formatter.json_mode:
sys.stdout.write(json.dumps(evt, ensure_ascii=False) + "\n")
write_machine_output(json.dumps(evt, ensure_ascii=False) + "\n")
sys.stdout.flush()
return
event = evt.get("event", "?")
Expand Down
6 changes: 3 additions & 3 deletions src/keboola_agent_cli/commands/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@
from __future__ import annotations

import json
import sys
from typing import Any

import typer

from ..constants import HTTP_DEFAULT_TIMEOUT
from ..output import write_machine_output
from ..services.http_forwarder_service import (
ForwardedResponse,
ForwarderError,
Expand All @@ -41,15 +41,15 @@
def _print_json(_console: Any, data: Any) -> None:
"""Human-mode renderer: pipe-safe pretty JSON.

Uses ``sys.stdout.write`` instead of ``console.print`` because Rich
Uses a raw stdout write instead of ``console.print`` because Rich
can soft-wrap long strings or escape markup, which breaks downstream
``json.loads`` consumers. Real-world case: an AI agent piped
``kbagent http get /openapi.json`` into ``python3 -c "json.load(sys.stdin)"``
and hit ``JSONDecodeError`` because Rich had reflowed lines. The output
of ``kbagent http`` is virtually always parsed by something downstream
(an LLM, a script, jq) -- so machine-clean stdout is the contract.
"""
sys.stdout.write(json.dumps(data, indent=2) + "\n")
write_machine_output(json.dumps(data, indent=2) + "\n")


def _resolve_service(ctx: typer.Context) -> HttpForwarderService:
Expand Down
77 changes: 74 additions & 3 deletions src/keboola_agent_cli/output.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Output formatting with JSON and Rich dual mode support."""

import contextlib
import json
import sys
from collections.abc import Callable
Expand All @@ -14,6 +15,76 @@
from .models import ErrorResponse, SuccessResponse


def _stdout_is_utf8() -> bool:
"""Whether ``sys.stdout`` already encodes as UTF-8 (or a UTF-8 alias)."""
encoding = getattr(sys.stdout, "encoding", None)
if not encoding:
return False
return encoding.lower().replace("-", "").replace("_", "") in ("utf8", "utf8sig")


def force_utf8_stdout() -> None:
"""Re-encode ``sys.stdout`` as UTF-8 for machine-readable output.

Machine output (``--json``, ``kbagent http``, agent run-event streams) is
written for a downstream consumer -- a file, a pipe, an LLM, ``jq`` -- so it
must not depend on the console codepage of the terminal that happens to be
attached. On Windows the inherited codepage is a legacy single-byte encoding
(cp1250 on Czech/Polish/Hungarian locales), and a single non-ASCII character
anywhere in the payload -- an arrow in a flow name, an accented config name,
an emoji -- made ``sys.stdout.write`` raise ``UnicodeEncodeError`` and killed
the command instead of printing JSON (issue #546).

Unlike the ``kbagent serve`` startup banner (issue #522), transliterating to
ASCII is *not* an option here: the banner is decoration, this is data.

Best effort and idempotent: streams that cannot be reconfigured (a
``StringIO`` in tests, an already-detached stream) are left alone and the
write-time fallback in :func:`write_machine_output` covers them.
"""
if _stdout_is_utf8():
return
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is None:
return
# Non-critical on failure: write_machine_output() still has a byte-level path.
with contextlib.suppress(OSError, ValueError, LookupError):
reconfigure(encoding="utf-8")


def write_machine_output(text: str) -> None:
"""Write machine-readable text to stdout as UTF-8, never as the console codepage.

:func:`force_utf8_stdout` handles the normal case up front; this stays
defensive for the streams it could not reconfigure. On
``UnicodeEncodeError`` the text is encoded to UTF-8 bytes and written to the
underlying binary buffer, which bypasses the text layer's codec entirely.
The text stream is flushed first so the bytes cannot overtake previously
buffered output.

Args:
text: The already-serialized payload (typically JSON), including any
trailing newline.
"""
force_utf8_stdout()
try:
sys.stdout.write(text)
return
except UnicodeEncodeError as exc:
buffer = getattr(sys.stdout, "buffer", None)
if buffer is None:
# No binary layer to fall back to (an exotic stream). Surfacing the
# original error beats silently dropping or mangling machine output.
raise exc

# The failed write left nothing valid to flush, but anything written before
# it must reach the stream ahead of the bytes below.
with contextlib.suppress(UnicodeEncodeError):
sys.stdout.flush()
buffer.write(text.encode("utf-8"))
buffer.flush()


class OutputFormatter:
"""Formats CLI output as either JSON (for machines/agents) or Rich (for humans).

Expand Down Expand Up @@ -54,7 +125,7 @@ def output(
"""
if self.json_mode:
response = SuccessResponse(status="ok", data=data)
sys.stdout.write(response.model_dump_json(indent=2) + "\n")
write_machine_output(response.model_dump_json(indent=2) + "\n")
else:
if human_formatter is not None:
human_formatter(self.console, data)
Expand Down Expand Up @@ -99,7 +170,7 @@ def error(
"status": "error",
"error": err.model_dump(exclude_none=True),
}
sys.stdout.write(json.dumps(error_envelope, indent=2) + "\n")
write_machine_output(json.dumps(error_envelope, indent=2) + "\n")
else:
self.err_console.print(f"[bold red]Error:[/bold red] {message}")

Expand All @@ -111,7 +182,7 @@ def success(self, message: str) -> None:
"""
if self.json_mode:
response = SuccessResponse(status="ok", data={"message": message})
sys.stdout.write(response.model_dump_json(indent=2) + "\n")
write_machine_output(response.model_dump_json(indent=2) + "\n")
else:
self.console.print(f"[bold green]Success:[/bold green] {message}")

Expand Down
Loading