Skip to content

Command errors print to stdout, so -o json is unparseable on the failure path #104

Description

@GregHolmes

Summary

0.3.0 fixed stdout pollution on the success path (#98, verified and closed). The failure path still writes English prose to stdout: with -o json, an authentication failure produces unparseable stdout while stderr stays empty.

Reproduction

deepctl 0.3.0 from PyPI in a clean venv, empty config file, DEEPGRAM_API_KEY set to an invalid value:

$ dg -o json projects --list 2>/dev/null > out.json; echo "exit=$?"
exit=1

$ cat out.json
Error: Invalid API key - authentication failed
Your API key may have expired or been revoked.
Run deepctl login to re-authenticate.

$ python -c "import json; json.load(open('out.json'))"
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

stderr is 0 bytes in every case — the diagnostic isn't duplicated there, it's only on stdout.

Five for five across commands, each producing an identical 132 bytes of prose on stdout with exit 1:

command exit stdout stderr json.load(stdout)
dg -o json models 1 132 B prose 0 B JSONDecodeError
dg -o json projects 1 132 B prose 0 B JSONDecodeError
dg -o json keys 1 132 B prose 0 B JSONDecodeError
dg -o json usage 1 132 B prose 0 B JSONDecodeError
dg -o json billing 1 132 B prose 0 B JSONDecodeError

Root cause

deepctl_core/auth.py:19 declares a module-level console bound to stdout:

console = Console()

and prints the failure through it at auth.py:408:

console.print(f"[red]Error:[/red] {message}")

get_status_console() warns about precisely this case in its own docstring:

Commands should use this rather than declaring their own Console(stderr=True) — a per-command console silently misses the agentic no-color settings, and a command that reaches for a bare Console() reintroduces the stdout pollution this exists to prevent.

The same bare-Console() pattern appears in six places:

  • deepctl_core/auth.py:19
  • deepctl_core/client.py:21
  • deepctl_core/base_command.py:15
  • deepctl_core/base_group_command.py:13
  • deepctl_core/timing.py:12
  • deepctl_core/plugin_manager.py:24

By contrast deepctl/main.py:33 does the right thing (console = stderr_console), which is why root-level usage errors and interrupts are already clean. The 0.3.0 fix was scoped to that root path and didn't reach the command layer.

Expected

With -o json, stdout carries only parseable JSON on success or failure; diagnostics go to stderr. print_error() in deepctl_core/output.py already behaves this way — it routes to stderr_console unconditionally — so the fix is to route these call sites through it (or through get_status_console()) rather than a bare Console().

Impact

A CI step that does dg -o json ... 2>/dev/null > out.json and parses the result gets a JSONDecodeError instead of a structured error, on the single most common way a CI step fails. It also defeats agentic mode, which exists to guarantee clean stdout.

Notes

  • Found while correcting the CLI pages on developers.deepgram.com (deepgram/deepgram-docs#1128). Those pages now tell readers to branch on the exit code rather than assume stdout parses; that caveat can come back out once this is fixed.
  • Environment: deepctl 0.3.0 (PyPI wheel, isolated venv), macOS, Python 3.11.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions