diff --git a/README.md b/README.md index 21303f5..3d30eac 100644 --- a/README.md +++ b/README.md @@ -316,8 +316,12 @@ 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, 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 @@ -334,8 +338,14 @@ 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 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 @@ -343,7 +353,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 @@ -395,10 +405,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-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-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-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-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-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-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/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..abe57fd 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 .output import _agentic, print_error, print_info, print_warning, stderr_console +from .models import ErrorResult +from .output import _agentic, print_error, 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): @@ -99,16 +104,52 @@ 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") - - except Exception: - # guard() already printed helpful error messages; - # exit without duplicating them. - raise SystemExit(1) + 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 + # 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 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 if self.requires_project: @@ -139,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/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..3c0d8fa --- /dev/null +++ b/packages/deepctl-core/tests/unit/test_output_channels.py @@ -0,0 +1,289 @@ +"""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] = [] + + # 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. + 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 + 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 == "" + + +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 + + +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")) 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 de3c04c..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,15 +10,20 @@ 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: 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 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 bf77dce..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,11 +6,16 @@ from urllib.parse import urlparse import httpx -from rich.console import Console +from .diagnostics import create_diagnostic_console 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 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() 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 new file mode 100644 index 0000000..45de904 --- /dev/null +++ b/tests/unit/test_command_examples.py @@ -0,0 +1,305 @@ +"""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 +from pathlib import Path + +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. 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 + + +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"\$\(([^()]*)\)|`([^`]*)`") + +# 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`), + command substitutions, and trailing `# comments`. Only the segments that + invoke our own binary are ours to validate. + """ + outer, inner = _split_substitutions(example) + + invocations = [] + for snippet in [outer, *inner]: + for segment in re.split(r"\|\||&&|\|", snippet): + try: + 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 + + +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, str, list[str]]]: + """(group, 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 _is_dynamic(argv): + continue + collected.append((group, entry_point.name, example, argv)) + return collected + + +CASES = _collect() + + +@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( + ("group", "command_name", "example", "argv"), + CASES, + ids=[f"{name}: {example}" for _, name, example, _ in CASES], +) +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 + + 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." + ) + + +# --------------------------------------------------------------------------- +# 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 stale command names and options. +# --------------------------------------------------------------------------- + +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`. +# 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. +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 not candidate.isascii(): + continue + if any( + not PLACEHOLDER.search(" ".join(argv)) + for argv in _dg_invocations(candidate) + ): + 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): + if PLACEHOLDER.search(" ".join(argv)): + continue + 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 ca3eced..998a441 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 @@ -296,7 +296,8 @@ dg debug probe # Analyze WebSocket stream ## Global Options -All commands accept these global flags: +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 the trailing global options (`dg debug` accepts `--verbose` only because it defines its own). | Flag | Description | |------|-------------| @@ -305,10 +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) | -| `--project-id` | Explicit project ID | -| `--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) | --- @@ -316,24 +325,29 @@ 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: 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 @@ -357,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) @@ -372,19 +386,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 11b3174..c6a0a80 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, Gemini) — disables prompts, outputs JSON, routes status to stderr -- Multiple output formats: `--output json|yaml|table|csv` +- 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 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) diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 18748bc..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. 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, 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." } } ] @@ -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 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', '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]) => (