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", 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", diff --git a/src/deepctl/main.py b/src/deepctl/main.py index db6aa0e..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`` @@ -389,12 +395,15 @@ 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: _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..9380a35 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -117,29 +117,115 @@ 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() + + # 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() - # Click exits with code 2 when there's an error in standalone mode 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 + + @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: @@ -172,14 +258,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 +275,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