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).