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:
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.
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_KEYset to an invalid value: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:
json.load(stdout)dg -o json modelsdg -o json projectsdg -o json keysdg -o json usagedg -o json billingRoot cause
deepctl_core/auth.py:19declares a module-level console bound to stdout:and prints the failure through it at
auth.py:408:get_status_console()warns about precisely this case in its own docstring:The same bare-
Console()pattern appears in six places:deepctl_core/auth.py:19deepctl_core/client.py:21deepctl_core/base_command.py:15deepctl_core/base_group_command.py:13deepctl_core/timing.py:12deepctl_core/plugin_manager.py:24By contrast
deepctl/main.py:33does 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()indeepctl_core/output.pyalready behaves this way — it routes tostderr_consoleunconditionally — so the fix is to route these call sites through it (or throughget_status_console()) rather than a bareConsole().Impact
A CI step that does
dg -o json ... 2>/dev/null > out.jsonand parses the result gets aJSONDecodeErrorinstead 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