From 189f7438a4e3351bfa3ba7f66ee2f52d32dc76a1 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Thu, 20 Aug 2026 11:37:26 +0100 Subject: [PATCH 01/17] fix(core): keep diagnostics off stdout on the failure path Closes #104. Four deepctl_core modules declared bare `Console()` instances bound to stdout and printed diagnostics through them, so `dg -o json ` on an auth failure wrote 132 bytes of English prose to stdout and left stderr empty -- a JSONDecodeError for the CI step that redirects stdout and parses it, on the single most common way a CI step fails. get_status_console() warns about exactly this pattern in its own docstring. auth, client, timing and base_group_command carry diagnostics only, so their console is now the shared stderr_console. base_command's is deliberately NOT moved: it writes the payload (tables, JSON, raw text from _output_*), which belongs on stdout. It moves from a bare Console() to the shared stdout instance so it stops silently missing the agentic no-color/highlight config -- the second half of the docstring's warning. Moving the prose off stdout left stdout *empty* on failure, which is still unparseable. The guard's `except` now emits an ErrorResult through the normal output path, so stdout carries {"status": "error", "error": ...}. It is a no-op in default mode, so humans see no duplicate of the stderr diagnosis. Verified against the issue's five commands with an invalid key: each now exits 1 with parseable JSON on stdout and the prose on stderr. Tests pin all three properties: which shared console each module holds, that the JSON failure payload parses while default mode stays silent on stdout, and an AST sweep asserting no module in deepctl_core declares a bare Console() at all -- confirmed to fail when one is reintroduced. --- .../deepctl-core/src/deepctl_core/auth.py | 7 +- .../src/deepctl_core/base_command.py | 29 +++- .../src/deepctl_core/base_group_command.py | 7 +- .../deepctl-core/src/deepctl_core/client.py | 8 +- .../deepctl-core/src/deepctl_core/timing.py | 7 +- .../tests/unit/test_output_channels.py | 148 ++++++++++++++++++ 6 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 packages/deepctl-core/tests/unit/test_output_channels.py diff --git a/packages/deepctl-core/src/deepctl_core/auth.py b/packages/deepctl-core/src/deepctl_core/auth.py index f15c04a..96f15d7 100644 --- a/packages/deepctl-core/src/deepctl_core/auth.py +++ b/packages/deepctl-core/src/deepctl_core/auth.py @@ -9,14 +9,17 @@ import httpx import keyring from pydantic import BaseModel -from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn from .client import _split_base_url from .config import Config from .models import ProfileInfo, ProfilesResult +from .output import stderr_console -console = Console() +# Diagnostics only: everything printed here is status or error chrome, so +# it goes to stderr and can never corrupt the machine-readable payload a +# command writes to stdout. See get_status_console() in output.py. +console = stderr_console # Auth provider base URL (dx-id OIDC provider) AUTH_BASE_URL = os.getenv("DEEPGRAM_CLI_BASE_URL", "https://id.dx.deepgram.com") diff --git a/packages/deepctl-core/src/deepctl_core/base_command.py b/packages/deepctl-core/src/deepctl_core/base_command.py index 38f8b69..8aa48be 100644 --- a/packages/deepctl-core/src/deepctl_core/base_command.py +++ b/packages/deepctl-core/src/deepctl_core/base_command.py @@ -4,15 +4,20 @@ from typing import Any, ClassVar import click -from rich.console import Console from .auth import AuthManager from .client import DeepgramClient from .config import Config +from .models import ErrorResult from .output import _agentic, print_error, print_info, print_warning, stderr_console +from .output import console as stdout_console from .timing import TimingContext -console = Console() +# The PAYLOAD console -- stdout, deliberately. Tables, JSON and raw text +# written by _output_* are the machine-readable result and belong there. +# Shared instance rather than a bare Console() so it honours the agentic +# no-color/highlight settings; diagnostics use stderr_console instead. +console = stdout_console class BaseCommand(ABC): @@ -105,10 +110,22 @@ def execute(self, ctx: click.Context, **kwargs: Any) -> None: else: print_warning("No project ID specified") - except Exception: - # guard() already printed helpful error messages; - # exit without duplicating them. - raise SystemExit(1) + except Exception as auth_error: + # guard() already wrote the human-readable diagnosis to + # stderr, so don't duplicate it -- but stdout must still + # carry a parseable payload in a machine-readable + # format. A CI step doing `dg -o json ... > out.json` + # and parsing the result should get a structured error, + # not an empty file. output_result is a no-op in + # default mode, so this adds nothing for humans. + self._tag_telemetry_status("error") + try: + self.output_result( + ErrorResult(error=str(auth_error)), config + ) + except (BrokenPipeError, OSError): + pass + raise SystemExit(1) from auth_error # Check project ID if required if self.requires_project: diff --git a/packages/deepctl-core/src/deepctl_core/base_group_command.py b/packages/deepctl-core/src/deepctl_core/base_group_command.py index f8b3941..7948c3f 100644 --- a/packages/deepctl-core/src/deepctl_core/base_group_command.py +++ b/packages/deepctl-core/src/deepctl_core/base_group_command.py @@ -3,14 +3,17 @@ from typing import Any import click -from rich.console import Console from .auth import AuthManager from .base_command import BaseCommand from .client import DeepgramClient from .config import Config +from .output import stderr_console -console = Console() +# Diagnostics only: everything printed here is status or error chrome, so +# it goes to stderr and can never corrupt the machine-readable payload a +# command writes to stdout. See get_status_console() in output.py. +console = stderr_console class BaseGroupCommand(BaseCommand): diff --git a/packages/deepctl-core/src/deepctl_core/client.py b/packages/deepctl-core/src/deepctl_core/client.py index 8c842e0..5fbfbdc 100644 --- a/packages/deepctl-core/src/deepctl_core/client.py +++ b/packages/deepctl-core/src/deepctl_core/client.py @@ -9,7 +9,8 @@ from deepgram import DeepgramClient as DGClient from deepgram import DeepgramClientEnvironment from deepgram.core.api_error import ApiError -from rich.console import Console + +from .output import stderr_console if TYPE_CHECKING: from collections.abc import Iterator @@ -18,7 +19,10 @@ from .auth import AuthManager from .config import Config -console = Console() +# Diagnostics only: everything printed here is status or error chrome, so +# it goes to stderr and can never corrupt the machine-readable payload a +# command writes to stdout. See get_status_console() in output.py. +console = stderr_console def _split_base_url(base_url: str) -> tuple[str, str, str]: diff --git a/packages/deepctl-core/src/deepctl_core/timing.py b/packages/deepctl-core/src/deepctl_core/timing.py index 86309c4..21c3ff7 100644 --- a/packages/deepctl-core/src/deepctl_core/timing.py +++ b/packages/deepctl-core/src/deepctl_core/timing.py @@ -7,9 +7,12 @@ from threading import local from typing import Any -from rich.console import Console +from .output import stderr_console -console = Console() +# Diagnostics only: everything printed here is status or error chrome, so +# it goes to stderr and can never corrupt the machine-readable payload a +# command writes to stdout. See get_status_console() in output.py. +console = stderr_console # Thread-local storage for timing data _timing_data = local() diff --git a/packages/deepctl-core/tests/unit/test_output_channels.py b/packages/deepctl-core/tests/unit/test_output_channels.py new file mode 100644 index 0000000..e8fa8d8 --- /dev/null +++ b/packages/deepctl-core/tests/unit/test_output_channels.py @@ -0,0 +1,148 @@ +"""Which stream each console writes to, pinned. + +Regression cover for #104: `deepctl_core` modules declared bare `Console()` +instances bound to stdout and printed diagnostics through them, so +`dg -o json ` on an auth failure wrote English prose to stdout and left +stderr empty -- unparseable for the CI step that redirects stdout and parses +it. `get_status_console()` warns about exactly this in its own docstring; the +tests below turn that warning into a gate. + +The distinction these tests protect is *what the console carries*, not the +module it lives in: + +- diagnostics (errors, status, progress, timing chrome) -> stderr, always +- the payload (JSON/YAML/table/CSV a command produces) -> stdout, always +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path + +import pytest +from deepctl_core import output + +CORE_SRC = Path(output.__file__).parent + +# Modules whose module-level `console` carries diagnostics only. +DIAGNOSTIC_MODULES = [ + "auth", + "client", + "timing", + "base_group_command", + "plugin_manager", +] + + +class TestConsoleBindings: + """Every module-level console must be one of the two shared instances.""" + + @pytest.mark.parametrize("module_name", DIAGNOSTIC_MODULES) + def test_diagnostic_console_is_the_shared_stderr_console( + self, module_name: str + ) -> None: + import importlib + + module = importlib.import_module(f"deepctl_core.{module_name}") + + assert module.console is output.stderr_console + assert module.console.stderr is True + + def test_base_command_console_is_the_shared_stdout_console(self) -> None: + """The payload console stays on stdout -- deliberately. + + Tables, JSON and raw text written by `_output_*` are the + machine-readable result and belong on stdout. The requirement is that + it is the *shared* instance, so it honours the agentic no-color and + highlight settings a bare Console() would silently miss. + """ + from deepctl_core import base_command + + assert base_command.console is output.console + assert base_command.console.stderr is False + + +class TestNoBareConsoleInCore: + """A bare `Console()` in deepctl_core is how #104 happened.""" + + def test_no_module_declares_a_bare_console(self) -> None: + offenders: list[str] = [] + + for path in sorted(CORE_SRC.glob("*.py")): + tree = ast.parse(path.read_text(), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + name = getattr(func, "id", None) or getattr(func, "attr", None) + if name != "Console": + continue + # output.py is where the two shared instances are built. + if path.name == "output.py": + continue + offenders.append(f"{path.name}:{node.lineno}") + + assert not offenders, ( + "bare Console() in deepctl_core reintroduces the #104 stdout " + "pollution -- import `console` or `stderr_console` from .output " + f"instead. Found at: {', '.join(offenders)}" + ) + + +class TestAuthFailurePayload: + """The #104 reproduction, as a test.""" + + def _run_guard_failure(self, capsys, output_format: str): + """Drive a requires_auth command whose guard() raises.""" + from unittest.mock import MagicMock, patch + + import click + from deepctl_core.auth import AuthenticationError + from deepctl_core.base_command import BaseCommand + from deepctl_core.config import Config + + class NeedsAuth(BaseCommand): + name = "needs-auth" + help = "test command" + requires_auth = True + + def handle(self, config, auth_manager, client, **kwargs): # type: ignore[no-untyped-def] + raise AssertionError("handle must not run when guard() fails") + + command = NeedsAuth() + ctx = MagicMock(spec=click.Context) + ctx.obj = {"config": Config()} + + auth_manager = MagicMock() + auth_manager.guard.side_effect = AuthenticationError( + "Invalid API key - authentication failed" + ) + + with ( + patch( + "deepctl_core.base_command.AuthManager", return_value=auth_manager + ), + patch("deepctl_core.base_command.DeepgramClient"), + patch( + "deepctl_core.output.get_output_format", return_value=output_format + ), + ): + with pytest.raises(SystemExit) as exc: + command.execute(ctx) + + assert exc.value.code == 1 + return capsys.readouterr() + + def test_json_failure_writes_parseable_payload_to_stdout(self, capsys) -> None: + captured = self._run_guard_failure(capsys, "json") + + payload = json.loads(captured.out) + assert payload["status"] == "error" + assert "Invalid API key" in payload["error"] + + def test_default_mode_writes_nothing_to_stdout(self, capsys) -> None: + """Human mode must not gain a duplicate of the stderr diagnosis.""" + captured = self._run_guard_failure(capsys, "default") + + assert captured.out == "" From 1b3a73866637d961e27a30f55edfa206fac6191f Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Thu, 20 Aug 2026 11:37:26 +0100 Subject: [PATCH 02/17] fix(commands): correct advertised examples that don't parse Closes #105. `dg usage` advertised `--days 30` and `--start`/`--end`; the real options are `--start-date`/`--end-date` and there is no `--days` at all. The help text contradicted its own options list eight lines further down. The issue suggested sweeping the other commands, which found the same class of error in two more places: - `dg debug stream` -- debug's subcommands are audio, browser, network, probe and toolkit. The WebSocket stream debugging its agent_help describes is `probe` ("Stream probe proxy"). - `dg profiles --show default` -- profiles has --switch, --current and --list; there is no --show. This is worse than a help-text typo because the same `examples` array is what `--agent-friendly` emits, so an agent asking the CLI how to use itself was handed four commands that fail. tests/unit/test_command_examples.py now parses every string in every examples array against the real command tree, one test per example (125 of them). parse_args resolves the command and validates options without invoking the handler, so nothing touches the network. Examples are shell snippets rather than bare argv, so it extracts just the `dg ...` segments -- pipelines, upstream producers and trailing # comments are not ours to validate, and command substitution is skipped outright. `dg debug toolkit`'s subcommands are exempt: they are built from a manifest fetched by `toolkit refresh` and cached on disk, so a clean checkout has only `refresh` and its script examples are unverifiable rather than wrong. A test_examples_were_discovered guard fails if the entry point groups go stale, so the suite cannot silently degrade to testing nothing. --- .../src/deepctl_cmd_debug/command.py | 2 +- .../src/deepctl_cmd_login/command.py | 2 +- .../src/deepctl_cmd_usage/command.py | 4 +- tests/unit/test_command_examples.py | 131 ++++++++++++++++++ 4 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_command_examples.py diff --git a/packages/deepctl-cmd-debug/src/deepctl_cmd_debug/command.py b/packages/deepctl-cmd-debug/src/deepctl_cmd_debug/command.py index ef8c347..df0af89 100644 --- a/packages/deepctl-cmd-debug/src/deepctl_cmd_debug/command.py +++ b/packages/deepctl-cmd-debug/src/deepctl_cmd_debug/command.py @@ -27,7 +27,7 @@ class DebugCommand(BaseGroupCommand): "dg debug audio -f recording.wav", "dg debug network", "dg debug browser", - "dg debug stream", + "dg debug probe", ] agent_help = ( "Diagnostic utilities for troubleshooting Deepgram integrations. " diff --git a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py index 1b4de0b..88d91af 100644 --- a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py +++ b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py @@ -539,7 +539,7 @@ class ProfilesCommand(BaseCommand): examples = [ "dg profiles --list", - "dg profiles --show default", + "dg profiles --current", "dg profiles --switch staging", ] agent_help = ( diff --git a/packages/deepctl-cmd-usage/src/deepctl_cmd_usage/command.py b/packages/deepctl-cmd-usage/src/deepctl_cmd_usage/command.py index 26f2e7b..ace787f 100644 --- a/packages/deepctl-cmd-usage/src/deepctl_cmd_usage/command.py +++ b/packages/deepctl-cmd-usage/src/deepctl_cmd_usage/command.py @@ -37,8 +37,8 @@ class UsageCommand(BaseCommand): examples = [ "dg usage", - "dg usage --days 30", - "dg usage --start 2025-01-01 --end 2025-01-31", + "dg usage --last-month", + "dg usage --start-date 2025-01-01 --end-date 2025-01-31", ] agent_help = ( "View Deepgram API usage statistics for the current project. " diff --git a/tests/unit/test_command_examples.py b/tests/unit/test_command_examples.py new file mode 100644 index 0000000..c4d1da7 --- /dev/null +++ b/tests/unit/test_command_examples.py @@ -0,0 +1,131 @@ +"""Every advertised example must actually parse. + +Regression cover for #105: `dg usage` shipped three examples, two of which the +command could not parse (`--days`, `--start`/`--end` against real options +`--start-date`/`--end-date`). The help text contradicted its own options list +eight lines further down. + +This matters beyond `--help`. The same `examples` array is what +`--agent-friendly` emits, so an agent asking the CLI how to use itself was +handed commands that fail. A sweep at the time this test was written found +four broken examples across three commands, so the class needed a gate rather +than three fixes. + +Parsing only -- `parse_args` resolves the command and validates the options +without invoking the handler, so nothing here touches the network. +""" + +from __future__ import annotations + +import re +import shlex +from importlib import metadata + +import click +import pytest + +# Entry point groups that carry command classes. +COMMAND_GROUPS = ["deepctl.commands", "deepctl.subcommands.debug"] + +BINARY_NAMES = ("dg", "deepctl", "deepgram") + +# Groups whose subcommands are built at runtime from state this test cannot +# see, so their examples are unverifiable rather than wrong. +# toolkit: subcommands come from a manifest fetched by `dg debug toolkit +# refresh` and cached on disk; a clean checkout has only `refresh`. +DYNAMIC_SUBCOMMAND_PREFIXES = [("debug", "toolkit")] + + +def _dg_invocations(example: str) -> list[list[str]]: + """Extract the argv of each `dg ...` invocation in a shell example. + + Examples are shell snippets, not bare argv: they contain pipelines + (`dg speak "hi" | ffplay -`), upstream producers (`cat f | dg read`), and + trailing `# comments`. Only the segments that invoke our own binary are + ours to validate. + """ + if "$(" in example or "`" in example: + # Command substitution -- `eval "$(dg completion bash)"` and friends. + # The inner dg call is real but the surrounding shell is not argv. + return [] + + invocations = [] + for segment in re.split(r"\|\||&&|\|", example): + try: + argv = shlex.split(segment, comments=True) + except ValueError: + continue + if argv and argv[0] in BINARY_NAMES: + invocations.append(argv[1:]) + return invocations + + +def _parse(cli: click.Group, argv: list[str]) -> None: + """Resolve the command path and parse its options. Never invokes.""" + ctx = click.Context(cli, info_name="dg") + command: click.Command = cli + args = list(argv) + + while isinstance(command, click.Group) and args and not args[0].startswith("-"): + name, sub, args = command.resolve_command(ctx, args) + if sub is None: + raise click.UsageError(f"No such command {name!r}") + ctx = click.Context(sub, parent=ctx, info_name=name) + command = sub + + command.parse_args(ctx, list(args)) + + +def _collect() -> list[tuple[str, str, list[str]]]: + """(command name, example string, argv) for every advertised example.""" + entry_points = metadata.entry_points() + collected = [] + for group in COMMAND_GROUPS: + for entry_point in entry_points.select(group=group): + try: + command_class = entry_point.load() + except Exception: # pragma: no cover - a broken package fails elsewhere + continue + for example in getattr(command_class, "examples", None) or []: + for argv in _dg_invocations(example): + if any( + tuple(argv[: len(prefix)]) == prefix + for prefix in DYNAMIC_SUBCOMMAND_PREFIXES + ): + continue + collected.append((entry_point.name, example, argv)) + return collected + + +CASES = _collect() + + +def test_examples_were_discovered() -> None: + """Guard the guard: an import change that empties CASES must not pass.""" + assert len(CASES) > 50, ( + f"only {len(CASES)} examples discovered -- the entry point groups in " + "COMMAND_GROUPS are probably stale, so this file is testing nothing" + ) + + +@pytest.mark.parametrize( + ("command_name", "example", "argv"), + CASES, + ids=[f"{name}: {example}" for name, example, _ in CASES], +) +def test_example_parses(command_name: str, example: str, argv: list[str]) -> None: + """Every string in every `examples` array must parse against the real CLI.""" + from deepctl.main import cli + + try: + _parse(cli, argv) + except (SystemExit, click.exceptions.Exit): + # An eager option such as --help short-circuits; it parsed fine. + pass + except click.ClickException as exc: + pytest.fail( + f"`{example}` is advertised by `dg {command_name}` but does not " + f"parse: {type(exc).__name__}: {exc}\n" + "Fix the example, or add the option/subcommand it promises. This " + "array is also what --agent-friendly emits." + ) From 71aaa305233e9561e188059745a7248e9fa8fbff Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Thu, 20 Aug 2026 11:37:26 +0100 Subject: [PATCH 03/17] docs(web): attribute auto-JSON to agent/CI detection, not piping Closes #106. The landing page promised output auto-switches to JSON when stdout is piped. It doesn't: setup_output only flips the format when is_agentic() is true, and is_agentic() needs 3+ soft signals. A plain pipe from an interactive shell scores 1 (stdout not a tty), or 2 if stdin is redirected too -- TERM is set in any normal terminal, so the third point never arrives. Verified directly: with both streams non-tty and TERM=xterm, is_agentic() is False and setup_output("default") leaves the format at "default". Two instances beyond the three the issue names: - README.md said the switch happens in "a non-TTY environment (pipes, CI, or AI coding tools)". CI and AI tools are right; pipes are not. - index.astro:433 is a JSON-LD FAQPage answer -- structured data Google can surface as a rich result, so the false claim travels further than the page. The 'Errors to stderr' bullet is softened rather than kept. #106 offered keeping "Clean stdout channel. No surprises in pipes." if #104 landed first; #104 landed, but the claim is still not true. Probing failure paths across ten commands found `dg ffprobe --path /nonexistent` and `dg debug audio -f /nonexistent.wav` still emitting prose to stdout ahead of the JSON payload, from bare Console() instances in their own packages. Those consoles are mixed -- they also carry the human-readable display that belongs on stdout in default mode -- so routing them needs per-call-site judgment across ~30 command packages rather than a module-level swap. Out of scope here; the bullet now describes the contract without the absolute guarantee. The agent-mode copy at :645 and llms.txt:34 already attributed the switch correctly and are left alone. Verified by building the site: all three visible strings render, the old claims are gone, and the FAQPage schema still parses. --- README.md | 7 +++++-- web/src/pages/index.astro | 8 ++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 21303f5..b030e16 100644 --- a/README.md +++ b/README.md @@ -316,8 +316,11 @@ dg keys --list -o csv dg usage --last-week -o yaml ``` -When running in a non-TTY environment (pipes, CI, or AI coding tools), the CLI -automatically switches to structured JSON output with plain-text status messages. +In CI and AI coding tools, the CLI detects the context and automatically +switches to structured JSON output with plain-text status messages. A plain +pipe on its own does not trigger this — `dg projects | jq` still gets the +human-readable table, so pass `-o json` explicitly when you are piping from an +interactive shell. ### Exit codes diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 18748bc..9f9cbed 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -430,7 +430,7 @@ const commandCards: CommandCard[] = [ "name": "Can I pipe the Deepgram CLI output to other tools?", "acceptedAnswer": { "@type": "Answer", - "text": "Yes. The CLI is fully UNIX-composable. Use --output json (or -o json) to get structured JSON, which can be piped to jq, grep, and other tools. When stdout is a pipe, the CLI automatically switches to JSON. Status messages always go to stderr to keep stdout clean." + "text": "Yes. The CLI is fully UNIX-composable. Use --output json (or -o json) to get structured JSON, which can be piped to jq, grep, and other tools. In CI and AI coding tools the CLI detects the context and switches to JSON automatically; from an interactive shell, pass -o json explicitly. Status messages go to stderr so stdout carries only the result." } } ] @@ -669,12 +669,12 @@ const commandCards: CommandCard[] = [

Every command writes structured data to stdout and diagnostics to stderr. Switch formats with -o json or let it - auto-switch when piped. Plays nicely with every UNIX tool you already know. + auto-switch in agent and CI environments. Plays nicely with every UNIX tool you already know.

{[ - ['JSON / YAML / table / CSV', 'Explicit output format, or auto-JSON when piped.'], - ['Errors to stderr', 'Clean stdout channel. No surprises in pipes.'], + ['JSON / YAML / table / CSV', 'Explicit output format, or auto-JSON in agent and CI contexts.'], + ['Errors to stderr', 'Status and diagnostics on stderr, structured results on stdout.'], ['Exit codes everywhere', 'Non-zero on error. Works in set -e scripts.'], ].map(([label, desc]) => (
From c56e96380f8a537802b013c9c378f0af80b5508a Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Thu, 20 Aug 2026 11:51:41 +0100 Subject: [PATCH 04/17] fix(test): read source as UTF-8 in the bare-Console AST sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard called path.read_text() with no encoding, so it used the locale codec. On Windows that is cp1252, which cannot decode the ⏱️ in timing.py:119, and the test died with UnicodeDecodeError before it could assert anything -- failing every windows-latest job in the matrix while passing on Linux and macOS. Reproduced locally by forcing the codec: read_text(encoding="cp1252") on timing.py raises at byte 3610; utf-8 reads it fine. --- packages/deepctl-core/tests/unit/test_output_channels.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/deepctl-core/tests/unit/test_output_channels.py b/packages/deepctl-core/tests/unit/test_output_channels.py index e8fa8d8..f4c6b55 100644 --- a/packages/deepctl-core/tests/unit/test_output_channels.py +++ b/packages/deepctl-core/tests/unit/test_output_channels.py @@ -70,7 +70,11 @@ def test_no_module_declares_a_bare_console(self) -> None: offenders: list[str] = [] for path in sorted(CORE_SRC.glob("*.py")): - tree = ast.parse(path.read_text(), filename=str(path)) + # encoding is explicit because read_text() otherwise uses the + # locale codec -- cp1252 on Windows, which cannot decode the + # emoji in timing.py and fails the whole matrix. + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) for node in ast.walk(tree): if not isinstance(node, ast.Call): continue From 70d7223feecde9b4db21f5fb4d556f3cf6b80a12 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 20 Sep 2026 10:35:16 -0400 Subject: [PATCH 05/17] fix(core): keep credential-source diagnostics off stdout `dg --api-key -o json projects --list` wrote two English sentences through the stdout console before the payload, so `json.loads(stdout)` raised -- the same #104 defect as the failure path, six lines above the block this branch already fixed. `print_info` and `print_warning` only route to stderr in agentic mode; with an explicit `-o json` from a terminal they do not. Send those three lines straight to `stderr_console`, keeping the INFO:/ WARN: prefixes so the rendering is unchanged in both modes. Probed with a pty stdin, piped stdout, `TERM=xterm` and a local HTTP stub: stdout goes 242 B of prose-then-JSON to 172 B of pure JSON that `json.loads` parses, and stderr picks the two lines up. Also: - the auth-failure `output_result` guard now matches its sibling: one `except OSError` (BrokenPipeError is an OSError) plus the `ValueError("closed file")` case it previously dropped - the bare-Console AST sweep uses `rglob`, so a future subpackage under `deepctl_core/` is covered too Co-Authored-By: Claude Opus 5 --- .../src/deepctl_core/base_command.py | 37 +++++++-- .../tests/unit/test_output_channels.py | 82 ++++++++++++++++++- 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/packages/deepctl-core/src/deepctl_core/base_command.py b/packages/deepctl-core/src/deepctl_core/base_command.py index 8aa48be..abe57fd 100644 --- a/packages/deepctl-core/src/deepctl_core/base_command.py +++ b/packages/deepctl-core/src/deepctl_core/base_command.py @@ -9,7 +9,7 @@ from .client import DeepgramClient from .config import Config from .models import ErrorResult -from .output import _agentic, print_error, print_info, print_warning, stderr_console +from .output import _agentic, print_error, stderr_console from .output import console as stdout_console from .timing import TimingContext @@ -104,11 +104,27 @@ def execute(self, ctx: click.Context, **kwargs: Any) -> None: "explicit flags", "environment variables", ]: - print_info(f"Using credentials from {source}") + # Diagnostics, not the result. print_info / + # print_warning still write to stdout outside + # agentic mode, so with an explicit `-o json` + # these three lines landed in front of the + # payload and broke `| jq` (#104, success + # path). Route them straight to stderr; the + # prefixes mirror output.py so the rendering + # is unchanged in both modes. + info = "INFO:" if _agentic else "[blue]ℹ[/blue]" + stderr_console.print( + f"{info} Using credentials from {source}" + ) if project_id: - print_info(f"Affecting project: {project_id}") + stderr_console.print( + f"{info} Affecting project: {project_id}" + ) else: - print_warning("No project ID specified") + warn = "WARN:" if _agentic else "[yellow]⚠[/yellow]" + stderr_console.print( + f"{warn} No project ID specified" + ) except Exception as auth_error: # guard() already wrote the human-readable diagnosis to @@ -123,8 +139,16 @@ def execute(self, ctx: click.Context, **kwargs: Any) -> None: self.output_result( ErrorResult(error=str(auth_error)), config ) - except (BrokenPipeError, OSError): + except OSError: + # Downstream stream closed. BrokenPipeError is an + # OSError, so one clause covers both. pass + except ValueError as exc: + # Rich raises ValueError("I/O operation on closed + # file") when the stream went away mid-write; any + # other ValueError is a real bug. + if "closed file" not in str(exc): + raise raise SystemExit(1) from auth_error # Check project ID if required @@ -156,10 +180,11 @@ def execute(self, ctx: click.Context, **kwargs: Any) -> None: with TimingContext("output_processing"): try: self.output_result(result, config) - except (BrokenPipeError, OSError): + except OSError: # Downstream stream closed (e.g. an MCP host disconnected # stdio after `dg mcp` finished). Nothing useful to log # here because the logger writes to the same closed stream. + # BrokenPipeError is an OSError, so one clause covers both. pass except ValueError as exc: if "closed file" not in str(exc): diff --git a/packages/deepctl-core/tests/unit/test_output_channels.py b/packages/deepctl-core/tests/unit/test_output_channels.py index f4c6b55..c1e8988 100644 --- a/packages/deepctl-core/tests/unit/test_output_channels.py +++ b/packages/deepctl-core/tests/unit/test_output_channels.py @@ -69,7 +69,9 @@ class TestNoBareConsoleInCore: def test_no_module_declares_a_bare_console(self) -> None: offenders: list[str] = [] - for path in sorted(CORE_SRC.glob("*.py")): + # rglob, not glob: a future subpackage under deepctl_core/ must be + # swept too, or the guard quietly stops covering new code. + for path in sorted(CORE_SRC.rglob("*.py")): # encoding is explicit because read_text() otherwise uses the # locale codec -- cp1252 on Windows, which cannot decode the # emoji in timing.py and fails the whole matrix. @@ -150,3 +152,81 @@ def test_default_mode_writes_nothing_to_stdout(self, capsys) -> None: captured = self._run_guard_failure(capsys, "default") assert captured.out == "" + + +class TestCredentialSourceDiagnostics: + """The #104 success path: `--api-key` used to prefix the payload. + + `print_info` / `print_warning` write to *stdout* outside agentic mode, so + `dg --api-key -o json projects --list` put two English sentences in + front of the JSON and `json.loads(stdout)` raised. These are diagnostics; + they belong on stderr in every mode. + """ + + def _run_with_credential_source(self, capsys, source: str, project_id): + from unittest.mock import MagicMock, patch + + import click + from deepctl_core import base_command + from deepctl_core.base_command import BaseCommand + from deepctl_core.config import Config + from deepctl_core.models import BaseResult + + class NeedsAuth(BaseCommand): + name = "needs-auth" + help = "test command" + requires_auth = True + + def handle(self, config, auth_manager, client, **kwargs): # type: ignore[no-untyped-def] + return BaseResult(status="success", message="done") + + command = NeedsAuth() + ctx = MagicMock(spec=click.Context) + ctx.obj = {"config": Config()} + # is_guided() walks ctx.command.params; spec=Context does not supply + # `command` because click sets it in __init__. + ctx.command = MagicMock(params=[]) + ctx.params = {} + + auth_manager = MagicMock() + auth_manager.guard.return_value = None + auth_manager.get_credential_source.return_value = source + auth_manager.get_project_id.return_value = project_id + + # Non-agentic is the mode the bug lived in: print_info/print_warning + # already route to stderr when `agentic` is set, and pytest's captured + # streams would otherwise make that the default here and hide the + # regression. Pin it off so this test exercises the broken path. + with ( + patch( + "deepctl_core.base_command.AuthManager", return_value=auth_manager + ), + patch("deepctl_core.base_command.DeepgramClient"), + patch( + "deepctl_core.output.get_output_format", return_value="json" + ), + patch.object(base_command, "_agentic", False), + patch.dict(output._output_config, {"agentic": False, "quiet": False}), + ): + command.execute(ctx) + + return capsys.readouterr() + + def test_explicit_flags_leave_stdout_parseable(self, capsys) -> None: + captured = self._run_with_credential_source(capsys, "explicit flags", None) + + payload = json.loads(captured.out) + assert payload["status"] == "success" + assert "Using credentials from" in captured.err + assert "No project ID specified" in captured.err + assert "Using credentials from" not in captured.out + + def test_project_id_line_also_goes_to_stderr(self, capsys) -> None: + captured = self._run_with_credential_source( + capsys, "environment variables", "abc-123" + ) + + payload = json.loads(captured.out) + assert payload["status"] == "success" + assert "Affecting project: abc-123" in captured.err + assert "abc-123" not in captured.out From 8d17d4b1f63920c98a55b60b67cddf7e7f4e5d3e Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 20 Sep 2026 10:35:28 -0400 Subject: [PATCH 06/17] fix(ffprobe,debug-audio): keep stdout for the payload Both commands printed their human-readable text through a bare module-level `Console()` bound to stdout, so `-o json` put prose in front of the JSON and `| jq` failed. Two separate problems, fixed separately: - errors, warnings and progress lines move to a stderr console. In `deepctl_shared_utils.validation` and `.ffprobe` that is the module-level swap the review describes -- all ~35 call sites there are diagnostics, none carries a payload. The package does not depend on `deepctl-core`, so it declares its own `Console(stderr=True)` rather than importing the shared instance. - the human rendering of the result (the analysis tables, the compatibility check, the encoding suggestions, the status lines) is the same information the returned model carries, so it now prints only when `get_output_format() == "default"` -- the guard the keys, models, usage and requests commands already use. Probed with a pty stdin, piped stdout, `TERM=xterm`, no network: dg -o json ffprobe --path /nonexistent 105 B mixed -> 69 B JSON, 36 B stderr dg -o json ffprobe 239 B JSON, 0 B stderr dg -o json debug audio -f missing.wav 988 B mixed -> 244 B JSON, 744 B stderr dg -o json debug audio -f tone.wav 2615 B JSON, 135 B stderr Human mode is unchanged apart from the diagnostics moving to stderr. Co-Authored-By: Claude Opus 5 --- .../src/deepctl_cmd_debug_audio/command.py | 122 +++++++++++------- .../src/deepctl_cmd_ffprobe/command.py | 70 ++++++---- .../src/deepctl_shared_utils/ffprobe.py | 7 +- .../src/deepctl_shared_utils/validation.py | 7 +- 4 files changed, 134 insertions(+), 72 deletions(-) diff --git a/packages/deepctl-cmd-debug-audio/src/deepctl_cmd_debug_audio/command.py b/packages/deepctl-cmd-debug-audio/src/deepctl_cmd_debug_audio/command.py index 1b5ed6e..193a3cc 100644 --- a/packages/deepctl-cmd-debug-audio/src/deepctl_cmd_debug_audio/command.py +++ b/packages/deepctl-cmd-debug-audio/src/deepctl_cmd_debug_audio/command.py @@ -10,15 +10,34 @@ import ffmpeg # type: ignore[import-untyped] import httpx -from deepctl_core import AuthManager, BaseCommand, Config, DeepgramClient +from deepctl_core import ( + AuthManager, + BaseCommand, + Config, + DeepgramClient, + get_console, + get_output_format, + get_status_console, +) from rich import box -from rich.console import Console from rich.panel import Panel from rich.table import Table from .models import AudioDebugResult, AudioFormat, AudioInfo, AudioStream -console = Console() +# Two channels, deliberately (#104): +# console -> stdout, the human rendering of the analysis. Only +# written in default (human) output mode; in json/yaml/ +# csv/table mode the serialized AudioDebugResult is the +# whole of stdout, so `dg -o json debug audio | jq` parses. +# status_console -> stderr, always: progress lines and failure panels. +console = get_console() +status_console = get_status_console() + + +def _human_output() -> bool: + """True when stdout is for a person, not a parser.""" + return get_output_format() == "default" class AudioCommand(BaseCommand): @@ -87,7 +106,7 @@ def _is_url(self, path: str) -> bool: def _download_url(self, url: str) -> str: """Download a URL to a temporary file and return the path.""" - console.print(f"[blue]Downloading:[/blue] {url}") + status_console.print(f"[blue]Downloading:[/blue] {url}") parsed = urlparse(url) ext = os.path.splitext(parsed.path)[1] or ".audio" @@ -107,7 +126,7 @@ def _download_url(self, url: str) -> str: tmp.write(chunk) total += len(chunk) size_mb = total / (1024 * 1024) - console.print( + status_console.print( f"[green]Downloaded[/green] {size_mb:.2f} MB → {tmp_name}" ) return tmp_name @@ -406,7 +425,7 @@ def handle( # Check if ffmpeg is installed if not self.check_ffmpeg_installed(): - console.print( + status_console.print( Panel( "[red]✗ FFmpeg not found![/red]\n\n" "The audio debug command requires FFmpeg to be installed " @@ -438,7 +457,7 @@ def handle( downloaded_file = self._download_url(audio_file) file_to_analyze = downloaded_file except Exception as e: - console.print( + status_console.print( Panel( f"[red]✗ Failed to download URL[/red]\n\n[dim]{e!s}[/dim]", title="Download Failed", @@ -454,7 +473,9 @@ def handle( # Process the audio file try: - console.print(f"[blue]Analyzing audio file:[/blue] {file_to_analyze}") + status_console.print( + f"[blue]Analyzing audio file:[/blue] {file_to_analyze}" + ) # Run ffprobe probe_data = self.run_ffprobe(file_to_analyze, ffprobe_args) @@ -462,45 +483,52 @@ def handle( # Parse the data audio_info = self.parse_audio_info(probe_data) - # Display results based on verbosity - if extra_verbose or ffprobe_args: - self.display_extra_verbose_info(audio_info) - elif verbose: - self.display_verbose_info(audio_info) - else: - self.display_basic_info(audio_info) - - # Check for Deepgram compatibility - console.print("\n[bold]Deepgram Compatibility Check:[/bold]") - compatibility_issues = [] - - if audio_info.streams: - for stream in audio_info.streams: - # Check sample rate - if stream.sample_rate and int(stream.sample_rate) < 8000: - compatibility_issues.append( - f"⚠️ Low sample rate ({stream.sample_rate} Hz) - " - f"Deepgram works best with 8kHz or higher" - ) - - # Check channels - if stream.channels and stream.channels > 2: - compatibility_issues.append( - f"⚠️ Multi-channel audio ({stream.channels} " - f"channels) - Consider converting to mono or " - f"stereo" - ) - - if compatibility_issues: - for issue in compatibility_issues: - console.print(f" {issue}") - else: - console.print( - " [green]✓[/green] Audio appears to be compatible with Deepgram" - ) + # Everything below is the human rendering of the analysis. It is + # the same information the returned AudioDebugResult carries, so + # in a machine-readable format it would be duplicate prose sitting + # in front of the payload -- exactly the #104 break. Print it only + # when stdout is for a person. + if _human_output(): + # Display results based on verbosity + if extra_verbose or ffprobe_args: + self.display_extra_verbose_info(audio_info) + elif verbose: + self.display_verbose_info(audio_info) + else: + self.display_basic_info(audio_info) + + # Check for Deepgram compatibility + console.print("\n[bold]Deepgram Compatibility Check:[/bold]") + compatibility_issues = [] + + if audio_info.streams: + for stream in audio_info.streams: + # Check sample rate + if stream.sample_rate and int(stream.sample_rate) < 8000: + compatibility_issues.append( + f"⚠️ Low sample rate ({stream.sample_rate} Hz) - " + f"Deepgram works best with 8kHz or higher" + ) + + # Check channels + if stream.channels and stream.channels > 2: + compatibility_issues.append( + f"⚠️ Multi-channel audio ({stream.channels} " + f"channels) - Consider converting to mono or " + f"stereo" + ) + + if compatibility_issues: + for issue in compatibility_issues: + console.print(f" {issue}") + else: + console.print( + " [green]✓[/green] Audio appears to be compatible " + "with Deepgram" + ) - # Encoding suggestions - self._suggest_encoding(audio_info) + # Encoding suggestions + self._suggest_encoding(audio_info) return AudioDebugResult( status="success", @@ -510,7 +538,7 @@ def handle( ) except Exception as e: - console.print( + status_console.print( Panel( f"[red]✗ Error analyzing audio file[/red]\n\n[dim]{e!s}[/dim]", title="Analysis Failed", diff --git a/packages/deepctl-cmd-ffprobe/src/deepctl_cmd_ffprobe/command.py b/packages/deepctl-cmd-ffprobe/src/deepctl_cmd_ffprobe/command.py index 24ff1f3..acec08b 100644 --- a/packages/deepctl-cmd-ffprobe/src/deepctl_cmd_ffprobe/command.py +++ b/packages/deepctl-cmd-ffprobe/src/deepctl_cmd_ffprobe/command.py @@ -7,16 +7,35 @@ import subprocess from typing import Any -from deepctl_core import AuthManager, BaseCommand, BaseResult, Config, DeepgramClient +from deepctl_core import ( + AuthManager, + BaseCommand, + BaseResult, + Config, + DeepgramClient, + get_console, + get_output_format, + get_status_console, +) from deepctl_shared_utils import ( get_ffprobe_path, print_ffprobe_install_instructions, ) -from rich.console import Console from .models import FfprobeResult -console = Console() +# Two channels, deliberately (#104): +# console -> stdout, the human rendering of the FfprobeResult. +# Written only in default (human) output mode, so +# `dg -o json ffprobe | jq` sees the payload alone. +# status_console -> stderr, always: errors and install instructions. +console = get_console() +status_console = get_status_console() + + +def _human_output() -> bool: + """True when stdout is for a person, not a parser.""" + return get_output_format() == "default" class FfprobeCommand(BaseCommand): @@ -74,7 +93,9 @@ def handle( reset = kwargs.get("reset", False) if path and reset: - console.print("[red]Error:[/red] Cannot use --path and --reset together") + status_console.print( + "[red]Error:[/red] Cannot use --path and --reset together" + ) return BaseResult( status="error", message="Cannot use --path and --reset together", @@ -92,11 +113,11 @@ def _handle_set_path(self, config: Config, path: str) -> BaseResult: """Store a custom ffprobe path.""" # Validate the path if not os.path.isfile(path): - console.print(f"[red]Error:[/red] File not found: {path}") + status_console.print(f"[red]Error:[/red] File not found: {path}") return BaseResult(status="error", message=f"File not found: {path}") if not os.access(path, os.X_OK): - console.print(f"[red]Error:[/red] File is not executable: {path}") + status_console.print(f"[red]Error:[/red] File is not executable: {path}") return BaseResult(status="error", message=f"File is not executable: {path}") # Store in config @@ -104,9 +125,10 @@ def _handle_set_path(self, config: Config, path: str) -> BaseResult: config.save() version = self._get_version(path) - console.print(f"[green]✓[/green] ffprobe path stored: {path}") - if version: - console.print(f" Version: {version}") + if _human_output(): + console.print(f"[green]✓[/green] ffprobe path stored: {path}") + if version: + console.print(f" Version: {version}") return FfprobeResult( status="success", @@ -121,14 +143,14 @@ def _handle_reset(self, config: Config) -> BaseResult: config._config.tools.ffprobe_path = None config.save() - console.print("[green]✓[/green] Stored ffprobe path cleared") - # Show auto-detected path auto_path = shutil.which("ffprobe") - if auto_path: - console.print(f" Auto-detected: {auto_path}") - else: - console.print(" [yellow]ffprobe not found in PATH[/yellow]") + if _human_output(): + console.print("[green]✓[/green] Stored ffprobe path cleared") + if auto_path: + console.print(f" Auto-detected: {auto_path}") + else: + console.print(" [yellow]ffprobe not found in PATH[/yellow]") return FfprobeResult( status="success", @@ -143,17 +165,19 @@ def _handle_status(self, config: Config) -> BaseResult: auto_path = shutil.which("ffprobe") effective_path = get_ffprobe_path(config) - if stored_path: - console.print(f" Stored path: {stored_path}") - if auto_path: - console.print(f" Auto-detected: {auto_path}") + if _human_output(): + if stored_path: + console.print(f" Stored path: {stored_path}") + if auto_path: + console.print(f" Auto-detected: {auto_path}") if effective_path: version = self._get_version(effective_path) - console.print(f" Active path: [green]{effective_path}[/green]") - if version: - console.print(f" Version: {version}") - console.print("\n[green]✓[/green] ffprobe is available") + if _human_output(): + console.print(f" Active path: [green]{effective_path}[/green]") + if version: + console.print(f" Version: {version}") + console.print("\n[green]✓[/green] ffprobe is available") return FfprobeResult( status="success", diff --git a/packages/deepctl-shared-utils/src/deepctl_shared_utils/ffprobe.py b/packages/deepctl-shared-utils/src/deepctl_shared_utils/ffprobe.py index de3c04c..ef70250 100644 --- a/packages/deepctl-shared-utils/src/deepctl_shared_utils/ffprobe.py +++ b/packages/deepctl-shared-utils/src/deepctl_shared_utils/ffprobe.py @@ -18,7 +18,12 @@ if TYPE_CHECKING: from deepctl_core import Config -console = Console() +# Diagnostics only: every console.print below is an error, a warning or a +# progress line -- never a payload -- so it goes to stderr and can never +# corrupt the machine-readable result a command writes to stdout (#104). +# deepctl-shared-utils does not depend on deepctl-core, so this is a local +# stderr Console rather than an import of core's shared `stderr_console`. +console = Console(stderr=True) def get_ffprobe_path(config: Config | None = None) -> str | None: diff --git a/packages/deepctl-shared-utils/src/deepctl_shared_utils/validation.py b/packages/deepctl-shared-utils/src/deepctl_shared_utils/validation.py index bf77dce..16ddec0 100644 --- a/packages/deepctl-shared-utils/src/deepctl_shared_utils/validation.py +++ b/packages/deepctl-shared-utils/src/deepctl_shared_utils/validation.py @@ -10,7 +10,12 @@ from .models import FileInfo -console = Console() +# Diagnostics only: every console.print below is an error, a warning or a +# progress line -- never a payload -- so it goes to stderr and can never +# corrupt the machine-readable result a command writes to stdout (#104). +# deepctl-shared-utils does not depend on deepctl-core, so this is a local +# stderr Console rather than an import of core's shared `stderr_console`. +console = Console(stderr=True) # Supported audio file extensions SUPPORTED_AUDIO_EXTENSIONS = { From ec289ade497c6d76962927ad9d204347d90f2730 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 20 Sep 2026 10:35:37 -0400 Subject: [PATCH 07/17] test(examples): check every entry point group and the substituted examples Three holes in the guard added earlier on this branch: - `test_examples_were_discovered` put a floor on the *total* number of cases. `deepctl.commands` alone supplies 115 of them, so dropping `deepctl.subcommands.debug` -- the group that carried the broken `dg debug stream` example this branch fixes -- still cleared the floor. The check is now parametrized per group, so an empty group fails on its own. - the `debug toolkit` exemption skipped the whole prefix, including `dg debug toolkit refresh`, which the exemption's own comment says is the one subcommand a clean checkout always has. The exemption now carries the set of statically present subcommands and keeps checking them. - examples containing a command substitution were dropped silently. Each `$(...)` is now lifted out as a snippet of its own with a placeholder left behind, so `eval "$(dg completion bash)"` and `dg ffprobe --path $(which ffprobe)` are both validated. 124 cases -> 127. Co-Authored-By: Claude Opus 5 --- tests/unit/test_command_examples.py | 120 ++++++++++++++++++++-------- 1 file changed, 87 insertions(+), 33 deletions(-) diff --git a/tests/unit/test_command_examples.py b/tests/unit/test_command_examples.py index c4d1da7..b00ed8f 100644 --- a/tests/unit/test_command_examples.py +++ b/tests/unit/test_command_examples.py @@ -30,33 +30,78 @@ BINARY_NAMES = ("dg", "deepctl", "deepgram") # Groups whose subcommands are built at runtime from state this test cannot -# see, so their examples are unverifiable rather than wrong. -# toolkit: subcommands come from a manifest fetched by `dg debug toolkit -# refresh` and cached on disk; a clean checkout has only `refresh`. -DYNAMIC_SUBCOMMAND_PREFIXES = [("debug", "toolkit")] +# see, so their examples are unverifiable rather than wrong. The value is the +# set of subcommands that *are* statically present, and those stay checked -- +# exempting the whole prefix would have excused `dg debug toolkit refresh`, +# the one subcommand the comment below says a clean checkout always has. +# toolkit: the rest come from a manifest fetched by `dg debug toolkit +# refresh` and cached on disk. +DYNAMIC_SUBCOMMAND_PREFIXES: dict[tuple[str, ...], frozenset[str]] = { + ("debug", "toolkit"): frozenset({"refresh"}), +} + + +def _is_dynamic(argv: list[str]) -> bool: + """True when argv names a subcommand only a populated cache would define.""" + for prefix, static in DYNAMIC_SUBCOMMAND_PREFIXES.items(): + if tuple(argv[: len(prefix)]) != prefix: + continue + rest = argv[len(prefix) :] + # `dg debug toolkit` itself, and its statically defined subcommands, + # resolve in a clean checkout -- check them. + if not rest or rest[0].startswith("-") or rest[0] in static: + return False + return True + return False + + +# `$(...)` or `` `...` `` -- non-nested, which is all our examples use. +SUBSTITUTION = re.compile(r"\$\(([^()]*)\)|`([^`]*)`") + +# Stands in for whatever a command substitution would expand to. Options that +# take a path validate their argument at runtime, not at parse time, so any +# non-empty token is enough to check the *shape* of the invocation. +SUBSTITUTION_PLACEHOLDER = "SUBSTITUTED" + + +def _split_substitutions(example: str) -> tuple[str, list[str]]: + """Split a snippet into its outer command and its substituted snippets. + + `eval "$(dg completion bash)"` and `dg ffprobe --path $(which ffprobe)` + are both advertised. Dropping them, as this test first did, left 2 of the + advertised examples unchecked -- including the only one that invokes + `dg completion`. Instead, lift each substitution out as a snippet in its + own right and leave a placeholder token behind, so both the inner and the + outer invocation get validated. + """ + inner: list[str] = [] + + def take(match: re.Match[str]) -> str: + inner.append(match.group(1) if match.group(1) is not None else match.group(2)) + return SUBSTITUTION_PLACEHOLDER + + return SUBSTITUTION.sub(take, example), inner def _dg_invocations(example: str) -> list[list[str]]: """Extract the argv of each `dg ...` invocation in a shell example. Examples are shell snippets, not bare argv: they contain pipelines - (`dg speak "hi" | ffplay -`), upstream producers (`cat f | dg read`), and - trailing `# comments`. Only the segments that invoke our own binary are - ours to validate. + (`dg speak "hi" | ffplay -`), upstream producers (`cat f | dg read`), + command substitutions, and trailing `# comments`. Only the segments that + invoke our own binary are ours to validate. """ - if "$(" in example or "`" in example: - # Command substitution -- `eval "$(dg completion bash)"` and friends. - # The inner dg call is real but the surrounding shell is not argv. - return [] + outer, inner = _split_substitutions(example) invocations = [] - for segment in re.split(r"\|\||&&|\|", example): - try: - argv = shlex.split(segment, comments=True) - except ValueError: - continue - if argv and argv[0] in BINARY_NAMES: - invocations.append(argv[1:]) + for snippet in [outer, *inner]: + for segment in re.split(r"\|\||&&|\|", snippet): + try: + argv = shlex.split(segment, comments=True) + except ValueError: + continue + if argv and argv[0] in BINARY_NAMES: + invocations.append(argv[1:]) return invocations @@ -76,8 +121,8 @@ def _parse(cli: click.Group, argv: list[str]) -> None: command.parse_args(ctx, list(args)) -def _collect() -> list[tuple[str, str, list[str]]]: - """(command name, example string, argv) for every advertised example.""" +def _collect() -> list[tuple[str, str, str, list[str]]]: + """(group, command name, example string, argv) for every advertised example.""" entry_points = metadata.entry_points() collected = [] for group in COMMAND_GROUPS: @@ -88,32 +133,41 @@ def _collect() -> list[tuple[str, str, list[str]]]: continue for example in getattr(command_class, "examples", None) or []: for argv in _dg_invocations(example): - if any( - tuple(argv[: len(prefix)]) == prefix - for prefix in DYNAMIC_SUBCOMMAND_PREFIXES - ): + if _is_dynamic(argv): continue - collected.append((entry_point.name, example, argv)) + collected.append((group, entry_point.name, example, argv)) return collected CASES = _collect() -def test_examples_were_discovered() -> None: - """Guard the guard: an import change that empties CASES must not pass.""" - assert len(CASES) > 50, ( - f"only {len(CASES)} examples discovered -- the entry point groups in " - "COMMAND_GROUPS are probably stale, so this file is testing nothing" +@pytest.mark.parametrize("group", COMMAND_GROUPS) +def test_examples_were_discovered(group: str) -> None: + """Guard the guard: a stale entry point group must not pass unnoticed. + + A floor on the *total* does not do that. `deepctl.commands` alone supplies + the overwhelming majority of cases, so dropping + `deepctl.subcommands.debug` -- the group that carried the broken + `dg debug stream` example -- still cleared a total-count check. Require + every group to contribute. + """ + from_group = [case for case in CASES if case[0] == group] + assert from_group, ( + f"no examples discovered from the {group!r} entry point group -- it is " + "probably stale or its packages are not installed, so this file is " + "silently testing less than it claims" ) @pytest.mark.parametrize( - ("command_name", "example", "argv"), + ("group", "command_name", "example", "argv"), CASES, - ids=[f"{name}: {example}" for name, example, _ in CASES], + ids=[f"{name}: {example}" for _, name, example, _ in CASES], ) -def test_example_parses(command_name: str, example: str, argv: list[str]) -> None: +def test_example_parses( + group: str, command_name: str, example: str, argv: list[str] +) -> None: """Every string in every `examples` array must parse against the real CLI.""" from deepctl.main import cli From 76f8c6029073cf8a53adb5dba1c3e4fef85c5818 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 20 Sep 2026 10:35:52 -0400 Subject: [PATCH 08/17] docs: make the stdout/stderr promise and the auto-JSON trigger true Four places promised, unconditionally, that diagnostics always go to stderr and stdout carries only the result. Two of the three commands that broke that promise are fixed in this branch; a sweep of every command reachable without a live key found four more that still print a human summary to stdout under `-o json` (`dg completion`, `dg profiles`, `dg update`, and usage errors, which write nothing to stdout at all). So the copy is qualified rather than dropped, and it does not name an exception list that will go stale. - README.md: the stderr/stdout paragraph now says what holds (a command that runs and fails writes its failure to stdout as a `"status": "error"` payload, authentication failures, `dg ffprobe` and `dg debug audio` included), what does not, and to branch on the exit code. - index.astro: "Every command writes..." -> "Core commands write...", and the "Errors to stderr" bullet is qualified the same way. The JSON-LD FAQPage answer, which Google can lift into a search result, carries the qualified wording too; the built `dist/index.html` still parses as valid JSON-LD. Auto-JSON detection is also described more accurately: `is_agentic()` fires on three accumulated soft signals, so a plain non-interactive run with no terminal attached (cron, systemd, `docker run` without `-t`) switches to JSON while being neither CI nor an AI tool. README.md and the FAQ answer now say so. And the two agent-facing files: - llms.txt listed Gemini among auto-detected agent contexts; there is no Gemini signal in `is_agentic()` (Gemini appears only in the skill-file generator). Removed, and non-interactive environments added. - llms-full.txt filed `CI=true` under "soft, 3+ = agent mode" when the code treats it as a hard signal that returns immediately, and omitted `--non-interactive`, `CODEX_SANDBOX_NETWORK_DISABLED` and the `OR_SITE_URL` Aider signal. Its "Data output to stdout only" line is qualified to match the README. Co-Authored-By: Claude Opus 5 --- README.md | 23 ++++++++++++++++------- web/public/llms-full.txt | 18 +++++++++++------- web/public/llms.txt | 2 +- web/src/pages/index.astro | 6 +++--- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b030e16..e5b61a9 100644 --- a/README.md +++ b/README.md @@ -316,11 +316,12 @@ dg keys --list -o csv dg usage --last-week -o yaml ``` -In CI and AI coding tools, the CLI detects the context and automatically -switches to structured JSON output with plain-text status messages. A plain -pipe on its own does not trigger this — `dg projects | jq` still gets the -human-readable table, so pass `-o json` explicitly when you are piping from an -interactive shell. +In CI, in AI coding tools, and in any fully non-interactive environment with +no terminal attached (cron, systemd, `docker run` without `-t`), the CLI +detects the context and automatically switches to structured JSON output with +plain-text status messages. A plain pipe on its own does not trigger this — +`dg projects | jq` from an interactive shell still gets the human-readable +table, so pass `-o json` explicitly when you are piping by hand. ### Exit codes @@ -337,8 +338,16 @@ Note that `dg` reports `2` for an interrupt rather than the shell's conventional `130`, so the code is the same whether the cancellation came from Ctrl-C or from declining a prompt. -If a CI step relied on `dg` always exiting `0` (every command did, before -0.3.0), it will now fail where it previously passed silently. +Human-readable status and error messages go to stderr, and stdout carries the +result. With `-o json` a command that runs and fails writes that failure to +stdout as a payload with `"status": "error"`, so `dg ... -o json | jq` stays +parseable across the failure — authentication failures, `dg ffprobe` and +`dg debug audio` included. This is not yet universal: a handful of commands +still echo their human-readable summary to stdout ahead of the payload, and a +usage error (a bad flag, an unknown command) writes nothing to stdout at all. +Branch on the exit code rather than on whether stdout parsed. If a CI step +relied on `dg` always exiting `0` (every command did, before 0.3.0), it will +now fail where it previously passed silently. ### Forcing non-interactive mode diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index ca3eced..fe4205c 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -316,24 +316,28 @@ All commands accept these global flags: `dg` auto-detects AI agent context and adjusts behavior automatically: -**Detection signals (hard):** -- `CLAUDECODE=1` (Claude Code) -- `CLAUDE_CODE_ENTRYPOINT` (Claude Code CLI) -- `CODEX_SANDBOX` (OpenAI Codex) -- `OR_APP_NAME=Aider` (Aider) +**Detection signals (hard, any one is conclusive):** +- `CLAUDECODE` set (Claude Code) +- `CLAUDE_CODE_ENTRYPOINT` set (Claude Code CLI) +- `CODEX_SANDBOX` or `CODEX_SANDBOX_NETWORK_DISABLED` set (OpenAI Codex) +- `OR_APP_NAME=Aider`, or `aider` in `OR_SITE_URL` (Aider) +- `CI=true` or `CI=1` - `--agent-friendly` flag on command line +- `--non-interactive` flag on command line **Detection signals (soft, 3+ = agent mode):** - stdin not a TTY - stdout not a TTY -- `CI=true` or `CI=1` - `TERM=dumb` or TERM unset - `NO_COLOR` set **Agent mode behavior:** - All interactive prompts return defaults (no blocking on stdin) - Status messages routed to stderr -- Data output to stdout only +- Data output to stdout: the core commands write nothing else there, so a + failure arrives on stdout as parseable JSON. A handful of commands still + print a human summary to stdout ahead of the payload, so branch on the exit + code rather than on whether stdout parsed. - JSON output format by default - Exit codes: 0 = success, 1 = error, 2 = user interrupt diff --git a/web/public/llms.txt b/web/public/llms.txt index 11b3174..fd882e5 100644 --- a/web/public/llms.txt +++ b/web/public/llms.txt @@ -31,7 +31,7 @@ ## Key Features -- Auto-detects AI agent context (Claude Code, Aider, Codex, Gemini) — disables prompts, outputs JSON, routes status to stderr +- Auto-detects AI agent context (Claude Code, Aider, Codex) and fully non-interactive environments — disables prompts, outputs JSON, routes status to stderr - Multiple output formats: `--output json|yaml|table|csv` - Named credential profiles for multi-environment workflows - Plugin system: `dg plugin install ` installs community extensions in isolated venv diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 9f9cbed..0c37ac4 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -430,7 +430,7 @@ const commandCards: CommandCard[] = [ "name": "Can I pipe the Deepgram CLI output to other tools?", "acceptedAnswer": { "@type": "Answer", - "text": "Yes. The CLI is fully UNIX-composable. Use --output json (or -o json) to get structured JSON, which can be piped to jq, grep, and other tools. In CI and AI coding tools the CLI detects the context and switches to JSON automatically; from an interactive shell, pass -o json explicitly. Status messages go to stderr so stdout carries only the result." + "text": "Yes. The CLI is fully UNIX-composable. Use --output json (or -o json) to get structured JSON, which can be piped to jq, grep, and other tools. In CI, in AI coding tools, and in any fully non-interactive environment with no terminal attached, the CLI detects the context and switches to JSON automatically; from an interactive shell, pass -o json explicitly. Status and error messages go to stderr, and the core commands keep stdout for the result alone, so a failure still arrives on stdout as parseable JSON. A handful of commands still print a human summary to stdout, so branch on the exit code rather than on whether stdout parsed." } } ] @@ -667,14 +667,14 @@ const commandCards: CommandCard[] = [ Pipe-friendly.
Script-ready.

- Every command writes structured data to stdout and diagnostics to stderr. + Core commands write structured data to stdout and diagnostics to stderr. Switch formats with -o json or let it auto-switch in agent and CI environments. Plays nicely with every UNIX tool you already know.

{[ ['JSON / YAML / table / CSV', 'Explicit output format, or auto-JSON in agent and CI contexts.'], - ['Errors to stderr', 'Status and diagnostics on stderr, structured results on stdout.'], + ['Errors to stderr', 'Core commands keep status on stderr and results on stdout.'], ['Exit codes everywhere', 'Non-zero on error. Works in set -e scripts.'], ].map(([label, desc]) => (
From 5199077e6275fc624cb624f146550fb80822ac8b Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 20 Sep 2026 10:49:40 -0400 Subject: [PATCH 09/17] test(output): pin the ffprobe, debug-audio and closed-stream channels The channel fixes shipped without tests. Fourteen new cases, each confirmed to fail against the pre-fix code: - `ffprobe` and `debug audio`: errors and failure panels land on stderr with stdout empty in every machine-readable format (json, yaml, csv, table), the human rendering is suppressed in those formats, and it still prints in default mode. - the auth-failure payload survives a closed stdout: `BrokenPipeError` and `ValueError("I/O operation on closed file")` still exit 1, any other `ValueError` surfaces. Those three `except` branches had no coverage before. Co-Authored-By: Claude Opus 5 --- .../tests/unit/test_audio_command.py | 102 ++++++++++++++++++ .../tests/unit/test_ffprobe_command.py | 77 +++++++++++++ .../tests/unit/test_output_channels.py | 57 ++++++++++ 3 files changed, 236 insertions(+) diff --git a/packages/deepctl-cmd-debug-audio/tests/unit/test_audio_command.py b/packages/deepctl-cmd-debug-audio/tests/unit/test_audio_command.py index 8599e4d..bfd40a2 100644 --- a/packages/deepctl-cmd-debug-audio/tests/unit/test_audio_command.py +++ b/packages/deepctl-cmd-debug-audio/tests/unit/test_audio_command.py @@ -244,3 +244,105 @@ def test_deepgram_compatibility_checks(self, command, sample_probe_data): audio_info = command.parse_audio_info(multi_channel_data) assert audio_info.streams[0].channels > 2 + + +class TestOutputChannels: + """Which stream each line lands on (#104). + + `dg -o json debug audio -f missing.wav` used to put a rich failure panel + on stdout ahead of the serialized AudioDebugResult, so `json.loads(stdout)` + raised. Progress lines and failure panels belong on stderr always; the + human rendering of the analysis belongs on stdout only in default mode. + """ + + @pytest.fixture + def command(self): + return AudioCommand() + + @pytest.fixture + def mocks(self): + return Mock(spec=Config), Mock(spec=AuthManager), Mock(spec=DeepgramClient) + + @pytest.fixture + def probe_data(self): + return { + "format": { + "filename": "test.mp3", + "format_name": "mp3", + "format_long_name": "MP2/3 (MPEG audio layer 2/3)", + "duration": "120.456", + "size": "2890752", + "bit_rate": "192000", + "nb_streams": 1, + }, + "streams": [ + { + "codec_type": "audio", + "codec_name": "mp3", + "codec_long_name": "MP3 (MPEG audio layer 3)", + "sample_rate": "44100", + "channels": 2, + "channel_layout": "stereo", + } + ], + } + + @pytest.mark.parametrize("fmt", ["json", "yaml", "csv", "table"]) + @patch("deepctl_cmd_debug_audio.command.get_output_format") + @patch.object(AudioCommand, "check_ffmpeg_installed", return_value=False) + def test_ffmpeg_missing_panel_goes_to_stderr( + self, _ffmpeg, mock_format, fmt, command, mocks, capsys + ): + mock_format.return_value = fmt + + result = command.handle(*mocks, file="recording.wav") + captured = capsys.readouterr() + + assert result.status == "error" + assert captured.out == "" + assert "FFmpeg" in captured.err + + @patch("deepctl_cmd_debug_audio.command.get_output_format", return_value="json") + @patch.object(AudioCommand, "check_ffmpeg_installed", return_value=True) + @patch.object(AudioCommand, "run_ffprobe", side_effect=RuntimeError("boom")) + def test_analysis_failure_panel_goes_to_stderr( + self, _probe, _ffmpeg, _format, command, mocks, capsys + ): + result = command.handle(*mocks, file="missing.wav") + captured = capsys.readouterr() + + assert result.status == "error" + assert captured.out == "" + assert "Analyzing audio file" in captured.err + assert "boom" in captured.err + + @patch("deepctl_cmd_debug_audio.command.get_output_format", return_value="json") + @patch.object(AudioCommand, "check_ffmpeg_installed", return_value=True) + def test_human_rendering_is_suppressed_for_machines( + self, _ffmpeg, _format, command, mocks, probe_data, capsys + ): + with patch.object( + AudioCommand, "run_ffprobe", return_value=probe_data + ): + result = command.handle(*mocks, file="test.mp3") + captured = capsys.readouterr() + + assert result.status == "success" + assert captured.out == "" + assert "Analyzing audio file" in captured.err + + @patch( + "deepctl_cmd_debug_audio.command.get_output_format", return_value="default" + ) + @patch.object(AudioCommand, "check_ffmpeg_installed", return_value=True) + def test_human_rendering_still_prints_for_humans( + self, _ffmpeg, _format, command, mocks, probe_data, capsys + ): + with patch.object( + AudioCommand, "run_ffprobe", return_value=probe_data + ): + command.handle(*mocks, file="test.mp3") + captured = capsys.readouterr() + + assert "Audio File Analysis Complete" in captured.out + assert "Deepgram Compatibility Check" in captured.out diff --git a/packages/deepctl-cmd-ffprobe/tests/unit/test_ffprobe_command.py b/packages/deepctl-cmd-ffprobe/tests/unit/test_ffprobe_command.py index 06d33fd..a9c93c8 100644 --- a/packages/deepctl-cmd-ffprobe/tests/unit/test_ffprobe_command.py +++ b/packages/deepctl-cmd-ffprobe/tests/unit/test_ffprobe_command.py @@ -129,3 +129,80 @@ def test_status_not_available( assert isinstance(result, FfprobeResult) assert result.available is False mock_print.assert_called_once() + + +class TestOutputChannels: + """Which stream each line lands on (#104). + + `dg -o json ffprobe` used to put the human status lines on stdout ahead + of the serialized FfprobeResult, so `json.loads(stdout)` raised. Errors + belong on stderr always; the human rendering belongs on stdout only in + default mode. + """ + + def setup_method(self): + self.cmd = FfprobeCommand() + self.config = Mock() + self.config._config = Mock() + self.config._config.tools = Mock() + self.config._config.tools.ffprobe_path = None + self.config.get.return_value = None + self.auth = Mock() + self.client = Mock() + + @pytest.mark.parametrize("fmt", ["json", "yaml", "csv", "table"]) + @patch("deepctl_cmd_ffprobe.command.get_output_format") + def test_errors_go_to_stderr_leaving_stdout_empty( + self, mock_format, fmt, capsys + ): + mock_format.return_value = fmt + + result = self.cmd.handle( + self.config, self.auth, self.client, path="/nonexistent" + ) + captured = capsys.readouterr() + + assert result.status == "error" + assert captured.out == "" + assert "File not found" in captured.err + + @patch("deepctl_cmd_ffprobe.command.get_output_format", return_value="default") + def test_errors_still_reach_a_human_on_stderr(self, mock_format, capsys): + self.cmd.handle(self.config, self.auth, self.client, path="/nonexistent") + captured = capsys.readouterr() + + assert captured.out == "" + assert "File not found" in captured.err + + @patch("deepctl_cmd_ffprobe.command.get_output_format", return_value="json") + @patch("deepctl_cmd_ffprobe.command.get_ffprobe_path") + @patch("deepctl_cmd_ffprobe.command.shutil.which") + @patch("deepctl_cmd_ffprobe.command.subprocess.run") + def test_status_summary_is_suppressed_for_machines( + self, mock_run, mock_which, mock_get_path, mock_format, capsys + ): + mock_which.return_value = "/usr/bin/ffprobe" + mock_get_path.return_value = "/usr/bin/ffprobe" + mock_run.return_value = Mock(returncode=0, stdout="ffprobe version 6.0\n") + + result = self.cmd.handle(self.config, self.auth, self.client) + captured = capsys.readouterr() + + assert result.available is True + assert captured.out == "" + + @patch("deepctl_cmd_ffprobe.command.get_output_format", return_value="default") + @patch("deepctl_cmd_ffprobe.command.get_ffprobe_path") + @patch("deepctl_cmd_ffprobe.command.shutil.which") + @patch("deepctl_cmd_ffprobe.command.subprocess.run") + def test_status_summary_still_prints_for_humans( + self, mock_run, mock_which, mock_get_path, mock_format, capsys + ): + mock_which.return_value = "/usr/bin/ffprobe" + mock_get_path.return_value = "/usr/bin/ffprobe" + mock_run.return_value = Mock(returncode=0, stdout="ffprobe version 6.0\n") + + self.cmd.handle(self.config, self.auth, self.client) + captured = capsys.readouterr() + + assert "ffprobe is available" in captured.out diff --git a/packages/deepctl-core/tests/unit/test_output_channels.py b/packages/deepctl-core/tests/unit/test_output_channels.py index c1e8988..3c0d8fa 100644 --- a/packages/deepctl-core/tests/unit/test_output_channels.py +++ b/packages/deepctl-core/tests/unit/test_output_channels.py @@ -230,3 +230,60 @@ def test_project_id_line_also_goes_to_stderr(self, capsys) -> None: assert payload["status"] == "success" assert "Affecting project: abc-123" in captured.err assert "abc-123" not in captured.out + + +class TestAuthFailurePayloadOnAClosedStream: + """The failure payload must not turn a closed pipe into a crash. + + `dg -o json … | head -1` closes stdout early. Writing the failure payload + then raises either `BrokenPipeError` (an `OSError`) or, from rich, + `ValueError("I/O operation on closed file")`. Both are swallowed so the + command still exits 1; any other `ValueError` is a real bug and must + surface. + """ + + def _run(self, output_error): + from unittest.mock import MagicMock, patch + + import click + from deepctl_core.auth import AuthenticationError + from deepctl_core.base_command import BaseCommand + from deepctl_core.config import Config + + class NeedsAuth(BaseCommand): + name = "needs-auth" + help = "test command" + requires_auth = True + + def handle(self, config, auth_manager, client, **kwargs): # type: ignore[no-untyped-def] + raise AssertionError("handle must not run when guard() fails") + + command = NeedsAuth() + ctx = MagicMock(spec=click.Context) + ctx.obj = {"config": Config()} + + auth_manager = MagicMock() + auth_manager.guard.side_effect = AuthenticationError("bad key") + + with ( + patch( + "deepctl_core.base_command.AuthManager", return_value=auth_manager + ), + patch("deepctl_core.base_command.DeepgramClient"), + patch.object(NeedsAuth, "output_result", side_effect=output_error), + ): + return command.execute(ctx) + + def test_broken_pipe_still_exits_one(self) -> None: + with pytest.raises(SystemExit) as exc: + self._run(BrokenPipeError(32, "Broken pipe")) + assert exc.value.code == 1 + + def test_closed_file_value_error_still_exits_one(self) -> None: + with pytest.raises(SystemExit) as exc: + self._run(ValueError("I/O operation on closed file")) + assert exc.value.code == 1 + + def test_any_other_value_error_surfaces(self) -> None: + with pytest.raises(ValueError, match="something else"): + self._run(ValueError("something else")) From 9ef3b5850b4e124a9c29bc744c0e575e2cec28e7 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 20 Sep 2026 10:49:41 -0400 Subject: [PATCH 10/17] fix(docs): correct 11 advertised commands the CLI rejects, and guard them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running every `dg …` string the README and the two `llms*.txt` files advertise through the real parser found 11 that do not resolve. The `examples`-array guard added earlier on this branch never reached these files, so #105's sweep stopped at `--help`: - `-o` is a *global* flag and must precede the subcommand, but seven places advertised it trailing (`dg projects --list -o json`, `dg listen call.mp3 -o json | jq …`) or under a name that does not exist (`dg listen --output json`). All seven fail with `Error: No such option '-o'` — the same instruction this PR's own stdout/stderr copy tells people to run. - `llms-full.txt` still advertised `dg usage --start/--end`, the exact option pair #105 fixed in `--help`. - `dg read --detect-topics --detect-entities` → the real flags are `--topics` and `--intents`. - `dg debug audio recording.wav` → the file is `-f recording.wav`. - `llms.txt` advertised `dg --agent-friendly` as a top-level flag; it is per-command. - `llms-full.txt` listed `--project-id` as a global option; there is no such global option. Telemetry opt-out, same class: the README and the notice appended to `dg --help` both said `dg config set telemetry.enabled false`. There is no `dg config` command, so following either left telemetry on. Both now name the two opt-outs that exist — `DEEPCTL_TELEMETRY_DISABLED=1` for one run, `telemetry.enabled: false` in `config.yaml` to persist — with the config path given per platform. Shipping a real `dg config` command instead is a product call, not a docs fix. `test_command_examples.py` now parses the ~190 command strings those three files advertise, one test each, alongside the 127 from `examples` arrays. Placeholders (`dg ... -o json`, `dg keys --delete KEY_ID`) are skipped through a spelled-out pattern rather than dropped silently, and a per-file discovery guard fails if the extractor goes stale. `_dg_invocations` also stops discarding env-prefixed snippets, so `CI=1 dg listen recording.wav` is checked too. Co-Authored-By: Claude Opus 5 --- README.md | 16 ++- .../src/deepctl_telemetry/notice.py | 5 +- .../tests/unit/test_telemetry.py | 7 +- tests/unit/test_command_examples.py | 112 ++++++++++++++++++ web/public/llms-full.txt | 17 ++- web/public/llms.txt | 4 +- 6 files changed, 142 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e5b61a9..099c18e 100644 --- a/README.md +++ b/README.md @@ -311,9 +311,9 @@ dg keys --delete KEY_ID --yes dg read --file report.txt --summarize # Output formats for scripting -dg projects --list -o json -dg keys --list -o csv -dg usage --last-week -o yaml +dg -o json projects --list +dg -o csv keys --list +dg -o yaml usage --last-week ``` In CI, in AI coding tools, and in any fully non-interactive environment with @@ -407,10 +407,14 @@ The CLI phones home anonymous error reports to help us catch crashes and regress ### Opt out -Persistent (recommended): +Persistent (recommended) — add this to your `config.yaml` +(`~/.config/deepctl/config.yaml` on Linux, +`~/Library/Application Support/deepctl/config.yaml` on macOS, +`%LOCALAPPDATA%\\deepgram\\deepctl\\config.yaml` on Windows): -```bash -dg config set telemetry.enabled false +```yaml +telemetry: + enabled: false ``` One-shot (CI, scripts, single command): diff --git a/packages/deepctl-telemetry/src/deepctl_telemetry/notice.py b/packages/deepctl-telemetry/src/deepctl_telemetry/notice.py index 4d99df8..0198a36 100644 --- a/packages/deepctl-telemetry/src/deepctl_telemetry/notice.py +++ b/packages/deepctl-telemetry/src/deepctl_telemetry/notice.py @@ -10,9 +10,12 @@ from deepctl_core import Config +# There is no `dg config` command, so the notice names the two opt-outs that +# actually exist: the env var for one run, and the config file to persist it. NOTICE_ON = ( "Telemetry is on (anonymous error reports). " - "Disable: dg config set telemetry.enabled false" + "Disable: DEEPCTL_TELEMETRY_DISABLED=1, " + "or set telemetry.enabled: false in your deepctl config.yaml" ) NOTICE_OFF = "Telemetry is off." diff --git a/packages/deepctl-telemetry/tests/unit/test_telemetry.py b/packages/deepctl-telemetry/tests/unit/test_telemetry.py index 8eb57f1..3233e73 100644 --- a/packages/deepctl-telemetry/tests/unit/test_telemetry.py +++ b/packages/deepctl-telemetry/tests/unit/test_telemetry.py @@ -31,7 +31,12 @@ class TestRenderNotice: def test_on_message(self) -> None: notice = render_notice(_config(True)) assert "Telemetry is on" in notice - assert "telemetry.enabled false" in notice + # The notice must name opt-outs that exist. It used to say + # `dg config set telemetry.enabled false`; there is no `dg config` + # command, so it names the env var and the config-file key instead. + assert "DEEPCTL_TELEMETRY_DISABLED=1" in notice + assert "telemetry.enabled: false" in notice + assert "dg config" not in notice def test_off_message(self) -> None: notice = render_notice(_config(False)) diff --git a/tests/unit/test_command_examples.py b/tests/unit/test_command_examples.py index b00ed8f..8a7cada 100644 --- a/tests/unit/test_command_examples.py +++ b/tests/unit/test_command_examples.py @@ -20,6 +20,7 @@ import re import shlex from importlib import metadata +from pathlib import Path import click import pytest @@ -55,6 +56,8 @@ def _is_dynamic(argv: list[str]) -> bool: return False +ENV_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") + # `$(...)` or `` `...` `` -- non-nested, which is all our examples use. SUBSTITUTION = re.compile(r"\$\(([^()]*)\)|`([^`]*)`") @@ -100,6 +103,10 @@ def _dg_invocations(example: str) -> list[list[str]]: argv = shlex.split(segment, comments=True) except ValueError: continue + # `CI=1 dg listen x.wav` and `DEEPCTL_TELEMETRY_DISABLED=1 dg …` + # are advertised too; the assignments are shell, the rest is ours. + while argv and ENV_ASSIGNMENT.match(argv[0]): + argv = argv[1:] if argv and argv[0] in BINARY_NAMES: invocations.append(argv[1:]) return invocations @@ -183,3 +190,108 @@ def test_example_parses( "Fix the example, or add the option/subcommand it promises. This " "array is also what --agent-friendly emits." ) + + +# --------------------------------------------------------------------------- +# The same guarantee, for the commands advertised in developer-facing docs. +# +# The `examples` arrays are not the only place the CLI tells people how to run +# it. The README and the two `llms*.txt` files agents read carry ~190 more +# command strings, and the #105 sweep did not reach them: `llms-full.txt` still +# advertised `dg usage --start/--end`, the exact option pair #105 fixed in +# `--help`, plus a `-o json` placement the CLI rejects (`-o` is a global flag +# and must precede the subcommand). +# --------------------------------------------------------------------------- + +REPO_ROOT = Path(__file__).resolve().parents[2] + +DOC_FILES = [ + "README.md", + "web/public/llms.txt", + "web/public/llms-full.txt", +] + +# Templates, not runnable commands: `dg ... -o json | jq`, `dg +# --agent-friendly`, `dg keys --delete KEY_ID`, `dg login --api-key SK`. +PLACEHOLDER = re.compile(r"\.\.\.|[<>{}]|YOUR_|\bKEY_ID\b|\bSK\b") + +# Lines that start a shell snippet we own. +SNIPPET_START = ("dg ", "deepctl ", "dg\t", "eval ") + + +def _doc_commands(path: str) -> list[tuple[int, str]]: + """(line number, command string) for every `dg …` a doc file advertises. + + Two sources, because both are read as instructions: lines inside fenced + code blocks, and inline `code spans` in prose. Non-ASCII candidates are + prose, not commands (the README banner caption is `deepctl \u2014 Official + Deepgram CLI \u2026`), and templates carrying a placeholder are skipped -- + with the placeholder set spelled out above rather than dropped silently. + """ + found: list[tuple[int, str]] = [] + fenced = False + text = (REPO_ROOT / path).read_text(encoding="utf-8") + for lineno, line in enumerate(text.splitlines(), 1): + if line.strip().startswith("```"): + fenced = not fenced + continue + candidates = [] + stripped = line.strip() + if fenced and ( + stripped.startswith(SNIPPET_START) + or (ENV_ASSIGNMENT.match(stripped) and " dg " in stripped) + ): + candidates.append(stripped) + candidates += [ + span + for span in re.findall(r"`([^`]+)`", line) + if span.startswith(("dg ", "deepctl ")) + ] + for candidate in candidates: + if PLACEHOLDER.search(candidate) or not candidate.isascii(): + continue + found.append((lineno, candidate)) + return found + + +DOC_CASES = [ + (path, lineno, command) + for path in DOC_FILES + for lineno, command in _doc_commands(path) +] + + +@pytest.mark.parametrize("path", DOC_FILES) +def test_doc_commands_were_discovered(path: str) -> None: + """A docs refactor that stops matching must fail rather than pass empty.""" + assert [case for case in DOC_CASES if case[0] == path], ( + f"no `dg ...` commands found in {path} -- the extractor is stale, so " + "this file is testing nothing" + ) + + +@pytest.mark.parametrize( + ("path", "lineno", "command"), + DOC_CASES, + ids=[f"{path}:{lineno}" for path, lineno, _ in DOC_CASES], +) +def test_doc_command_parses(path: str, lineno: int, command: str) -> None: + """Every command a doc advertises must resolve against the real CLI.""" + from deepctl.main import cli + + for argv in _dg_invocations(command): + try: + _parse(cli, argv) + except (SystemExit, click.exceptions.Exit): + # An eager option such as --help short-circuits; it parsed fine. + pass + except (click.exceptions.NoArgsIsHelpError, click.MissingParameter): + # The docs list bare command names (`dg debug`, `dg plugin`) in + # tables. Those resolve; they just need arguments to run. + pass + except click.ClickException as exc: + pytest.fail( + f"{path}:{lineno} advertises `{command}`, which does not " + f"parse: {type(exc).__name__}: {exc}\n" + "Fix the doc, or add the option/subcommand it promises." + ) diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index fe4205c..5e72673 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -77,7 +77,7 @@ dg listen recording.wav dg listen podcast.mp3 --model nova-3 dg listen interview.mp4 --diarize --language en-US dg listen meeting.wav --summarize --topics -dg listen call.mp3 --output json --save-to transcript.json +dg -o json listen call.mp3 --save-to transcript.json ``` ### Transcribe a URL @@ -168,7 +168,7 @@ dg speak --file script.txt # From file ```bash dg read "The product exceeded expectations" --sentiment -dg read --file article.txt --summarize --detect-topics --detect-entities +dg read --file article.txt --summarize --topics --intents echo "Meeting notes..." | dg read --summarize ``` @@ -215,7 +215,7 @@ dg members --revoke-invite user@example.com ```bash dg usage # Usage summary -dg usage --start 2024-01-01 --end 2024-01-31 +dg usage --start-date 2024-01-01 --end-date 2024-01-31 dg billing # Account balance dg requests # Recent API requests ``` @@ -286,7 +286,7 @@ Plugins install into an isolated venv at `~/.deepctl/plugins/venv/`. ## Debug Tools ```bash -dg debug audio recording.wav # Analyze audio file (codec, bitrate, compatibility) +dg debug audio -f recording.wav # Analyze audio file (codec, bitrate, compatibility) dg debug network # Test connectivity to Deepgram API dg debug browser # Check browser/WebRTC capabilities dg debug probe # Analyze WebSocket stream @@ -306,7 +306,6 @@ All commands accept these global flags: | `--profile`, `-p` | Use named credential profile | | `--config`, `-c` | Path to config file | | `--api-key` | Explicit API key (overrides profile) | -| `--project-id` | Explicit project ID | | `--timing` | Show performance timing | | `--agent-friendly` | Output JSON metadata for this command and exit | @@ -376,19 +375,19 @@ Environment variables: ```bash # Transcribe and extract text with jq -dg listen call.mp3 -o json | jq '.results.channels[0].alternatives[0].transcript' +dg -o json listen call.mp3 | jq '.results.channels[0].alternatives[0].transcript' # Get all speaker segments -dg listen interview.mp3 --diarize -o json | jq '.results.channels[0].alternatives[0].words[] | select(.speaker) | {speaker, word}' +dg -o json listen interview.mp3 --diarize | jq '.results.channels[0].alternatives[0].words[] | select(.speaker) | {speaker, word}' # TTS piped to audio player dg speak "Hello from Deepgram" | ffplay -nodisp -autoexit - # Stream mic and log all transcripts -dg listen --mic -o json | tee transcripts.jsonl +dg -o json listen --mic | tee transcripts.jsonl # Create a time-limited API key for CI -dg keys --create --comment "CI/CD" --ttl 3600 -o json | jq -r '.created_key.key' +dg -o json keys --create --comment "CI/CD" --ttl 3600 | jq -r '.created_key.key' ``` --- diff --git a/web/public/llms.txt b/web/public/llms.txt index fd882e5..ac372ef 100644 --- a/web/public/llms.txt +++ b/web/public/llms.txt @@ -27,12 +27,12 @@ - `dg api ` — Direct authenticated HTTP proxy to any Deepgram endpoint - `dg whoami` — Show current auth status, profile, and key source - `dg completion bash|zsh|fish` — Generate shell completion scripts -- `dg --agent-friendly` — Output machine-readable JSON metadata for any command +- `dg --agent-friendly` — Output machine-readable JSON metadata for that command ## Key Features - Auto-detects AI agent context (Claude Code, Aider, Codex) and fully non-interactive environments — disables prompts, outputs JSON, routes status to stderr -- Multiple output formats: `--output json|yaml|table|csv` +- Multiple output formats: `dg -o json|yaml|table|csv ` (global flag, goes before the command) - Named credential profiles for multi-environment workflows - Plugin system: `dg plugin install ` installs community extensions in isolated venv - `--dry-run` on all destructive operations (key deletion, member removal) From 134c1e4ab4d4c60b670efacbed7ed8877e87b74e Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 20 Sep 2026 10:53:30 -0400 Subject: [PATCH 11/17] fix(docs): describe flag position accurately in llms-full.txt The "Global Options" table said "All commands accept these global flags", which reads as "usable after the command". They are not: `dg listen -o json` fails with `No such option '-o'`. The heading now says where they go and shows one, and the table gains `--base-url`, `--timing-detailed` and `--non-interactive`, which were missing. `--agent-friendly` and `--project-id` were listed as global and are not. They move to a per-command table, with `--project-id` naming the six commands that define it (billing, keys, login, members, requests, usage). Every position in both tables was verified against the CLI. Co-Authored-By: Claude Opus 5 --- web/public/llms-full.txt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index 5e72673..f72379b 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -296,7 +296,8 @@ dg debug probe # Analyze WebSocket stream ## Global Options -All commands accept these global flags: +These go on `dg` itself, before the command: `dg -o json listen call.mp3`. +Putting them after the command fails with `No such option`. | Flag | Description | |------|-------------| @@ -305,9 +306,18 @@ All commands accept these global flags: | `--verbose`, `-v` | Enable verbose/debug output | | `--profile`, `-p` | Use named credential profile | | `--config`, `-c` | Path to config file | +| `--base-url` | Override the API base URL | | `--api-key` | Explicit API key (overrides profile) | -| `--timing` | Show performance timing | -| `--agent-friendly` | Output JSON metadata for this command and exit | +| `--timing`, `--timing-detailed` | Show performance timing | +| `--non-interactive` | Skip prompts; use defaults | + +Per-command flags, which go after the command: + +| Flag | Description | +|------|-------------| +| `--agent-friendly` | Output JSON metadata for that command and exit | +| `--non-interactive` | Also accepted here, so either position works | +| `--project-id`, `-p` | Explicit project ID (billing, keys, login, members, requests, usage) | --- From 125ff142c2d7f3da7eb5c2f69385bd3ca31d05b6 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 20 Sep 2026 14:54:43 +0000 Subject: [PATCH 12/17] fix(docs): render the Windows config path as one backslash each Markdown does not process backslash escapes inside a code span, so the doubled backslashes showed up literally as `%LOCALAPPDATA%\\deepgram\\deepctl\\config.yaml`. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 099c18e..ecb5ff1 100644 --- a/README.md +++ b/README.md @@ -410,7 +410,7 @@ The CLI phones home anonymous error reports to help us catch crashes and regress Persistent (recommended) — add this to your `config.yaml` (`~/.config/deepctl/config.yaml` on Linux, `~/Library/Application Support/deepctl/config.yaml` on macOS, -`%LOCALAPPDATA%\\deepgram\\deepctl\\config.yaml` on Windows): +`%LOCALAPPDATA%\deepgram\deepctl\config.yaml` on Windows): ```yaml telemetry: From cd9959ad85531d2a4b18246b70fc3a0f4819ed3e Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 23 Sep 2026 14:34:52 +0100 Subject: [PATCH 13/17] fix(docs): clarify trailing output flags --- README.md | 6 +++--- tests/unit/test_command_examples.py | 16 ++++++++++++---- web/public/llms-full.txt | 4 ++-- web/public/llms.txt | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ecb5ff1..b0e6702 100644 --- a/README.md +++ b/README.md @@ -311,9 +311,9 @@ dg keys --delete KEY_ID --yes dg read --file report.txt --summarize # Output formats for scripting -dg -o json projects --list -dg -o csv keys --list -dg -o yaml usage --last-week +dg projects --list -o json +dg keys --list -o csv +dg usage --last-week -o yaml ``` In CI, in AI coding tools, and in any fully non-interactive environment with diff --git a/tests/unit/test_command_examples.py b/tests/unit/test_command_examples.py index 8a7cada..45de904 100644 --- a/tests/unit/test_command_examples.py +++ b/tests/unit/test_command_examples.py @@ -199,8 +199,7 @@ def test_example_parses( # it. The README and the two `llms*.txt` files agents read carry ~190 more # command strings, and the #105 sweep did not reach them: `llms-full.txt` still # advertised `dg usage --start/--end`, the exact option pair #105 fixed in -# `--help`, plus a `-o json` placement the CLI rejects (`-o` is a global flag -# and must precede the subcommand). +# `--help`, plus stale command names and options. # --------------------------------------------------------------------------- REPO_ROOT = Path(__file__).resolve().parents[2] @@ -213,6 +212,9 @@ def test_example_parses( # Templates, not runnable commands: `dg ... -o json | jq`, `dg # --agent-friendly`, `dg keys --delete KEY_ID`, `dg login --api-key SK`. +# Apply this to the extracted CLI argv rather than a whole shell snippet: a +# downstream jq filter may contain `{}` while the preceding `dg` invocation is +# still a valid command that we must validate. PLACEHOLDER = re.compile(r"\.\.\.|[<>{}]|YOUR_|\bKEY_ID\b|\bSK\b") # Lines that start a shell snippet we own. @@ -248,9 +250,13 @@ def _doc_commands(path: str) -> list[tuple[int, str]]: if span.startswith(("dg ", "deepctl ")) ] for candidate in candidates: - if PLACEHOLDER.search(candidate) or not candidate.isascii(): + if not candidate.isascii(): continue - found.append((lineno, candidate)) + if any( + not PLACEHOLDER.search(" ".join(argv)) + for argv in _dg_invocations(candidate) + ): + found.append((lineno, candidate)) return found @@ -280,6 +286,8 @@ def test_doc_command_parses(path: str, lineno: int, command: str) -> None: from deepctl.main import cli for argv in _dg_invocations(command): + if PLACEHOLDER.search(" ".join(argv)): + continue try: _parse(cli, argv) except (SystemExit, click.exceptions.Exit): diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index f72379b..6a14013 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -296,8 +296,8 @@ dg debug probe # Analyze WebSocket stream ## Global Options -These go on `dg` itself, before the command: `dg -o json listen call.mp3`. -Putting them after the command fails with `No such option`. +Most of these go on `dg` itself, before the command: `dg --profile staging listen call.mp3`. +`--output`/`-o`, `--quiet`/`-q`, `--verbose`/`-v`, and `--non-interactive` are also accepted after a leaf command, so `dg listen call.mp3 -o json` works. The remaining options fail after a leaf command; command groups such as `dg debug` reject every trailing global option. | Flag | Description | |------|-------------| diff --git a/web/public/llms.txt b/web/public/llms.txt index ac372ef..6ce47f6 100644 --- a/web/public/llms.txt +++ b/web/public/llms.txt @@ -32,7 +32,7 @@ ## Key Features - Auto-detects AI agent context (Claude Code, Aider, Codex) and fully non-interactive environments — disables prompts, outputs JSON, routes status to stderr -- Multiple output formats: `dg -o json|yaml|table|csv ` (global flag, goes before the command) +- Multiple output formats: `dg -o json|yaml|table|csv ` (before the command or after a leaf command) - Named credential profiles for multi-environment workflows - Plugin system: `dg plugin install ` installs community extensions in isolated venv - `--dry-run` on all destructive operations (key deletion, member removal) From c944c455e7512b75823ea7c70bedce36f9707825 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 23 Sep 2026 16:43:01 +0100 Subject: [PATCH 14/17] fix(docs): qualify trailing output flags --- README.md | 2 +- web/public/llms-full.txt | 2 +- web/public/llms.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b0e6702..8cffcc5 100644 --- a/README.md +++ b/README.md @@ -355,7 +355,7 @@ Three explicit ways to skip every prompt and run with defaults — useful from a real terminal where auto-detection wouldn't otherwise trigger: ```bash -# Global flag (works at any position) +# Global flag (before any command, or after a leaf command) dg --non-interactive listen recording.wav dg listen --non-interactive recording.wav diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index 6a14013..f587379 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -297,7 +297,7 @@ dg debug probe # Analyze WebSocket stream ## Global Options Most of these go on `dg` itself, before the command: `dg --profile staging listen call.mp3`. -`--output`/`-o`, `--quiet`/`-q`, `--verbose`/`-v`, and `--non-interactive` are also accepted after a leaf command, so `dg listen call.mp3 -o json` works. The remaining options fail after a leaf command; command groups such as `dg debug` reject every trailing global option. +`--quiet`/`-q`, `--verbose`/`-v`, and `--non-interactive` are also accepted after a leaf command. `--output`/`-o` can follow a leaf command that does not define its own output option, so `dg listen call.mp3 -o json` works. Speak owns `-o` as an audio-file path; use `dg -o json speak ...` for structured Speak output. The remaining options fail after a leaf command; command groups such as `dg debug` reject every trailing global option. | Flag | Description | |------|-------------| diff --git a/web/public/llms.txt b/web/public/llms.txt index 6ce47f6..c6a0a80 100644 --- a/web/public/llms.txt +++ b/web/public/llms.txt @@ -32,7 +32,7 @@ ## Key Features - Auto-detects AI agent context (Claude Code, Aider, Codex) and fully non-interactive environments — disables prompts, outputs JSON, routes status to stderr -- Multiple output formats: `dg -o json|yaml|table|csv ` (before the command or after a leaf command) +- Multiple output formats: `dg -o json|yaml|table|csv ` (before the command or after a leaf command without its own output option; Speak's `-o` is an audio-file path) - Named credential profiles for multi-environment workflows - Plugin system: `dg plugin install ` installs community extensions in isolated venv - `--dry-run` on all destructive operations (key deletion, member removal) From 6ec06688822d5dae91bbd39c4e70b0f9ec0383ef Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 23 Sep 2026 16:55:03 +0100 Subject: [PATCH 15/17] fix(output): keep shared diagnostics plain in CI --- .../src/deepctl_shared_utils/diagnostics.py | 52 +++++++++++++++++++ .../src/deepctl_shared_utils/ffprobe.py | 8 +-- .../src/deepctl_shared_utils/validation.py | 8 +-- .../tests/unit/test_diagnostics.py | 16 ++++++ 4 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 packages/deepctl-shared-utils/src/deepctl_shared_utils/diagnostics.py create mode 100644 packages/deepctl-shared-utils/tests/unit/test_diagnostics.py diff --git a/packages/deepctl-shared-utils/src/deepctl_shared_utils/diagnostics.py b/packages/deepctl-shared-utils/src/deepctl_shared_utils/diagnostics.py new file mode 100644 index 0000000..e01979f --- /dev/null +++ b/packages/deepctl-shared-utils/src/deepctl_shared_utils/diagnostics.py @@ -0,0 +1,52 @@ +"""Plain-text diagnostic output shared by utilities outside deepctl-core.""" + +import os +import sys +from typing import TextIO + +from rich.console import Console + + +def is_non_interactive() -> bool: + """Mirror deepctl-core's agent and non-interactive output policy. + + Shared utilities intentionally do not depend on deepctl-core, so keep this + lightweight detection aligned with ``deepctl_core.output.is_agentic``. + """ + env = os.environ + + if "--non-interactive" in sys.argv or "--agent-friendly" in sys.argv: + return True + if env.get("CI") in ("true", "1"): + return True + if env.get("CLAUDECODE") or env.get("CLAUDE_CODE_ENTRYPOINT"): + return True + if env.get("CODEX_SANDBOX") or env.get("CODEX_SANDBOX_NETWORK_DISABLED"): + return True + if env.get("OR_APP_NAME") == "Aider" or "aider" in env.get("OR_SITE_URL", ""): + return True + + score = 0 + if not sys.stdin.isatty(): + score += 1 + if not sys.stdout.isatty(): + score += 1 + if not env.get("TERM") or env.get("TERM") == "dumb": + score += 1 + if "NO_COLOR" in env: + score += 1 + + return score >= 3 + + +def create_diagnostic_console( + *, file: TextIO | None = None, force_terminal: bool | None = None +) -> Console: + """Create a stderr diagnostic console with the shared plain-text policy.""" + non_interactive = is_non_interactive() + return Console( + file=file or sys.stderr, + force_terminal=force_terminal, + no_color=non_interactive, + highlight=not non_interactive, + ) diff --git a/packages/deepctl-shared-utils/src/deepctl_shared_utils/ffprobe.py b/packages/deepctl-shared-utils/src/deepctl_shared_utils/ffprobe.py index ef70250..14b4af4 100644 --- a/packages/deepctl-shared-utils/src/deepctl_shared_utils/ffprobe.py +++ b/packages/deepctl-shared-utils/src/deepctl_shared_utils/ffprobe.py @@ -10,9 +10,9 @@ import tempfile from typing import TYPE_CHECKING -from rich.console import Console from rich.panel import Panel +from .diagnostics import create_diagnostic_console from .ffprobe_models import AudioFormatInfo, AudioProbeResult, AudioStreamInfo if TYPE_CHECKING: @@ -21,9 +21,9 @@ # Diagnostics only: every console.print below is an error, a warning or a # progress line -- never a payload -- so it goes to stderr and can never # corrupt the machine-readable result a command writes to stdout (#104). -# deepctl-shared-utils does not depend on deepctl-core, so this is a local -# stderr Console rather than an import of core's shared `stderr_console`. -console = Console(stderr=True) +# deepctl-shared-utils does not depend on deepctl-core, so this local factory +# mirrors core's agentic/no-color policy instead of importing `stderr_console`. +console = create_diagnostic_console() def get_ffprobe_path(config: Config | None = None) -> str | None: diff --git a/packages/deepctl-shared-utils/src/deepctl_shared_utils/validation.py b/packages/deepctl-shared-utils/src/deepctl_shared_utils/validation.py index 16ddec0..bcb33fc 100644 --- a/packages/deepctl-shared-utils/src/deepctl_shared_utils/validation.py +++ b/packages/deepctl-shared-utils/src/deepctl_shared_utils/validation.py @@ -6,16 +6,16 @@ from urllib.parse import urlparse import httpx -from rich.console import Console +from .diagnostics import create_diagnostic_console from .models import FileInfo # Diagnostics only: every console.print below is an error, a warning or a # progress line -- never a payload -- so it goes to stderr and can never # corrupt the machine-readable result a command writes to stdout (#104). -# deepctl-shared-utils does not depend on deepctl-core, so this is a local -# stderr Console rather than an import of core's shared `stderr_console`. -console = Console(stderr=True) +# deepctl-shared-utils does not depend on deepctl-core, so this local factory +# mirrors core's agentic/no-color policy instead of importing `stderr_console`. +console = create_diagnostic_console() # Supported audio file extensions SUPPORTED_AUDIO_EXTENSIONS = { diff --git a/packages/deepctl-shared-utils/tests/unit/test_diagnostics.py b/packages/deepctl-shared-utils/tests/unit/test_diagnostics.py new file mode 100644 index 0000000..0c15b75 --- /dev/null +++ b/packages/deepctl-shared-utils/tests/unit/test_diagnostics.py @@ -0,0 +1,16 @@ +"""Tests for shared diagnostic console output.""" + +from io import StringIO + +from deepctl_shared_utils.diagnostics import create_diagnostic_console + + +def test_ci_diagnostic_console_uses_plain_text_in_a_tty(monkeypatch): + monkeypatch.setenv("CI", "true") + output = StringIO() + + console = create_diagnostic_console(file=output, force_terminal=True) + console.print("[red]Error:[/red] File not found") + + assert output.getvalue() == "Error: File not found\n" + assert "\x1b" not in output.getvalue() From dbbc1815f4aad45ba9ea6389c951f89635a8c812 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Wed, 23 Sep 2026 17:12:30 +0100 Subject: [PATCH 16/17] docs: scope structured failure output --- README.md | 16 +++++++--------- web/public/llms-full.txt | 15 ++++++++------- web/src/pages/index.astro | 2 +- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8cffcc5..3d30eac 100644 --- a/README.md +++ b/README.md @@ -339,15 +339,13 @@ conventional `130`, so the code is the same whether the cancellation came from Ctrl-C or from declining a prompt. Human-readable status and error messages go to stderr, and stdout carries the -result. With `-o json` a command that runs and fails writes that failure to -stdout as a payload with `"status": "error"`, so `dg ... -o json | jq` stays -parseable across the failure — authentication failures, `dg ffprobe` and -`dg debug audio` included. This is not yet universal: a handful of commands -still echo their human-readable summary to stdout ahead of the payload, and a -usage error (a bad flag, an unknown command) writes nothing to stdout at all. -Branch on the exit code rather than on whether stdout parsed. If a CI step -relied on `dg` always exiting `0` (every command did, before 0.3.0), it will -now fail where it previously passed silently. +result. With an explicit structured-output mode, authentication-guard failures +and commands that return an error result write a payload with `"status": +"error"` to stdout — authentication failures, `dg ffprobe`, and `dg debug +audio` included. Usage errors and handler-raised exceptions report on stderr +and can leave stdout empty. Branch on the exit code rather than on whether +stdout parsed. If a CI step relied on `dg` always exiting `0` (every command +did, before 0.3.0), it will now fail where it previously passed silently. ### Forcing non-interactive mode diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index f587379..0a0c907 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -343,10 +343,11 @@ Per-command flags, which go after the command: **Agent mode behavior:** - All interactive prompts return defaults (no blocking on stdin) - Status messages routed to stderr -- Data output to stdout: the core commands write nothing else there, so a - failure arrives on stdout as parseable JSON. A handful of commands still - print a human summary to stdout ahead of the payload, so branch on the exit - code rather than on whether stdout parsed. +- Data output to stdout: authentication-guard failures and commands that + return an error result emit a structured payload in explicit structured-output + modes. Usage errors and handler-raised exceptions report on stderr and may + leave stdout empty, so branch on the exit code rather than on whether stdout + parsed. - JSON output format by default - Exit codes: 0 = success, 1 = error, 2 = user interrupt @@ -370,9 +371,9 @@ Available on every command. Outputs a JSON contract describing the command: ## Configuration Config file location: -- macOS: `~/Library/Application Support/deepgram/config.yaml` -- Linux: `~/.config/deepgram/config.yaml` -- Windows: `%APPDATA%\deepgram\config.yaml` +- macOS: `~/Library/Application Support/deepctl/config.yaml` +- Linux: `~/.config/deepctl/config.yaml` +- Windows: `%LOCALAPPDATA%\deepgram\deepctl\config.yaml` Environment variables: - `DEEPGRAM_API_KEY` — API key (overrides profile) diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 0c37ac4..cacd44a 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -430,7 +430,7 @@ const commandCards: CommandCard[] = [ "name": "Can I pipe the Deepgram CLI output to other tools?", "acceptedAnswer": { "@type": "Answer", - "text": "Yes. The CLI is fully UNIX-composable. Use --output json (or -o json) to get structured JSON, which can be piped to jq, grep, and other tools. In CI, in AI coding tools, and in any fully non-interactive environment with no terminal attached, the CLI detects the context and switches to JSON automatically; from an interactive shell, pass -o json explicitly. Status and error messages go to stderr, and the core commands keep stdout for the result alone, so a failure still arrives on stdout as parseable JSON. A handful of commands still print a human summary to stdout, so branch on the exit code rather than on whether stdout parsed." + "text": "Yes. The CLI is fully UNIX-composable. Use --output json (or -o json) to get structured JSON, which can be piped to jq, grep, and other tools. In CI, in AI coding tools, and in any fully non-interactive environment with no terminal attached, the CLI detects the context and switches to JSON automatically; from an interactive shell, pass -o json explicitly. Authentication-guard failures and commands that return an error result emit a structured payload in explicit structured-output modes. Usage errors and handler-raised exceptions report on stderr and may leave stdout empty, so branch on the exit code rather than on whether stdout parsed." } } ] From 395841e421d132fe0c36829e36bd6756d5b14f79 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Tue, 29 Sep 2026 07:10:18 -0400 Subject: [PATCH 17/17] docs(llms): qualify the command-group flag rule `dg debug --verbose` is accepted because the debug group defines its own -v/--verbose, so "reject every trailing global option" overstated it. Co-Authored-By: Claude Fable 5.1 --- web/public/llms-full.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index 0a0c907..998a441 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -297,7 +297,7 @@ dg debug probe # Analyze WebSocket stream ## Global Options Most of these go on `dg` itself, before the command: `dg --profile staging listen call.mp3`. -`--quiet`/`-q`, `--verbose`/`-v`, and `--non-interactive` are also accepted after a leaf command. `--output`/`-o` can follow a leaf command that does not define its own output option, so `dg listen call.mp3 -o json` works. Speak owns `-o` as an audio-file path; use `dg -o json speak ...` for structured Speak output. The remaining options fail after a leaf command; command groups such as `dg debug` reject every trailing global option. +`--quiet`/`-q`, `--verbose`/`-v`, and `--non-interactive` are also accepted after a leaf command. `--output`/`-o` can follow a leaf command that does not define its own output option, so `dg listen call.mp3 -o json` works. Speak owns `-o` as an audio-file path; use `dg -o json speak ...` for structured Speak output. The remaining options fail after a leaf command; command groups such as `dg debug` reject the trailing global options (`dg debug` accepts `--verbose` only because it defines its own). | Flag | Description | |------|-------------|