From 98f9e912682aff85836dbef40ee65391bb579fae Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 19 Aug 2026 10:36:45 +0100 Subject: [PATCH 1/5] fix(deps): raise deepctl-core floor to 0.2.16 in the eight packages that import get_status_console deepctl-cmd-{billing,keys,members,models,projects,read,requests,usage} import get_status_console at module scope, a symbol added to deepctl-core in 0.2.16, while declaring deepctl-core>=0.1.10. PyPI's latest published core is 0.2.14 (0.2.15 was tagged in the failed 0.2.27 cycle and never published), so the declared floor is satisfiable by a core that lacks the symbol. Reproduced: from published deepctl==0.2.26, `pip install --upgrade deepctl-cmd-keys` installs keys 0.0.4, leaves core at 0.2.14 (pip's default only-if-needed strategy), and the keys command then fails to load -- exit 2 with the ImportError printed to stdout. The default `pip install deepctl` path is unaffected (root's core floor forces 0.2.16); this closes the sub-package-upgraded-alone path, and the metadata is wrong regardless. Same class of fix as the hand-bump in #92; release-please has no plugins configured, so these floors are hand-maintained. --- packages/deepctl-cmd-billing/pyproject.toml | 2 +- packages/deepctl-cmd-keys/pyproject.toml | 2 +- packages/deepctl-cmd-members/pyproject.toml | 2 +- packages/deepctl-cmd-models/pyproject.toml | 2 +- packages/deepctl-cmd-projects/pyproject.toml | 2 +- packages/deepctl-cmd-read/pyproject.toml | 2 +- packages/deepctl-cmd-requests/pyproject.toml | 2 +- packages/deepctl-cmd-usage/pyproject.toml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/deepctl-cmd-billing/pyproject.toml b/packages/deepctl-cmd-billing/pyproject.toml index 3f72717..1d5cd82 100644 --- a/packages/deepctl-cmd-billing/pyproject.toml +++ b/packages/deepctl-cmd-billing/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ keywords = ["deepgram", "cli", "billing", "usage"] requires-python = ">=3.10" dependencies = [ - "deepctl-core>=0.1.10", + "deepctl-core>=0.2.16", "click>=8.0.0", "rich>=13.0.0", "pydantic>=2.0.0", diff --git a/packages/deepctl-cmd-keys/pyproject.toml b/packages/deepctl-cmd-keys/pyproject.toml index 13c63a0..3f88c6e 100644 --- a/packages/deepctl-cmd-keys/pyproject.toml +++ b/packages/deepctl-cmd-keys/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ keywords = ["deepgram", "cli", "keys", "api-keys"] requires-python = ">=3.10" dependencies = [ - "deepctl-core>=0.1.10", + "deepctl-core>=0.2.16", "click>=8.0.0", "rich>=13.0.0", "pydantic>=2.0.0", diff --git a/packages/deepctl-cmd-members/pyproject.toml b/packages/deepctl-cmd-members/pyproject.toml index d06425d..0b5116e 100644 --- a/packages/deepctl-cmd-members/pyproject.toml +++ b/packages/deepctl-cmd-members/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ keywords = ["deepgram", "cli", "members", "team"] requires-python = ">=3.10" dependencies = [ - "deepctl-core>=0.1.10", + "deepctl-core>=0.2.16", "click>=8.0.0", "rich>=13.0.0", "pydantic>=2.0.0", diff --git a/packages/deepctl-cmd-models/pyproject.toml b/packages/deepctl-cmd-models/pyproject.toml index 816bf92..18852e0 100644 --- a/packages/deepctl-cmd-models/pyproject.toml +++ b/packages/deepctl-cmd-models/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ keywords = ["deepgram", "cli", "models"] requires-python = ">=3.10" dependencies = [ - "deepctl-core>=0.1.10", + "deepctl-core>=0.2.16", "click>=8.0.0", "rich>=13.0.0", "pydantic>=2.0.0", diff --git a/packages/deepctl-cmd-projects/pyproject.toml b/packages/deepctl-cmd-projects/pyproject.toml index cdfabbe..c7d7258 100644 --- a/packages/deepctl-cmd-projects/pyproject.toml +++ b/packages/deepctl-cmd-projects/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ keywords = ["deepgram", "cli", "projects", "management"] requires-python = ">=3.10" dependencies = [ - "deepctl-core>=0.1.10", + "deepctl-core>=0.2.16", "click>=8.0.0", "rich>=13.0.0", "deepgram-sdk>=7.7.0,<8", diff --git a/packages/deepctl-cmd-read/pyproject.toml b/packages/deepctl-cmd-read/pyproject.toml index adbc3b8..beeb51c 100644 --- a/packages/deepctl-cmd-read/pyproject.toml +++ b/packages/deepctl-cmd-read/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ keywords = ["deepgram", "cli", "text-intelligence", "nlp", "read"] requires-python = ">=3.10" dependencies = [ - "deepctl-core>=0.1.10", + "deepctl-core>=0.2.16", "click>=8.0.0", "rich>=13.0.0", "pydantic>=2.0.0", diff --git a/packages/deepctl-cmd-requests/pyproject.toml b/packages/deepctl-cmd-requests/pyproject.toml index b2bccb9..5bde238 100644 --- a/packages/deepctl-cmd-requests/pyproject.toml +++ b/packages/deepctl-cmd-requests/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ keywords = ["deepgram", "cli", "requests", "history"] requires-python = ">=3.10" dependencies = [ - "deepctl-core>=0.1.10", + "deepctl-core>=0.2.16", "click>=8.0.0", "rich>=13.0.0", "pydantic>=2.0.0", diff --git a/packages/deepctl-cmd-usage/pyproject.toml b/packages/deepctl-cmd-usage/pyproject.toml index 1131dee..6feffd4 100644 --- a/packages/deepctl-cmd-usage/pyproject.toml +++ b/packages/deepctl-cmd-usage/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ keywords = ["deepgram", "cli", "usage", "statistics"] requires-python = ">=3.10" dependencies = [ - "deepctl-core>=0.1.10", + "deepctl-core>=0.2.16", "deepctl-shared-utils>=0.1.10", "click>=8.0.0", "rich>=13.0.0", From c0b0023e2395a65f444e5cdc9487cabd9ae2ea22 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 19 Aug 2026 10:37:23 +0100 Subject: [PATCH 2/5] fix(deps): raise root dependency floors to the versions this release publishes Root declared floors as low as >=0.0.1 and >=0.1.10 for packages whose fixes this release exists to deliver. `dg update` runs `pip install --upgrade deepctl`, and pip's default only-if-needed strategy leaves any sub-package whose installed version already satisfies its floor untouched. Measured from published deepctl==0.2.26 with this release's wheels available: only deepctl, deepctl-core, deepctl-cmd-listen and deepctl-cmd-speak upgraded -- 13 of 17 released packages stayed stale while dg --version reported the new number. The keys fixes, including the -o json fix that headlines the release, never arrived. (uv resolves fresh and is unaffected, so the same dg update lands two users in different states.) Floors now match the versions release-please is publishing in the current release PR, so pip is forced to deliver what the changelog advertises. --- pyproject.toml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4882212..a3c377e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,12 +35,12 @@ requires-python = ">=3.10" dependencies = [ "click>=8.0.0", "deepgram-sdk>=7.7.0,<8", - "deepctl-core>=0.2.15", - "deepctl-cmd-login>=0.1.10", - "deepctl-cmd-projects>=0.1.10", + "deepctl-core>=0.2.16", + "deepctl-cmd-login>=0.1.17", + "deepctl-cmd-projects>=0.1.14", "deepctl-cmd-transcribe>=0.1.10", - "deepctl-cmd-usage>=0.1.10", - "deepctl-cmd-mcp>=0.1.10", + "deepctl-cmd-usage>=0.1.14", + "deepctl-cmd-mcp>=0.1.15", "deepctl-cmd-api>=0.0.1", "deepctl-cmd-debug>=0.1.10", "deepctl-cmd-debug-browser>=0.1.10", @@ -49,21 +49,21 @@ dependencies = [ "deepctl-cmd-debug-probe>=0.0.1", "deepctl-cmd-debug-toolkit>=0.0.1", "deepctl-cmd-ffprobe>=0.0.1", - "deepctl-cmd-update>=0.1.10", + "deepctl-cmd-update>=0.2.6", "deepctl-cmd-plugin>=0.1.10", - "deepctl-cmd-skills>=0.0.1", + "deepctl-cmd-skills>=0.0.7", "deepctl-cmd-init>=0.0.1", - "deepctl-cmd-models>=0.0.1", + "deepctl-cmd-models>=0.0.3", "deepctl-cmd-speak>=0.0.4", - "deepctl-cmd-keys>=0.0.1", - "deepctl-cmd-read>=0.0.1", + "deepctl-cmd-keys>=0.0.4", + "deepctl-cmd-read>=0.0.3", "deepctl-cmd-listen>=0.0.14", - "deepctl-cmd-requests>=0.0.1", - "deepctl-cmd-billing>=0.0.1", - "deepctl-cmd-members>=0.0.1", + "deepctl-cmd-requests>=0.0.3", + "deepctl-cmd-billing>=0.0.3", + "deepctl-cmd-members>=0.0.4", "deepctl-cmd-completion>=0.0.1", "deepctl-shared-utils>=0.1.10", - "deepctl-telemetry>=0.0.1", + "deepctl-telemetry>=0.0.6", "pydantic>=2.0.0", "rich>=13.0.0", "httpx>=0.24.0", From 914e132951c64f37ceab49f363ff93d567326de7 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 19 Aug 2026 10:46:18 +0100 Subject: [PATCH 3/5] fix: exit 1, not 2, when a command crashes or is misused main()'s generic exception handler exited 2 -- the code the published contract reserves for user interrupt -- making an internal crash indistinguishable from Ctrl-C to any script branching on the exit code. Because cli() runs with standalone_mode=False, Click usage errors (bad flag, unknown command, bare `dg`) propagate to this same handler, so they move from 2 to 1 as well. KeyboardInterrupt keeps 2. Also repairs the three tests that patched cli.__call__ on the instance: dunder lookup bypasses instance attributes, so the patches were inert and the tests exercised the bare-`dg` help path instead of the paths they named. They now patch deepctl.main.cli, and a new test pins the usage-error code. BREAKING CHANGE: `dg` now exits non-zero when a command fails: 1 for errors (including crashes and usage errors), 2 for user interrupt, 0 on success. Every command previously exited 0 regardless of outcome, so scripts and CI steps that ignored the exit code will surface failures they were silently swallowing. No command that succeeds changes its exit code. --- src/deepctl/main.py | 2 +- tests/unit/test_main.py | 43 +++++++++++++++++++++++++++++------------ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/deepctl/main.py b/src/deepctl/main.py index db6aa0e..4fc8173 100644 --- a/src/deepctl/main.py +++ b/src/deepctl/main.py @@ -394,7 +394,7 @@ def main() -> None: sys.exit(2) except Exception as e: _safe_console_print(f"[red]Error: {e}[/red]") - sys.exit(2) + sys.exit(1) # 1 = error; 2 is reserved for user interrupt if __name__ == "__main__": diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 47f36be..650297a 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -117,29 +117,48 @@ def test_main_keyboard_interrupt(self): """Test main() handles KeyboardInterrupt.""" from deepctl.main import main - # Mock sys.argv and the cli call to raise KeyboardInterrupt + # Patch the module-global `cli` that main() calls, going through + # sys.modules because deepctl/__init__ re-exports the `main` function + # as `deepctl.main`, shadowing the submodule attribute. (Patching the + # instance's __call__ is inert: dunder lookup bypasses instance + # attributes, so the old patch.object(cli, "__call__", ...) form + # exercised the bare-`dg` help path instead.) + main_mod = sys.modules["deepctl.main"] with patch("sys.argv", ["deepctl"]): - # Patch the cli function that's already imported at module level - with patch.object(cli, "__call__", side_effect=KeyboardInterrupt()): + with patch.object(main_mod, "cli", side_effect=KeyboardInterrupt()): with pytest.raises(SystemExit) as exc_info: main() - # Click exits with code 2 when there's an error in standalone mode + # main()'s own KeyboardInterrupt handler: 2 = user interrupt assert exc_info.value.code == 2 def test_main_general_exception(self): """Test main() handles general exceptions.""" from deepctl.main import main - # Mock sys.argv and the cli call to raise an exception + main_mod = sys.modules["deepctl.main"] with patch("sys.argv", ["deepctl"]): - # Patch the cli function that's already imported at module level - with patch.object(cli, "__call__", side_effect=Exception("Test error")): + with patch.object(main_mod, "cli", side_effect=Exception("Test error")): with pytest.raises(SystemExit) as exc_info: main() - # Click exits with code 2 when there's an error in standalone mode - assert exc_info.value.code == 2 + # main()'s own handler: 1 = error (2 is reserved for interrupt) + assert exc_info.value.code == 1 + + def test_main_usage_error(self): + """A Click usage error (bad flag, unknown command) exits 1. + + standalone_mode=False means Click's UsageError propagates to main()'s + generic handler rather than Click's own standalone exit(2) -- per the + published contract, 1 = error and 2 is reserved for user interrupt. + """ + from deepctl.main import main + + with patch("sys.argv", ["deepctl", "--definitely-not-a-flag"]): + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 class TestSafeConsolePrint: @@ -172,14 +191,14 @@ def test_swallows_closed_stream(self, error): def test_main_survives_broken_console_on_error(self): """The full DX-CLI-P cascade: cli raises AND the console is closed. - main() must still exit(2) cleanly rather than let rich's ValueError + main() must still exit(1) cleanly rather than let rich's ValueError escape to the excepthook. """ main_mod = sys.modules["deepctl.main"] with ( patch("sys.argv", ["deepctl"]), - patch.object(cli, "__call__", side_effect=Exception("boom")), + patch.object(main_mod, "cli", side_effect=Exception("boom")), patch.object( main_mod.console, "print", @@ -189,4 +208,4 @@ def test_main_survives_broken_console_on_error(self): ): main_mod.main() - assert exc_info.value.code == 2 + assert exc_info.value.code == 1 From b0e80e2b35484654dbb5afaae6db32d0eba8bdfe Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 19 Aug 2026 11:07:02 +0100 Subject: [PATCH 4/5] fix: keep exit 2 when Ctrl-C interrupts a running command With standalone_mode=False, Click catches a KeyboardInterrupt raised during command execution and re-raises it as Abort (a RuntimeError), so a mid-command interrupt -- the common case -- bypassed the KeyboardInterrupt handler and hit the generic error handler, exiting 1 and printing an empty 'Error: '. Catch Abort alongside KeyboardInterrupt so user cancellation (Ctrl-C, Ctrl-D at a prompt) always exits 2 per the published contract, and add a test on the Abort delivery path. --- src/deepctl/main.py | 5 ++++- tests/unit/test_main.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/deepctl/main.py b/src/deepctl/main.py index 4fc8173..f7809b7 100644 --- a/src/deepctl/main.py +++ b/src/deepctl/main.py @@ -389,7 +389,10 @@ def main() -> None: if exit_code: sys.exit(exit_code) - except KeyboardInterrupt: + except (KeyboardInterrupt, click.exceptions.Abort): + # Click wraps a Ctrl-C/Ctrl-D raised inside cli() in Abort when + # standalone_mode=False, so a mid-command interrupt arrives here as + # Abort, not KeyboardInterrupt. Both are user cancellation: exit 2. _safe_console_print("\n[yellow]Operation cancelled by user[/yellow]") sys.exit(2) except Exception as e: diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 650297a..be2cd7a 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -132,6 +132,28 @@ def test_main_keyboard_interrupt(self): # main()'s own KeyboardInterrupt handler: 2 = user interrupt assert exc_info.value.code == 2 + def test_main_click_abort(self): + """A click Abort (Ctrl-C/Ctrl-D during a command) exits 2. + + With standalone_mode=False, Click catches a KeyboardInterrupt raised + inside command execution and re-raises it as Abort (a RuntimeError, + not a KeyboardInterrupt) -- so the mid-command interrupt, the common + case, reaches main() as Abort. It is user cancellation: 2, not 1. + """ + import click + + from deepctl.main import main + + main_mod = sys.modules["deepctl.main"] + with patch("sys.argv", ["deepctl"]): + with patch.object( + main_mod, "cli", side_effect=click.exceptions.Abort() + ): + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 2 + def test_main_general_exception(self): """Test main() handles general exceptions.""" from deepctl.main import main From f4b7c48a2155942c0ff7865079fd016397d40482 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 19 Aug 2026 12:08:18 +0100 Subject: [PATCH 5/5] fix: send root error and interrupt output to stderr, not stdout main()'s module-level `console` was a plain rich Console(), which writes to stdout, and both of main()'s handlers print through it. So a crash, a bad flag, an unknown command, or a bare `dg` wrote human-readable prose to stdout -- `dg -o json not-a-command` put `Error: No such command ...` on stdout and left stderr empty, so anything piping stdout into jq parsed the error text instead of JSON. This is the root-handler half of the #97 sweep. That issue's scope covered moving errors to a stderr Console "so stdout stays clean", and #101 closed the sweep, but both only reached the command layer; main()'s own handlers were never moved. Reuse deepctl_core.output.stderr_console -- the same console print_error() writes to, and the pattern deepctl-cmd-mcp already follows -- so root-level and command-level diagnostics format identically, including the no-color handling for agentic/CI callers. Exit codes are unchanged (1 for errors, 2 for interrupt, 0 on success), and success paths still write their payload to stdout. Adds tests asserting a failing `dg -o json ...` writes nothing to stdout for both the unknown- command and bad-flag paths, and that the cancellation notice is on stderr -- the assertion that would have caught this during the original sweep. --- src/deepctl/main.py | 12 ++++++++--- tests/unit/test_main.py | 45 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/deepctl/main.py b/src/deepctl/main.py index f7809b7..6e65a47 100644 --- a/src/deepctl/main.py +++ b/src/deepctl/main.py @@ -19,12 +19,18 @@ print_timing_summary, setup_output, ) -from rich.console import Console +from deepctl_core.output import stderr_console from rich.traceback import install # Install rich traceback for better error messages install(show_locals=True) -console = Console() + +# Diagnostics go to stderr so stdout carries only the requested payload -- a +# crash or usage error must not corrupt `dg -o json ...` for a script piping +# stdout into jq. Same console the command layer uses via print_error(), so +# root-level and command-level diagnostics format identically (including the +# no-color handling for agentic/CI callers). +console = stderr_console def _record_install_method_cb( @@ -282,7 +288,7 @@ def _telemetry_transaction() -> Iterator[None]: def _safe_console_print(message: str) -> None: - """Print to the console, tolerating a closed/broken output stream. + """Print a diagnostic to stderr, tolerating a closed/broken stream. When `dg` runs as an MCP server, the host can close stdio before the process finishes. A write to the closed stream raises ``BrokenPipeError`` diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index be2cd7a..9380a35 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -182,6 +182,51 @@ def test_main_usage_error(self): assert exc_info.value.code == 1 + @pytest.mark.parametrize( + ("argv", "label"), + [ + (["deepctl", "-o", "json", "not-a-command"], "unknown command"), + (["deepctl", "-o", "json", "--definitely-not-a-flag"], "bad flag"), + ], + ) + def test_failure_keeps_stdout_clean(self, capsys, argv, label): + """A failing `dg -o json ...` writes nothing to stdout. + + The whole point of `-o json` is that stdout is machine-readable, so a + script can pipe it into jq. Diagnostics therefore belong on stderr: + printing `Error: ...` to stdout leaves the caller parsing prose. This + is the root-handler half of the #97 sweep, which moved command-level + status chrome to stderr but left main()'s own handlers on stdout. + """ + from deepctl.main import main + + with patch("sys.argv", argv): + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert captured.out == "", f"{label} polluted stdout: {captured.out!r}" + assert "Error" in captured.err + + def test_interrupt_message_goes_to_stderr(self, capsys): + """The cancellation notice is a diagnostic, so it also stays off stdout.""" + import click + + from deepctl.main import main + + main_mod = sys.modules["deepctl.main"] + with patch("sys.argv", ["deepctl"]): + with patch.object( + main_mod, "cli", side_effect=click.exceptions.Abort() + ): + with pytest.raises(SystemExit): + main() + + captured = capsys.readouterr() + assert captured.out == "" + assert "cancelled" in captured.err.lower() + class TestSafeConsolePrint: """The closed/broken-stream guard on the error/interrupt exit path.