From 4b069699c92f49b7edd0faaa39e8a9b9f83675e2 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Mon, 29 Jun 2026 21:16:15 +0500 Subject: [PATCH] fix(bundle): send command errors to stderr so --json stdout stays parseable The bundle command group's _fail() helper is documented as printing 'to stderr', and the module contract is 'human logs go to stderr/console' while --json 'emits machine-readable data on stdout'. But it called console.print(), and the shared console writes to STDOUT, so every bundle error (every command routes through _fail) landed on stdout -- corrupting the JSON stream that --json consumers parse. Add a stderr-bound err_console to _console.py (its documented role as the single Console source) and use it in _fail. stdout now carries only the JSON payload. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/_console.py | 4 ++++ src/specify_cli/commands/bundle/__init__.py | 6 ++++-- tests/contract/test_bundle_cli.py | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/_console.py b/src/specify_cli/_console.py index 33bd70f77f..8d1216387f 100644 --- a/src/specify_cli/_console.py +++ b/src/specify_cli/_console.py @@ -34,6 +34,10 @@ console = Console(highlight=False) +# Stderr-bound console for error/diagnostic output, so human-facing messages +# never contaminate stdout (which carries machine-readable ``--json`` payloads). +err_console = Console(stderr=True, highlight=False) + class StepTracker: """Track and render hierarchical steps without emojis, similar to Claude Code tree output. Supports live auto-refresh via an attached refresh callback. diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py index 185e00acf6..afae9bcf84 100644 --- a/src/specify_cli/commands/bundle/__init__.py +++ b/src/specify_cli/commands/bundle/__init__.py @@ -13,7 +13,7 @@ import typer -from ..._console import console +from ..._console import console, err_console from ...bundler import BundlerError from ...bundler.lib.project import ( active_integration, @@ -41,7 +41,9 @@ def _fail(message: str) -> None: """Print an actionable error to stderr and exit non-zero.""" - console.print(f"[red]Error:[/red] {message}", style=None) + # Use the stderr console so the error never lands on stdout, which under + # ``--json`` carries the machine-readable payload and must stay parseable. + err_console.print(f"[red]Error:[/red] {message}", style=None) raise typer.Exit(code=1) diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py index 018b2bbec1..58a26fae91 100644 --- a/tests/contract/test_bundle_cli.py +++ b/tests/contract/test_bundle_cli.py @@ -62,6 +62,21 @@ def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch assert "Spec Kit project" in result.output +def test_fail_writes_error_to_stderr_not_stdout(capsys): + """_fail must write to stderr, not stdout: every bundle command routes errors + through it, and under --json the error would otherwise corrupt the JSON payload + that consumers read from stdout.""" + import typer + + from specify_cli.commands.bundle import _fail + + with pytest.raises(typer.Exit): + _fail("something broke") + captured = capsys.readouterr() + assert "something broke" in captured.err + assert "something broke" not in captured.out + + def test_search_works_without_a_project(tmp_path: Path, monkeypatch): # Discovery commands fall back to the built-in/user catalog stack and must # not require a Spec Kit project (matches README/quickstart examples).