Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
189f743
fix(core): keep diagnostics off stdout on the failure path
GregHolmes Aug 20, 2026
1b3a738
fix(commands): correct advertised examples that don't parse
GregHolmes Aug 20, 2026
71aaa30
docs(web): attribute auto-JSON to agent/CI detection, not piping
GregHolmes Aug 20, 2026
c56e963
fix(test): read source as UTF-8 in the bare-Console AST sweep
GregHolmes Aug 20, 2026
70d7223
fix(core): keep credential-source diagnostics off stdout
dg-coreylweathers Sep 20, 2026
8d17d4b
fix(ffprobe,debug-audio): keep stdout for the payload
dg-coreylweathers Sep 20, 2026
ec289ad
test(examples): check every entry point group and the substituted exa…
dg-coreylweathers Sep 20, 2026
76f8c60
docs: make the stdout/stderr promise and the auto-JSON trigger true
dg-coreylweathers Sep 20, 2026
5199077
test(output): pin the ffprobe, debug-audio and closed-stream channels
dg-coreylweathers Sep 20, 2026
9ef3b58
fix(docs): correct 11 advertised commands the CLI rejects, and guard …
dg-coreylweathers Sep 20, 2026
134c1e4
fix(docs): describe flag position accurately in llms-full.txt
dg-coreylweathers Sep 20, 2026
125ff14
fix(docs): render the Windows config path as one backslash each
dg-coreylweathers Sep 20, 2026
cd9959a
fix(docs): clarify trailing output flags
GregHolmes Sep 23, 2026
c944c45
fix(docs): qualify trailing output flags
GregHolmes Sep 23, 2026
6ec0668
fix(output): keep shared diagnostics plain in CI
GregHolmes Sep 23, 2026
dbbc181
docs: scope structured failure output
GregHolmes Sep 23, 2026
395841e
docs(llms): qualify the command-group flag rule
dg-coreylweathers Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -334,16 +338,22 @@ 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

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

Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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",
Expand All @@ -454,53 +473,62 @@ 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)

# 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",
Expand All @@ -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",
Expand Down
102 changes: 102 additions & 0 deletions packages/deepctl-cmd-debug-audio/tests/unit/test_audio_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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. "
Expand Down
Loading
Loading