From 52741d9ac9e8cd3bca9e5ef97d543950d70e4f53 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Mon, 17 Aug 2026 16:33:41 +0100 Subject: [PATCH 1/5] fix(web): correct invalid command syntax and document Flux TTS/STT The ALL COMMANDS section advertised seven invocations that the CLI rejects. Anyone copying them got an error: dg keys create "ci-runner" -> No such command 'keys create' dg projects list -> No such command 'projects list' dg members list -> No such command 'members list' dg models list --type stt -> No such command 'models list' dg requests list --limit 20 -o json -> No such command 'requests list' dg completion install --shell zsh -> No such command 'completion install' dg api GET /v1/projects -> Got unexpected extra argument These commands are flag-based, not subcommand-based. The page was already internally inconsistent: its FAQ used the correct forms (dg projects --list, dg keys --create --comment) while the cards used a subcommand style that has never existed. Content was also stale after the Flux TTS default (#89) and SDK 7.7.0 (#92): the page said "Generate speech with Aura voices" with no mention of Flux, and none of the new flags appeared anywhere. - speak: document Flux as the default engine plus --speed and --expressivity, and keep an explicit Aura card so -m aura-2-* stays discoverable - listen: add --redact/--numerals and Flux STT streaming (flux-general-en), noting it is streaming-only - hero terminal: a default `dg speak` now reports flux-alexis-en, not aura-2-asteria-en - add the missing commands: profiles, logout, ffprobe, debug browser, debug probe (27 cards -> 36) - llms.txt / llms-full.txt: same Flux corrections, add the new flag rows, and fix a model name that does not exist (aura-2-en-us -> real aura-2-* ids) Every advertised invocation in the cards was validated against the installed CLI; the shell-composed ones were checked by hand. Build passes and the stale forms are absent from the built output. --- web/public/llms-full.txt | 6 ++++- web/public/llms.txt | 2 +- web/src/pages/index.astro | 54 ++++++++++++++++++++++++++++++++------- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index 76e3978..03b6b46 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -98,6 +98,8 @@ dg listen interview.mp3 --srt --diarize --save-to interview.srt | `--model` | STT model (nova-3, nova-2, whisper, flux-general-en, etc.) | nova-3 | | `--language` | Language code (en-US, es-ES, fr-FR, etc.) | en-US | | `--diarize` | Speaker diarization — outputs `[Speaker N]` labels | false | +| `--redact` | Redact sensitive content — `numbers`/`aggressive_numbers` on Flux STT; `pci`, `ssn` etc. on v1 models | off | +| `--numerals` | Convert spoken numbers to digits ("four twenty" -> "420") | false | | `--smart-format` | Smart formatting (punctuation, capitalization) | true | | `--punctuate` | Enable punctuation | true | | `--summarize` | Generate transcript summary | false | @@ -153,7 +155,9 @@ dg speak --file script.txt # From file | Flag | Description | |------|-------------| | `--output` | Save to file path | -| `--model` | Voice model (aura-2-en-us, etc.) | +| `--model` | Voice model — `flux-alexis-en` (default, streaming) or `aura-2-asteria-en` / `aura-2-selena-es` (REST batch) | +| `--speed` | Flux TTS only — speech-rate multiplier from 0.85 to 1.15 (1.00 = nominal) | +| `--expressivity` | Flux TTS only (beta) — expressive range -2 to 2 (0 = nominal) | | `--encoding` | Output encoding (mp3, linear16, flac, wav, ogg) | | `--file` | Read text from file | diff --git a/web/public/llms.txt b/web/public/llms.txt index a7dc1e6..11b3174 100644 --- a/web/public/llms.txt +++ b/web/public/llms.txt @@ -17,7 +17,7 @@ - `dg login` — Authenticate via browser (OIDC device flow) or `--api-key` flag - `dg listen ` — Transcribe files, URLs, microphone (`--mic`), or stdin. Supports `--diarize`, `--webvtt`, `--srt`, `--summarize`. (`dg transcribe` is a hidden alias) -- `dg speak ""` — Text-to-speech synthesis with Aura voices +- `dg speak ""` — Text-to-speech synthesis; streams Flux TTS by default (flux-alexis-en), or pick an Aura voice with `-m aura-2-*` - `dg read ` — Text intelligence: sentiment, summaries, topics, intents - `dg models` — List available Deepgram models - `dg projects` — Manage Deepgram projects diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 508b756..b86d1e1 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -71,6 +71,14 @@ const commandCards: CommandCard[] = [ title: 'Write live WebVTT captions', desc: 'Pipe real-time caption output directly to a .vtt file.', lines: ['dg listen --mic --webvtt > captions.vtt'] }, + { cmd: 'listen', icon: 'mic', color: 'green', + title: 'Redact sensitive audio', + desc: 'Strip PCI, SSN, or numbers from transcripts — works on files and live streams.', + lines: ['dg listen call.wav --redact pci --numerals'] }, + { cmd: 'listen', icon: 'mic', color: 'green', + title: 'Stream with Flux STT', + desc: 'Conversational turn-based model for live audio. Streaming only — use nova-3 for files.', + lines: ['dg listen --mic --model flux-general-en \\', ' --redact aggressive_numbers'] }, { cmd: 'listen', icon: 'mic', color: 'green', title: 'Pipe transcript to an LLM', desc: 'Chain with any LLM CLI for instant summarization or Q&A.', @@ -79,7 +87,16 @@ const commandCards: CommandCard[] = [ // ── dg speak ──────────────────────────────────────────────────────────── { cmd: 'speak', icon: 'speaker', color: 'purple', title: 'Synthesize speech to a file', + desc: 'Streams from Flux TTS by default — Aura voices stay available with -m aura-2-*.', lines: ['dg speak "Hello from Deepgram" -o hello.wav'] }, + { cmd: 'speak', icon: 'speaker', color: 'purple', + title: 'Tune pace and expressivity', + desc: 'Flux-only controls: --speed from 0.85 to 1.15, and --expressivity from -2 to 2 (beta).', + lines: ['dg speak "So exciting!" --speed 1.05 \\', ' --expressivity 2 -o lively.wav'] }, + { cmd: 'speak', icon: 'speaker', color: 'purple', + title: 'Pick an Aura voice', + desc: 'Speak v1 REST batch — 40+ Aura voices with mp3, linear16, flac, and opus output.', + lines: ['dg speak "Hola, mundo" -m aura-2-selena-es -o hola.mp3'] }, { cmd: 'speak', icon: 'speaker', color: 'purple', title: 'Pipe TTS to your speaker', desc: 'Any text in, audio out. Works with ffplay, aplay, or mpv.', @@ -101,21 +118,21 @@ const commandCards: CommandCard[] = [ lines: ['dg login'] }, { cmd: 'keys', icon: 'key', color: 'amber', title: 'Create a scoped API key', - lines: ['dg keys create "ci-runner"'] }, + lines: ['dg keys --create --comment "ci-runner" --scopes member'] }, // ── account ───────────────────────────────────────────────────────────── { cmd: 'projects', icon: 'building', color: 'amber', title: 'List all projects', - lines: ['dg projects list'] }, + lines: ['dg projects --list'] }, { cmd: 'members', icon: 'users', color: 'amber', title: 'View project members', - lines: ['dg members list'] }, + lines: ['dg members --list'] }, // ── models & data ─────────────────────────────────────────────────────── { cmd: 'models', icon: 'chart', color: 'blue', title: 'Browse available models', desc: 'Filter by type, language, or tier to find the right model.', - lines: ['dg models list --type stt'] }, + lines: ['dg models --type stt'] }, { cmd: 'usage', icon: 'chart', color: 'subtle', title: 'Check usage and limits', lines: ['dg usage'] }, @@ -124,13 +141,13 @@ const commandCards: CommandCard[] = [ lines: ['dg billing'] }, { cmd: 'requests', icon: 'clock', color: 'subtle', title: 'Audit recent API calls', - lines: ['dg requests list --limit 20 -o json'] }, + lines: ['dg -o json requests --limit 20'] }, // ── raw api ───────────────────────────────────────────────────────────── { cmd: 'api', icon: 'code', color: 'blue', title: 'Call any Deepgram endpoint', desc: 'Escape hatch for endpoints not yet covered by a dedicated command.', - lines: ['dg api GET /v1/projects'] }, + lines: ['dg api /v1/projects'] }, // ── debug ─────────────────────────────────────────────────────────────── { cmd: 'debug audio', icon: 'wrench', color: 'amber', @@ -170,7 +187,26 @@ const commandCards: CommandCard[] = [ // ── utilities ─────────────────────────────────────────────────────────── { cmd: 'completion', icon: 'check', color: 'subtle', title: 'Install shell tab-completion', - lines: ['dg completion install --shell zsh'] }, + lines: ['dg completion zsh --install'] }, + { cmd: 'profiles', icon: 'users', color: 'amber', + title: 'Switch between accounts', + desc: 'Keep separate credentials per environment and switch with one command.', + lines: ['dg profiles --list', 'dg profiles --switch staging'] }, + { cmd: 'logout', icon: 'key', color: 'amber', + title: 'Clear stored credentials', + lines: ['dg logout --profile staging'] }, + { cmd: 'ffprobe', icon: 'wrench', color: 'subtle', + title: 'Point at a custom ffprobe', + desc: 'Used for local audio analysis before transcription.', + lines: ['dg ffprobe --path $(which ffprobe)'] }, + { cmd: 'debug browser', icon: 'wrench', color: 'amber', + title: 'Check browser capabilities', + desc: 'Verifies codec and WebSocket support for browser-based capture.', + lines: ['dg debug browser'] }, + { cmd: 'debug probe', icon: 'wrench', color: 'amber', + title: 'Proxy and inspect a live stream', + desc: 'Sits in front of a stream so you can see exactly what reaches Deepgram.', + lines: ['dg debug probe'] }, ]; --- @@ -594,7 +630,7 @@ const commandCards: CommandCard[] = [

Text-to-speech

-

Generate speech with Aura voices. Pipe to ffplay or save to file.

+

Stream speech with Flux TTS, or pick from 40+ Aura voices. Pipe to ffplay or save to file.

@@ -940,7 +976,7 @@ const commandCards: CommandCard[] = [ // ── TTS → pipe to player ────────────────────── [ { p: '❯ ', t: 'dg speak "Hello from Deepgram" | ffplay -nodisp -autoexit -', c: '' }, - { p: '', t: '✓ Synthesizing · aura-2-asteria-en', c: '#13ef95' }, + { p: '', t: '✓ Synthesizing · flux-alexis-en', c: '#13ef95' }, { p: '', t: '▶ Streaming 3.2s of audio…', c: '#949498' }, { p: '', t: '', c: '' }, ], From 8a9c2e53d28c7642f4748a9fb18773899d7d3a7c Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Tue, 18 Aug 2026 13:49:52 +0100 Subject: [PATCH 2/5] fix(output): honor -o json across requests/read/models/projects/members/usage/billing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These seven commands printed their Rich table and status chrome to stdout unconditionally, so in -o json mode the framework's JSON was emitted *after* the human output — corrupting stdout for anyone piping into jq. This makes the web page's `dg -o json requests` example actually produce JSON. Fix follows the existing house pattern (init/listen/speak): status/progress chrome goes to a stderr Console, and the human table is gated on get_output_format() == "default". The returned result (which the framework serializes for json/yaml/csv) is unchanged, so default/interactive output is identical. requests --show now returns the request detail in JSON instead of a bare success message. Also export get_output_format from deepctl_core.__all__ (it was importable but not an explicit re-export; mypy flagged it). Verified: all 7 commands emit pure jq-parseable JSON; default renders the table; 60 unit tests pass; ruff and mypy clean across 21 source files. --- .../src/deepctl_cmd_billing/command.py | 44 +++++--- .../src/deepctl_cmd_members/command.py | 94 +++++++++------- .../src/deepctl_cmd_models/command.py | 41 ++++--- .../src/deepctl_cmd_projects/command.py | 77 +++++++------ .../src/deepctl_cmd_read/command.py | 38 +++++-- .../src/deepctl_cmd_requests/command.py | 83 ++++++++------ .../src/deepctl_cmd_requests/models.py | 5 + .../src/deepctl_cmd_usage/command.py | 106 ++++++++++-------- .../deepctl-core/src/deepctl_core/__init__.py | 1 + 9 files changed, 294 insertions(+), 195 deletions(-) diff --git a/packages/deepctl-cmd-billing/src/deepctl_cmd_billing/command.py b/packages/deepctl-cmd-billing/src/deepctl_cmd_billing/command.py index 6d8b8b8..9ef94d4 100644 --- a/packages/deepctl-cmd-billing/src/deepctl_cmd_billing/command.py +++ b/packages/deepctl-cmd-billing/src/deepctl_cmd_billing/command.py @@ -10,6 +10,7 @@ BaseResult, Config, DeepgramClient, + get_output_format, ) from rich.console import Console from rich.table import Table @@ -17,6 +18,9 @@ from .models import BalanceInfo, BillingResult console = Console() +# Status/progress chrome must never touch stdout, or it corrupts JSON/CSV +# output that callers pipe into jq and friends. +status_console = Console(stderr=True) class BillingCommand(BaseCommand): @@ -116,7 +120,7 @@ def handle( return result except Exception as e: - console.print(f"[red]Error:[/red] {e}") + status_console.print(f"[red]Error:[/red] {e}") return BaseResult(status="error", message=str(e)) def _show_balances( @@ -125,21 +129,16 @@ def _show_balances( project_id: str | None, result: BillingResult, ) -> None: - console.print("[blue]Fetching balances...[/blue]") + status_console.print("[blue]Fetching balances...[/blue]") data = client.get_balances(project_id=project_id) balances_raw = data.get("balances", []) if not balances_raw: - console.print("[yellow]No balances found[/yellow]") + status_console.print("[yellow]No balances found[/yellow]") return - table = Table( - title="Account Balances", show_header=True, header_style="bold blue" - ) - table.add_column("Balance ID", style="dim") - table.add_column("Amount", justify="right", style="green") - table.add_column("Units") - + # Always populate the returned result; only the human table render + # is gated on default mode (json/yaml/csv are emitted by the framework). for b in balances_raw: b_data = ( b @@ -152,9 +151,19 @@ def _show_balances( units=b_data.get("units", ""), ) result.balances.append(info) - table.add_row(info.balance_id, f"{info.amount:,.2f}", info.units) - console.print(table) + if get_output_format() == "default": + table = Table( + title="Account Balances", show_header=True, header_style="bold blue" + ) + table.add_column("Balance ID", style="dim") + table.add_column("Amount", justify="right", style="green") + table.add_column("Units") + + for info in result.balances: + table.add_row(info.balance_id, f"{info.amount:,.2f}", info.units) + + console.print(table) def _show_breakdown( self, @@ -166,7 +175,7 @@ def _show_breakdown( grouping: str | None = None, ) -> None: - console.print("[blue]Fetching billing breakdown...[/blue]") + status_console.print("[blue]Fetching billing breakdown...[/blue]") data = client.get_billing_breakdown( project_id=project_id, start=start, @@ -176,13 +185,18 @@ def _show_breakdown( result.breakdown = data + # Human display only in default mode; json/yaml/csv are emitted by the + # framework from result.breakdown. + if get_output_format() != "default": + return + # Display breakdown summary resolution = data.get("resolution", {}) if resolution: period = resolution.get("period", "") amount = resolution.get("amount", 0) if period: - console.print( + status_console.print( f"\n[green]Billing Period:[/green] {period} ({amount} units)" ) @@ -205,6 +219,6 @@ def _show_breakdown( console.print(table) else: - console.print( + status_console.print( "[dim]No breakdown data available for the specified period[/dim]" ) diff --git a/packages/deepctl-cmd-members/src/deepctl_cmd_members/command.py b/packages/deepctl-cmd-members/src/deepctl_cmd_members/command.py index 643c4f2..88cc12f 100644 --- a/packages/deepctl-cmd-members/src/deepctl_cmd_members/command.py +++ b/packages/deepctl-cmd-members/src/deepctl_cmd_members/command.py @@ -10,6 +10,7 @@ BaseResult, Config, DeepgramClient, + get_output_format, ) from rich.console import Console from rich.table import Table @@ -17,6 +18,9 @@ from .models import InviteInfo, MemberInfo, MembersResult console = Console() +# Status/progress chrome must never touch stdout, or it corrupts JSON/CSV +# output that callers pipe into jq and friends. +status_console = Console(stderr=True) class MembersCommand(BaseCommand): @@ -141,29 +145,21 @@ def handle( return self._list_members(client, project_id) except Exception as e: - console.print(f"[red]Error:[/red] {e}") + status_console.print(f"[red]Error:[/red] {e}") return BaseResult(status="error", message=str(e)) def _list_members( self, client: DeepgramClient, project_id: str | None ) -> BaseResult: - console.print("[blue]Fetching project members...[/blue]") + status_console.print("[blue]Fetching project members...[/blue]") result = client.list_members(project_id=project_id) members_raw = result.get("members", []) if not members_raw: - console.print("[yellow]No members found[/yellow]") + status_console.print("[yellow]No members found[/yellow]") return MembersResult(status="info", message="No members found") member_models: list[MemberInfo] = [] - table = Table( - title="Project Members", show_header=True, header_style="bold blue" - ) - table.add_column("Name", style="green") - table.add_column("Email") - table.add_column("Scopes") - table.add_column("Member ID", style="dim") - for m in members_raw: m_data = ( m @@ -179,16 +175,29 @@ def _list_members( ) member_models.append(info) - name = f"{info.first_name} {info.last_name}".strip() or "(no name)" - table.add_row( - name, - info.email, - ", ".join(info.scopes) if info.scopes else "-", - info.member_id, + # Render the human table only in default mode. For json/yaml/csv the + # framework serialises the returned result to stdout, so printing the + # table here would prepend non-parseable text to that output. + if get_output_format() == "default": + table = Table( + title="Project Members", show_header=True, header_style="bold blue" ) + table.add_column("Name", style="green") + table.add_column("Email") + table.add_column("Scopes") + table.add_column("Member ID", style="dim") + + for info in member_models: + name = f"{info.first_name} {info.last_name}".strip() or "(no name)" + table.add_row( + name, + info.email, + ", ".join(info.scopes) if info.scopes else "-", + info.member_id, + ) - console.print(table) - console.print(f"\n[dim]{len(member_models)} member(s)[/dim]") + console.print(table) + console.print(f"\n[dim]{len(member_models)} member(s)[/dim]") return MembersResult( status="success", members=member_models, count=len(member_models) @@ -203,15 +212,15 @@ def _invite_member( dry_run: bool = False, ) -> BaseResult: if dry_run: - console.print("[yellow]Dry run — no changes made[/yellow]") - console.print(f" Would invite: {email} (scope: {scope})") + status_console.print("[yellow]Dry run — no changes made[/yellow]") + status_console.print(f" Would invite: {email} (scope: {scope})") return BaseResult( status="dry_run", message=f"Dry run: would invite {email}" ) - console.print(f"[blue]Inviting {email} with scope '{scope}'...[/blue]") + status_console.print(f"[blue]Inviting {email} with scope '{scope}'...[/blue]") client.create_invite(email=email, scope=scope, project_id=project_id) - console.print(f"[green]Invitation sent to {email}[/green]") + status_console.print(f"[green]Invitation sent to {email}[/green]") return BaseResult(status="success", message=f"Invited {email}") def _remove_member( @@ -223,8 +232,8 @@ def _remove_member( dry_run: bool = False, ) -> BaseResult: if dry_run: - console.print("[yellow]Dry run — no changes made[/yellow]") - console.print(f" Would remove member: {member_id}") + status_console.print("[yellow]Dry run — no changes made[/yellow]") + status_console.print(f" Would remove member: {member_id}") return BaseResult( status="dry_run", message=f"Dry run: would remove member {member_id}" ) @@ -234,29 +243,23 @@ def _remove_member( ): return BaseResult(status="cancelled", message="Cancelled by user") - console.print(f"[blue]Removing member {member_id}...[/blue]") + status_console.print(f"[blue]Removing member {member_id}...[/blue]") client.remove_member(member_id, project_id=project_id) - console.print(f"[green]Member {member_id} removed[/green]") + status_console.print(f"[green]Member {member_id} removed[/green]") return BaseResult(status="success", message=f"Member {member_id} removed") def _list_invites( self, client: DeepgramClient, project_id: str | None ) -> BaseResult: - console.print("[blue]Fetching pending invites...[/blue]") + status_console.print("[blue]Fetching pending invites...[/blue]") result = client.list_invites(project_id=project_id) invites_raw = result.get("invites", []) if not invites_raw: - console.print("[yellow]No pending invites[/yellow]") + status_console.print("[yellow]No pending invites[/yellow]") return MembersResult(status="info", message="No pending invites") invite_models: list[InviteInfo] = [] - table = Table( - title="Pending Invites", show_header=True, header_style="bold blue" - ) - table.add_column("Email", style="green") - table.add_column("Scope") - for inv in invites_raw: inv_data = ( inv @@ -268,9 +271,18 @@ def _list_invites( scope=inv_data.get("scope", ""), ) invite_models.append(info) - table.add_row(info.email, info.scope) - console.print(table) + if get_output_format() == "default": + table = Table( + title="Pending Invites", show_header=True, header_style="bold blue" + ) + table.add_column("Email", style="green") + table.add_column("Scope") + + for info in invite_models: + table.add_row(info.email, info.scope) + + console.print(table) return MembersResult( status="success", invites=invite_models, count=len(invite_models) @@ -285,8 +297,8 @@ def _revoke_invite( dry_run: bool = False, ) -> BaseResult: if dry_run: - console.print("[yellow]Dry run — no changes made[/yellow]") - console.print(f" Would revoke invite for: {email}") + status_console.print("[yellow]Dry run — no changes made[/yellow]") + status_console.print(f" Would revoke invite for: {email}") return BaseResult( status="dry_run", message=f"Dry run: would revoke invite for {email}", @@ -295,7 +307,7 @@ def _revoke_invite( if not yes and not self.confirm(f"Revoke invite for {email}?", default=False): return BaseResult(status="cancelled", message="Cancelled by user") - console.print(f"[blue]Revoking invite for {email}...[/blue]") + status_console.print(f"[blue]Revoking invite for {email}...[/blue]") client.delete_invite(email, project_id=project_id) - console.print(f"[green]Invite for {email} revoked[/green]") + status_console.print(f"[green]Invite for {email} revoked[/green]") return BaseResult(status="success", message=f"Invite for {email} revoked") diff --git a/packages/deepctl-cmd-models/src/deepctl_cmd_models/command.py b/packages/deepctl-cmd-models/src/deepctl_cmd_models/command.py index d400dd7..c128b2b 100644 --- a/packages/deepctl-cmd-models/src/deepctl_cmd_models/command.py +++ b/packages/deepctl-cmd-models/src/deepctl_cmd_models/command.py @@ -10,6 +10,7 @@ BaseResult, Config, DeepgramClient, + get_output_format, ) from rich.console import Console from rich.table import Table @@ -17,6 +18,9 @@ from .models import ModelInfo, ModelsResult console = Console() +# Status/progress chrome must never touch stdout, or it corrupts JSON/CSV +# output that callers pipe into jq and friends. +status_console = Console(stderr=True) class ModelsCommand(BaseCommand): @@ -101,26 +105,29 @@ def handle( ) if not all_models: - console.print("[yellow]No models found[/yellow]") + status_console.print("[yellow]No models found[/yellow]") return ModelsResult(status="info", message="No models found") - # Display as table - table = Table( - title="Deepgram Models", show_header=True, header_style="bold blue" - ) - table.add_column("Name", style="green") - table.add_column("Type", style="cyan") - table.add_column("Language") - table.add_column("Version") - table.add_column("ID", style="dim") - - for m in all_models: - table.add_row( - m.name, m.model_type.upper(), m.language, m.version, m.model_id + # Render the human table only in default mode. For json/yaml/csv + # the framework serialises the returned result to stdout, so + # printing the table here would corrupt that output for piping. + if get_output_format() == "default": + table = Table( + title="Deepgram Models", show_header=True, header_style="bold blue" ) + table.add_column("Name", style="green") + table.add_column("Type", style="cyan") + table.add_column("Language") + table.add_column("Version") + table.add_column("ID", style="dim") + + for m in all_models: + table.add_row( + m.name, m.model_type.upper(), m.language, m.version, m.model_id + ) - console.print(table) - console.print(f"\n[dim]{len(all_models)} model(s) found[/dim]") + console.print(table) + console.print(f"\n[dim]{len(all_models)} model(s) found[/dim]") return ModelsResult( status="success", @@ -129,5 +136,5 @@ def handle( ) except Exception as e: - console.print(f"[red]Error listing models:[/red] {e}") + status_console.print(f"[red]Error listing models:[/red] {e}") return BaseResult(status="error", message=str(e)) diff --git a/packages/deepctl-cmd-projects/src/deepctl_cmd_projects/command.py b/packages/deepctl-cmd-projects/src/deepctl_cmd_projects/command.py index 71ccaed..8be63ff 100644 --- a/packages/deepctl-cmd-projects/src/deepctl_cmd_projects/command.py +++ b/packages/deepctl-cmd-projects/src/deepctl_cmd_projects/command.py @@ -8,12 +8,16 @@ BaseResult, Config, DeepgramClient, + get_output_format, ) from rich.console import Console from .models import ProjectInfo, ProjectsResult console = Console() +# Status/progress chrome must never touch stdout, or it corrupts JSON/CSV +# output that callers pipe into jq and friends. +status_console = Console(stderr=True) class ProjectsCommand(BaseCommand): @@ -110,12 +114,12 @@ def handle( return self._list_projects(client) except Exception as e: - console.print(f"[red]Error:[/red] {e}") + status_console.print(f"[red]Error:[/red] {e}") return BaseResult(status="error", message=str(e)) def _list_projects(self, client: DeepgramClient) -> ProjectsResult | BaseResult: """List all projects.""" - console.print("[blue]Fetching projects...[/blue]") + status_console.print("[blue]Fetching projects...[/blue]") try: result = client.get_projects() @@ -134,7 +138,7 @@ def _list_projects(self, client: DeepgramClient) -> ProjectsResult | BaseResult: projects_raw = result.get("projects", []) if not projects_raw: - console.print("[yellow]No projects found[/yellow]") + status_console.print("[yellow]No projects found[/yellow]") return ProjectsResult( status="info", message="No projects found", @@ -143,8 +147,6 @@ def _list_projects(self, client: DeepgramClient) -> ProjectsResult | BaseResult: ) project_models: list[ProjectInfo] = [] - console.print(f"[green]Found {len(projects_raw)} project(s):[/green]") - for proj in projects_raw: # Handle project objects that might not be dicts project_data = proj @@ -162,10 +164,18 @@ def _list_projects(self, client: DeepgramClient) -> ProjectsResult | BaseResult: ) project_models.append(info) - console.print(f" • {info.name}") - console.print(f" ID: {info.project_id}") - console.print(f" Company: {info.company or 'N/A'}") - console.print() + # Human list only in default mode; json/yaml/csv are emitted by the + # framework from the returned result, so printing here would prepend + # non-parseable text to that output. + if get_output_format() == "default": + console.print( + f"[green]Found {len(project_models)} project(s):[/green]" + ) + for info in project_models: + console.print(f" • {info.name}") + console.print(f" ID: {info.project_id}") + console.print(f" Company: {info.company or 'N/A'}") + console.print() return ProjectsResult( status="success", @@ -174,17 +184,17 @@ def _list_projects(self, client: DeepgramClient) -> ProjectsResult | BaseResult: ) except Exception as e: - console.print(f"[red]Failed to list projects:[/red] {e}") + status_console.print(f"[red]Failed to list projects:[/red] {e}") return BaseResult(status="error", message=str(e)) def _create_project( self, client: DeepgramClient, name: str, company: str | None ) -> ProjectsResult | BaseResult: """Create a new project.""" - console.print(f"[blue]Creating project:[/blue] {name}") + status_console.print(f"[blue]Creating project:[/blue] {name}") if company: - console.print(f"[dim]Company:[/dim] {company}") + status_console.print(f"[dim]Company:[/dim] {company}") try: result = client.create_project(name, company) @@ -200,8 +210,8 @@ def _create_project( if isinstance(result_dict, dict) and "project_id" in result_dict: project_id = result_dict["project_id"] - console.print("[green]✓[/green] Project created successfully") - console.print(f"[dim]Project ID:[/dim] {project_id}") + status_console.print("[green]✓[/green] Project created successfully") + status_console.print(f"[dim]Project ID:[/dim] {project_id}") proj = ProjectInfo(project_id=project_id, name=name, company=company) return ProjectsResult( @@ -211,7 +221,7 @@ def _create_project( count=1, ) else: - console.print( + status_console.print( "[yellow]Project creation response missing project_id[/yellow]" ) return ProjectsResult( @@ -222,14 +232,14 @@ def _create_project( ) except Exception as e: - console.print(f"[red]Failed to create project:[/red] {e}") + status_console.print(f"[red]Failed to create project:[/red] {e}") return BaseResult(status="error", message=str(e)) def _show_project( self, client: DeepgramClient, project_id: str ) -> ProjectsResult | BaseResult: """Show details for a specific project.""" - console.print(f"[blue]Fetching project details:[/blue] {project_id}") + status_console.print(f"[blue]Fetching project details:[/blue] {project_id}") try: result = client.get_project(project_id) @@ -247,15 +257,16 @@ def _show_project( name = result_dict.get("name", "N/A") company = result_dict.get("company", "N/A") - console.print("[green]Project Details:[/green]") - console.print(f" Name: {name}") - console.print(f" ID: {project_id}") - console.print(f" Company: {company}") + if get_output_format() == "default": + console.print("[green]Project Details:[/green]") + console.print(f" Name: {name}") + console.print(f" ID: {project_id}") + console.print(f" Company: {company}") proj = ProjectInfo(project_id=project_id, name=name, company=company) return ProjectsResult(status="success", projects=[proj], count=1) else: - console.print("[yellow]Project details incomplete[/yellow]") + status_console.print("[yellow]Project details incomplete[/yellow]") # Still try to create a project info with what we have name = ( result_dict.get("name", "Unknown") @@ -276,7 +287,7 @@ def _show_project( ) except Exception as e: - console.print(f"[red]Failed to get project details:[/red] {e}") + status_console.print(f"[red]Failed to get project details:[/red] {e}") return BaseResult(status="error", message=str(e)) def _show_current_project( @@ -286,21 +297,21 @@ def _show_current_project( project_id = auth_manager.get_project_id() if not project_id: - console.print("[yellow]No current project set[/yellow]") - console.print( + status_console.print("[yellow]No current project set[/yellow]") + status_console.print( "Set a project ID with: deepctl login --project-id " ) - console.print("Or use environment variable: DEEPGRAM_PROJECT_ID") + status_console.print("Or use environment variable: DEEPGRAM_PROJECT_ID") return BaseResult(status="info", message="No current project set") - console.print(f"[blue]Current project ID:[/blue] {project_id}") + status_console.print(f"[blue]Current project ID:[/blue] {project_id}") return self._show_project(client, project_id) def _set_default_project( self, config: Config, auth_manager: AuthManager, project_id: str ) -> BaseResult: """Set default project ID.""" - console.print(f"[blue]Setting default project:[/blue] {project_id}") + status_console.print(f"[blue]Setting default project:[/blue] {project_id}") try: # Update current profile @@ -311,9 +322,9 @@ def _set_default_project( client = DeepgramClient(config, auth_manager) try: client.get_project(project_id) - console.print("[green]✓[/green] Project ID validated") + status_console.print("[green]✓[/green] Project ID validated") except Exception as e: - console.print( + status_console.print( f"[yellow]Warning:[/yellow] Could not validate project: {e}" ) if not self.confirm("Continue anyway?", default=False): @@ -327,7 +338,9 @@ def _set_default_project( base_url=current_profile.base_url, ) - console.print(f"[green]✓[/green] Default project set to: {project_id}") + status_console.print( + f"[green]✓[/green] Default project set to: {project_id}" + ) return BaseResult( status="success", @@ -335,5 +348,5 @@ def _set_default_project( ) except Exception as e: - console.print(f"[red]Failed to set default project:[/red] {e}") + status_console.print(f"[red]Failed to set default project:[/red] {e}") return BaseResult(status="error", message=str(e)) diff --git a/packages/deepctl-cmd-read/src/deepctl_cmd_read/command.py b/packages/deepctl-cmd-read/src/deepctl_cmd_read/command.py index 428d9c3..613cf28 100644 --- a/packages/deepctl-cmd-read/src/deepctl_cmd_read/command.py +++ b/packages/deepctl-cmd-read/src/deepctl_cmd_read/command.py @@ -12,12 +12,16 @@ BaseResult, Config, DeepgramClient, + get_output_format, ) from rich.console import Console from .models import ReadResult console = Console() +# Status/progress chrome must never touch stdout, or it corrupts JSON/CSV +# output that callers pipe into jq and friends. +status_console = Console(stderr=True) class ReadCommand(BaseCommand): @@ -129,7 +133,7 @@ def handle( intents = True try: - console.print("[blue]Analyzing text...[/blue]") + status_console.print("[blue]Analyzing text...[/blue]") result = client.analyze_text( text=text, @@ -143,7 +147,7 @@ def handle( return self._display_results(result, sentiment, summarize, topics, intents) except Exception as e: - console.print(f"[red]Error analyzing text:[/red] {e}") + status_console.print(f"[red]Error analyzing text:[/red] {e}") return BaseResult(status="error", message=str(e)) def _display_results( @@ -157,6 +161,11 @@ def _display_results( results = result.get("results", {}) read_result = ReadResult(status="success") + # Human display only in default mode. For json/yaml/csv the framework + # serialises the returned result to stdout, so anything printed here + # would corrupt output that callers pipe into jq. + show = get_output_format() == "default" + # Summary if show_summary: summaries = results.get("summary", {}) @@ -164,8 +173,9 @@ def _display_results( summaries.get("text", "") if isinstance(summaries, dict) else "" ) if summary_text: - console.print("\n[green]Summary:[/green]") - console.print(f" {summary_text}") + if show: + console.print("\n[green]Summary:[/green]") + console.print(f" {summary_text}") read_result.summary = summary_text # Sentiment @@ -175,9 +185,11 @@ def _display_results( average = sentiments.get("average", {}) sentiment_val = average.get("sentiment", "") sentiment_score = average.get("sentiment_score", 0.0) - console.print( - f"\n[green]Sentiment:[/green] {sentiment_val} ({sentiment_score:.2f})" - ) + if show: + console.print( + f"\n[green]Sentiment:[/green] " + f"{sentiment_val} ({sentiment_score:.2f})" + ) read_result.sentiment = sentiment_val read_result.sentiment_score = float(sentiment_score) @@ -188,7 +200,8 @@ def _display_results( topics_data.get("segments", []) if isinstance(topics_data, dict) else [] ) if segments: - console.print("\n[green]Topics:[/green]") + if show: + console.print("\n[green]Topics:[/green]") seen_topics: set[str] = set() for seg in segments: for topic in seg.get("topics", []): @@ -196,7 +209,8 @@ def _display_results( if topic_name and topic_name not in seen_topics: seen_topics.add(topic_name) confidence = topic.get("confidence_score", 0.0) - console.print(f" • {topic_name} ({confidence:.0%})") + if show: + console.print(f" • {topic_name} ({confidence:.0%})") read_result.topics = segments # Intents @@ -208,7 +222,8 @@ def _display_results( else [] ) if segments: - console.print("\n[green]Intents:[/green]") + if show: + console.print("\n[green]Intents:[/green]") seen_intents: set[str] = set() for seg in segments: for intent in seg.get("intents", []): @@ -216,7 +231,8 @@ def _display_results( if intent_name and intent_name not in seen_intents: seen_intents.add(intent_name) confidence = intent.get("confidence_score", 0.0) - console.print(f" • {intent_name} ({confidence:.0%})") + if show: + console.print(f" • {intent_name} ({confidence:.0%})") read_result.intents = segments return read_result diff --git a/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/command.py b/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/command.py index 1d207a6..2b127a7 100644 --- a/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/command.py +++ b/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/command.py @@ -11,6 +11,7 @@ BaseResult, Config, DeepgramClient, + get_output_format, ) from rich.console import Console from rich.table import Table @@ -18,6 +19,9 @@ from .models import RequestInfo, RequestsResult console = Console() +# Status/progress chrome must never touch stdout, or it corrupts JSON/CSV +# output that callers pipe into jq and friends. +status_console = Console(stderr=True) class RequestsCommand(BaseCommand): @@ -144,7 +148,7 @@ def handle( last_day=kwargs.get("last_day", False), ) except Exception as e: - console.print(f"[red]Error:[/red] {e}") + status_console.print(f"[red]Error:[/red] {e}") return BaseResult(status="error", message=str(e)) def _list_requests( @@ -176,7 +180,7 @@ def _list_requests( start_dt = datetime.fromisoformat(start_date) end_dt = datetime.fromisoformat(end_date) if end_date else datetime.now() - console.print("[blue]Fetching request history...[/blue]") + status_console.print("[blue]Fetching request history...[/blue]") result = client.list_requests( project_id=project_id, @@ -191,18 +195,10 @@ def _list_requests( requests_raw = result.get("requests", []) if not requests_raw: - console.print("[yellow]No requests found[/yellow]") + status_console.print("[yellow]No requests found[/yellow]") return RequestsResult(status="info", message="No requests found") req_models: list[RequestInfo] = [] - table = Table(title="API Requests", show_header=True, header_style="bold blue") - table.add_column("Time", style="dim") - table.add_column("Endpoint", style="cyan") - table.add_column("Method") - table.add_column("Status") - table.add_column("Duration") - table.add_column("Request ID", style="dim") - for r in requests_raw: req_data = ( r @@ -222,20 +218,35 @@ def _list_requests( ) req_models.append(info) - status_style = "green" if info.status == "succeeded" else "red" - table.add_row( - info.created[:19] if info.created else "-", - info.path or "-", - info.method or "-", - f"[{status_style}]{info.status}[/{status_style}]", - f"{info.duration:.2f}s" if info.duration else "-", - info.request_id[:12] + "..." - if len(info.request_id) > 12 - else info.request_id, + # Render the human table only in default mode. For json/yaml/csv the + # framework serialises the returned result to stdout, so printing the + # table here would prepend non-parseable text to that output. + if get_output_format() == "default": + table = Table( + title="API Requests", show_header=True, header_style="bold blue" ) + table.add_column("Time", style="dim") + table.add_column("Endpoint", style="cyan") + table.add_column("Method") + table.add_column("Status") + table.add_column("Duration") + table.add_column("Request ID", style="dim") + + for info in req_models: + status_style = "green" if info.status == "succeeded" else "red" + table.add_row( + info.created[:19] if info.created else "-", + info.path or "-", + info.method or "-", + f"[{status_style}]{info.status}[/{status_style}]", + f"{info.duration:.2f}s" if info.duration else "-", + info.request_id[:12] + "..." + if len(info.request_id) > 12 + else info.request_id, + ) - console.print(table) - console.print(f"\n[dim]{len(req_models)} request(s) shown[/dim]") + console.print(table) + console.print(f"\n[dim]{len(req_models)} request(s) shown[/dim]") return RequestsResult( status="success", requests=req_models, count=len(req_models) @@ -247,16 +258,22 @@ def _show_request( request_id: str, project_id: str | None, ) -> BaseResult: - console.print(f"[blue]Fetching request details:[/blue] {request_id}") + status_console.print( + f"[blue]Fetching request details:[/blue] {request_id}" + ) result = client.get_request(request_id, project_id=project_id) - console.print("[green]Request Details:[/green]") - for key, value in result.items(): - if isinstance(value, dict): - console.print(f" {key}:") - for k, v in value.items(): - console.print(f" {k}: {v}") - else: - console.print(f" {key}: {value}") + if get_output_format() == "default": + console.print("[green]Request Details:[/green]") + for key, value in result.items(): + if isinstance(value, dict): + console.print(f" {key}:") + for k, v in value.items(): + console.print(f" {k}: {v}") + else: + console.print(f" {key}: {value}") - return BaseResult(status="success", message="Request details displayed") + return RequestsResult( + status="success", + detail=dict(result) if isinstance(result, dict) else {"value": result}, + ) diff --git a/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/models.py b/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/models.py index ca5a537..dc6f2f7 100644 --- a/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/models.py +++ b/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/models.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from deepctl_core import BaseResult from pydantic import BaseModel, Field @@ -18,3 +20,6 @@ class RequestInfo(BaseModel): class RequestsResult(BaseResult): requests: list[RequestInfo] = Field(default_factory=list) count: int = 0 + # Populated by `--show`; carries the full request detail so json/yaml + # output is useful instead of a bare success message. + detail: dict[str, Any] | None = None 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 b02dea5..4420683 100644 --- a/packages/deepctl-cmd-usage/src/deepctl_cmd_usage/command.py +++ b/packages/deepctl-cmd-usage/src/deepctl_cmd_usage/command.py @@ -9,6 +9,7 @@ BaseResult, Config, DeepgramClient, + get_output_format, ) from deepctl_shared_utils import validate_date_format from rich.console import Console @@ -16,6 +17,9 @@ from .models import UsageBucket, UsageResult console = Console() +# Status/progress chrome must never touch stdout, or it corrupts JSON/CSV +# output that callers pipe into jq and friends. +status_console = Console(stderr=True) class UsageCommand(BaseCommand): @@ -108,13 +112,15 @@ def handle( # Determine date range if last_week: start_date, end_date = self._get_last_week_range() - console.print("[blue]Fetching usage for last week...[/blue]") + status_console.print("[blue]Fetching usage for last week...[/blue]") elif last_month: start_date, end_date = self._get_last_month_range() - console.print("[blue]Fetching usage for last month...[/blue]") + status_console.print("[blue]Fetching usage for last month...[/blue]") elif current_month: start_date, end_date = self._get_current_month_range() - console.print("[blue]Fetching usage for current month...[/blue]") + status_console.print( + "[blue]Fetching usage for current month...[/blue]" + ) elif start_date or end_date: # Validate custom date range if start_date and not validate_date_format(start_date): @@ -128,7 +134,7 @@ def handle( message=f"Invalid end date format: {end_date}", ) - console.print( + status_console.print( f"[blue]Fetching usage from " f"{start_date or 'beginning'} to " f"{end_date or 'now'}...[/blue]" @@ -136,7 +142,9 @@ def handle( else: # Default to current month start_date, end_date = self._get_current_month_range() - console.print("[blue]Fetching usage for current month...[/blue]") + status_console.print( + "[blue]Fetching usage for current month...[/blue]" + ) # Get usage data result = client.get_usage(project_id, start_date, end_date) @@ -147,7 +155,7 @@ def handle( ) except Exception as e: - console.print(f"[red]Error fetching usage:[/red] {e}") + status_console.print(f"[red]Error fetching usage:[/red] {e}") return BaseResult(status="error", message=str(e)) def _get_last_week_range(self) -> tuple[str, str]: @@ -233,44 +241,50 @@ def _process_usage_result( ) ) - # Display summary - console.print( - f"\n[green]Usage Summary ({start_date} to {end_date}):[/green]" - ) - console.print(f" Total Hours: {total_hours:,.1f}") - console.print(f" Total Requests: {total_requests:,}") - - if total_tts_characters > 0: - console.print(f" TTS Characters: {total_tts_characters:,}") - - if total_tokens_out > 0: - console.print(f" Tokens Out: {total_tokens_out:,}") - - # Display detailed breakdown if not summary only - if not summary_only and result_dict["results"]: - console.print("\n[blue]Daily Breakdown:[/blue]") - for item in result_dict["results"]: - item_date = item.get("start", "Unknown") - hours = item.get("total_hours", 0) - requests = item.get("requests", 0) - - console.print(f"\n {item_date}:") - console.print(f" Hours: {hours}") - console.print(f" Requests: {requests}") - - if "tts" in item: - console.print( - f" TTS Characters: " - f"{item['tts'].get('characters', 0):,}" - ) - console.print( - f" TTS Requests: {item['tts'].get('requests', 0):,}" - ) - - if "tokens" in item and item["tokens"].get("out", 0) > 0: - console.print( - f" Tokens Out: {item['tokens'].get('out', 0):,}" - ) + # Human summary/breakdown only in default mode. For + # json/yaml/csv the framework serialises the returned result + # to stdout, so printing here would corrupt that output. + if get_output_format() == "default": + console.print( + f"\n[green]Usage Summary " + f"({start_date} to {end_date}):[/green]" + ) + console.print(f" Total Hours: {total_hours:,.1f}") + console.print(f" Total Requests: {total_requests:,}") + + if total_tts_characters > 0: + console.print(f" TTS Characters: {total_tts_characters:,}") + + if total_tokens_out > 0: + console.print(f" Tokens Out: {total_tokens_out:,}") + + # Display detailed breakdown if not summary only + if not summary_only and result_dict["results"]: + console.print("\n[blue]Daily Breakdown:[/blue]") + for item in result_dict["results"]: + item_date = item.get("start", "Unknown") + hours = item.get("total_hours", 0) + requests = item.get("requests", 0) + + console.print(f"\n {item_date}:") + console.print(f" Hours: {hours}") + console.print(f" Requests: {requests}") + + if "tts" in item: + console.print( + f" TTS Characters: " + f"{item['tts'].get('characters', 0):,}" + ) + console.print( + f" TTS Requests: " + f"{item['tts'].get('requests', 0):,}" + ) + + if "tokens" in item and item["tokens"].get("out", 0) > 0: + console.print( + f" Tokens Out: " + f"{item['tokens'].get('out', 0):,}" + ) project_id = result_dict.get("project_id", "") return UsageResult( @@ -280,13 +294,13 @@ def _process_usage_result( total_hours=float(total_hours), ) else: - console.print( + status_console.print( "[yellow]No usage data found for the specified period[/yellow]" ) return BaseResult(status="info", message="No usage data found") except Exception as e: - console.print(f"[red]Error processing usage data:[/red] {e}") + status_console.print(f"[red]Error processing usage data:[/red] {e}") import traceback traceback.print_exc() diff --git a/packages/deepctl-core/src/deepctl_core/__init__.py b/packages/deepctl-core/src/deepctl_core/__init__.py index d29b8be..dc7663a 100644 --- a/packages/deepctl-core/src/deepctl_core/__init__.py +++ b/packages/deepctl-core/src/deepctl_core/__init__.py @@ -50,6 +50,7 @@ "TimingContext", "enable_timing", "get_console", + "get_output_format", "get_timing_summary", "is_agentic", "is_timing_enabled", From 428b6a803a50538b947b7f4f4921c55f2854c698 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Tue, 18 Aug 2026 14:57:56 +0100 Subject: [PATCH 3/5] fix: dedicated RequestDetailResult, reword --speed range, apply ruff format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requests --show now returns RequestDetailResult (just `detail`) instead of RequestsResult, so single-request json/yaml output no longer emits an empty `requests: []` / `count: 0` beside the detail. - Reword --speed in the web card + llms-full.txt to "in 0.05 steps from 0.85 to 1.15" — the CLI accepts a discrete 7-value set, not a continuous range. - Run `ruff format` on projects/requests/usage command.py so `make check` (the CI Lint gate) passes again. --- .../src/deepctl_cmd_projects/command.py | 4 +--- .../src/deepctl_cmd_requests/command.py | 8 +++----- .../src/deepctl_cmd_requests/models.py | 11 ++++++++--- .../src/deepctl_cmd_usage/command.py | 14 ++++---------- web/public/llms-full.txt | 2 +- web/src/pages/index.astro | 2 +- 6 files changed, 18 insertions(+), 23 deletions(-) diff --git a/packages/deepctl-cmd-projects/src/deepctl_cmd_projects/command.py b/packages/deepctl-cmd-projects/src/deepctl_cmd_projects/command.py index 8be63ff..fbdcdf5 100644 --- a/packages/deepctl-cmd-projects/src/deepctl_cmd_projects/command.py +++ b/packages/deepctl-cmd-projects/src/deepctl_cmd_projects/command.py @@ -168,9 +168,7 @@ def _list_projects(self, client: DeepgramClient) -> ProjectsResult | BaseResult: # framework from the returned result, so printing here would prepend # non-parseable text to that output. if get_output_format() == "default": - console.print( - f"[green]Found {len(project_models)} project(s):[/green]" - ) + console.print(f"[green]Found {len(project_models)} project(s):[/green]") for info in project_models: console.print(f" • {info.name}") console.print(f" ID: {info.project_id}") diff --git a/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/command.py b/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/command.py index 2b127a7..51c47cc 100644 --- a/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/command.py +++ b/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/command.py @@ -16,7 +16,7 @@ from rich.console import Console from rich.table import Table -from .models import RequestInfo, RequestsResult +from .models import RequestDetailResult, RequestInfo, RequestsResult console = Console() # Status/progress chrome must never touch stdout, or it corrupts JSON/CSV @@ -258,9 +258,7 @@ def _show_request( request_id: str, project_id: str | None, ) -> BaseResult: - status_console.print( - f"[blue]Fetching request details:[/blue] {request_id}" - ) + status_console.print(f"[blue]Fetching request details:[/blue] {request_id}") result = client.get_request(request_id, project_id=project_id) if get_output_format() == "default": @@ -273,7 +271,7 @@ def _show_request( else: console.print(f" {key}: {value}") - return RequestsResult( + return RequestDetailResult( status="success", detail=dict(result) if isinstance(result, dict) else {"value": result}, ) diff --git a/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/models.py b/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/models.py index dc6f2f7..377a10a 100644 --- a/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/models.py +++ b/packages/deepctl-cmd-requests/src/deepctl_cmd_requests/models.py @@ -20,6 +20,11 @@ class RequestInfo(BaseModel): class RequestsResult(BaseResult): requests: list[RequestInfo] = Field(default_factory=list) count: int = 0 - # Populated by `--show`; carries the full request detail so json/yaml - # output is useful instead of a bare success message. - detail: dict[str, Any] | None = None + + +class RequestDetailResult(BaseResult): + # Returned by `--show`; carries the full request detail so json/yaml + # output is useful instead of a bare success message. Kept separate from + # RequestsResult so single-request output doesn't emit an empty + # `requests: []` / `count: 0` alongside the detail. + detail: dict[str, Any] = Field(default_factory=dict) 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 4420683..caf72df 100644 --- a/packages/deepctl-cmd-usage/src/deepctl_cmd_usage/command.py +++ b/packages/deepctl-cmd-usage/src/deepctl_cmd_usage/command.py @@ -118,9 +118,7 @@ def handle( status_console.print("[blue]Fetching usage for last month...[/blue]") elif current_month: start_date, end_date = self._get_current_month_range() - status_console.print( - "[blue]Fetching usage for current month...[/blue]" - ) + status_console.print("[blue]Fetching usage for current month...[/blue]") elif start_date or end_date: # Validate custom date range if start_date and not validate_date_format(start_date): @@ -142,9 +140,7 @@ def handle( else: # Default to current month start_date, end_date = self._get_current_month_range() - status_console.print( - "[blue]Fetching usage for current month...[/blue]" - ) + status_console.print("[blue]Fetching usage for current month...[/blue]") # Get usage data result = client.get_usage(project_id, start_date, end_date) @@ -246,8 +242,7 @@ def _process_usage_result( # to stdout, so printing here would corrupt that output. if get_output_format() == "default": console.print( - f"\n[green]Usage Summary " - f"({start_date} to {end_date}):[/green]" + f"\n[green]Usage Summary ({start_date} to {end_date}):[/green]" ) console.print(f" Total Hours: {total_hours:,.1f}") console.print(f" Total Requests: {total_requests:,}") @@ -282,8 +277,7 @@ def _process_usage_result( if "tokens" in item and item["tokens"].get("out", 0) > 0: console.print( - f" Tokens Out: " - f"{item['tokens'].get('out', 0):,}" + f" Tokens Out: {item['tokens'].get('out', 0):,}" ) project_id = result_dict.get("project_id", "") diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index 03b6b46..2544e4d 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -156,7 +156,7 @@ dg speak --file script.txt # From file |------|-------------| | `--output` | Save to file path | | `--model` | Voice model — `flux-alexis-en` (default, streaming) or `aura-2-asteria-en` / `aura-2-selena-es` (REST batch) | -| `--speed` | Flux TTS only — speech-rate multiplier from 0.85 to 1.15 (1.00 = nominal) | +| `--speed` | Flux TTS only — speech-rate multiplier in 0.05 steps from 0.85 to 1.15 (1.00 = nominal) | | `--expressivity` | Flux TTS only (beta) — expressive range -2 to 2 (0 = nominal) | | `--encoding` | Output encoding (mp3, linear16, flac, wav, ogg) | | `--file` | Read text from file | diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index b86d1e1..18748bc 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -91,7 +91,7 @@ const commandCards: CommandCard[] = [ lines: ['dg speak "Hello from Deepgram" -o hello.wav'] }, { cmd: 'speak', icon: 'speaker', color: 'purple', title: 'Tune pace and expressivity', - desc: 'Flux-only controls: --speed from 0.85 to 1.15, and --expressivity from -2 to 2 (beta).', + desc: 'Flux-only controls: --speed in 0.05 steps from 0.85 to 1.15, and --expressivity from -2 to 2 (beta).', lines: ['dg speak "So exciting!" --speed 1.05 \\', ' --expressivity 2 -o lively.wav'] }, { cmd: 'speak', icon: 'speaker', color: 'purple', title: 'Pick an Aura voice', From 74f0bdfe8f00df3d452442ca9bb3488799fd9b58 Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Tue, 18 Aug 2026 14:58:05 +0100 Subject: [PATCH 4/5] test: add -o json output-format gating tests across account commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the three rendering shapes changed by the -o json fix — table (requests, models), list (projects, new test file), and inline (read). Each asserts that in json mode the command writes nothing to the stdout console while still returning a fully-populated result, and that default mode still renders. Also pins RequestDetailResult for `requests --show` (no empty requests/count fields in the payload). --- .../tests/unit/test_models_command.py | 60 +++++++++++- .../tests/unit/test_projects_command.py | 73 ++++++++++++++ .../tests/unit/test_read_command.py | 71 ++++++++++++++ .../tests/unit/test_requests_command.py | 94 ++++++++++++++++++- 4 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 packages/deepctl-cmd-projects/tests/unit/test_projects_command.py diff --git a/packages/deepctl-cmd-models/tests/unit/test_models_command.py b/packages/deepctl-cmd-models/tests/unit/test_models_command.py index 4cd75db..de4192d 100644 --- a/packages/deepctl-cmd-models/tests/unit/test_models_command.py +++ b/packages/deepctl-cmd-models/tests/unit/test_models_command.py @@ -1,6 +1,6 @@ """Tests for models command.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from deepctl_cmd_models.command import ModelsCommand @@ -256,3 +256,61 @@ def test_models_result_serialization(self): assert data["count"] == 1 assert len(data["models"]) == 1 assert data["models"][0]["name"] == "Nova-3" + + +class TestModelsOutputGating: + """stdout stays machine-parseable in json/yaml/csv modes. + + In any non-``default`` output mode the command must not write its human + table to the stdout ``console``; the framework serialises the returned + result to stdout, so a stray print here would corrupt piped JSON. + """ + + @pytest.fixture + def command(self): + return ModelsCommand() + + @staticmethod + def _response(): + return { + "stt": [ + { + "uuid": "stt-uuid-1", + "name": "Nova-3", + "version": "1.0", + "language": "en", + } + ], + "tts": [], + } + + @patch("deepctl_cmd_models.command.get_output_format", return_value="json") + @patch("deepctl_cmd_models.command.console") + def test_json_mode_writes_nothing_to_stdout(self, mock_console, _fmt, command): + client = Mock(spec=DeepgramClient) + client.list_models.return_value = self._response() + + result = command.handle( + config=Mock(spec=Config), + auth_manager=Mock(spec=AuthManager), + client=client, + ) + + assert isinstance(result, ModelsResult) + assert result.count == 1 + assert result.models[0].name == "Nova-3" + mock_console.print.assert_not_called() + + @patch("deepctl_cmd_models.command.get_output_format", return_value="default") + @patch("deepctl_cmd_models.command.console") + def test_default_mode_renders_table(self, mock_console, _fmt, command): + client = Mock(spec=DeepgramClient) + client.list_models.return_value = self._response() + + command.handle( + config=Mock(spec=Config), + auth_manager=Mock(spec=AuthManager), + client=client, + ) + + assert mock_console.print.called diff --git a/packages/deepctl-cmd-projects/tests/unit/test_projects_command.py b/packages/deepctl-cmd-projects/tests/unit/test_projects_command.py new file mode 100644 index 0000000..6e6e539 --- /dev/null +++ b/packages/deepctl-cmd-projects/tests/unit/test_projects_command.py @@ -0,0 +1,73 @@ +"""Tests for projects command.""" + +from __future__ import annotations + +from unittest.mock import Mock, patch + +import pytest +from deepctl_cmd_projects.command import ProjectsCommand +from deepctl_cmd_projects.models import ProjectsResult +from deepctl_core import AuthManager, Config, DeepgramClient + + +class TestProjectsOutputGating: + """stdout stays machine-parseable in json/yaml/csv modes. + + ``projects --list`` prints a human list line-by-line. In any non-``default`` + output mode none of it may reach the stdout ``console`` — the framework + serialises the returned ``ProjectsResult`` to stdout, so a stray print here + would prepend non-parseable text to piped JSON. Status chrome uses + ``status_console`` (stderr) and is intentionally unaffected. + """ + + @pytest.fixture + def command(self): + return ProjectsCommand() + + @staticmethod + def _response(): + return { + "projects": [ + { + "project_id": "proj-1", + "name": "Acme", + "company": "Acme Inc", + } + ] + } + + @patch("deepctl_cmd_projects.command.get_output_format", return_value="json") + @patch("deepctl_cmd_projects.command.console") + def test_list_json_mode_writes_nothing_to_stdout( + self, mock_console, _fmt, command + ): + client = Mock(spec=DeepgramClient) + client.get_projects.return_value = self._response() + + result = command.handle( + config=Mock(spec=Config), + auth_manager=Mock(spec=AuthManager), + client=client, + list=True, + ) + + # Result still fully populated for the framework to serialise. + assert isinstance(result, ProjectsResult) + assert result.count == 1 + assert result.projects[0].project_id == "proj-1" + mock_console.print.assert_not_called() + + @patch("deepctl_cmd_projects.command.get_output_format", return_value="default") + @patch("deepctl_cmd_projects.command.console") + def test_list_default_mode_prints_list(self, mock_console, _fmt, command): + client = Mock(spec=DeepgramClient) + client.get_projects.return_value = self._response() + + command.handle( + config=Mock(spec=Config), + auth_manager=Mock(spec=AuthManager), + client=client, + list=True, + ) + + assert mock_console.print.called diff --git a/packages/deepctl-cmd-read/tests/unit/test_read_command.py b/packages/deepctl-cmd-read/tests/unit/test_read_command.py index 2b4a548..6bd11e9 100644 --- a/packages/deepctl-cmd-read/tests/unit/test_read_command.py +++ b/packages/deepctl-cmd-read/tests/unit/test_read_command.py @@ -301,3 +301,74 @@ def test_read_result_serialization(self): assert data["summary"] == "Test summary" assert data["sentiment"] == "negative" assert data["sentiment_score"] == 0.3 + + +class TestReadOutputGating: + """stdout stays machine-parseable in json/yaml/csv modes. + + ``read`` prints its analysis inline (summary/sentiment/topics/intents). In + any non-``default`` output mode none of that may reach the stdout + ``console`` — the framework serialises the returned ``ReadResult`` to + stdout, so a stray print would corrupt piped JSON. The result must still + carry the parsed fields regardless of format. + """ + + @pytest.fixture + def command(self): + return ReadCommand() + + @pytest.fixture + def analyze_response(self): + return { + "results": { + "summary": {"text": "A summary of the content"}, + "sentiments": { + "average": { + "sentiment": "positive", + "sentiment_score": 0.85, + } + }, + } + } + + @patch("deepctl_cmd_read.command.get_output_format", return_value="json") + @patch("deepctl_cmd_read.command.console") + def test_json_mode_writes_nothing_to_stdout( + self, mock_console, _fmt, command, analyze_response + ): + client = Mock(spec=DeepgramClient) + client.analyze_text.return_value = analyze_response + + result = command.handle( + config=Mock(spec=Config), + auth_manager=Mock(spec=AuthManager), + client=client, + text="Hello world", + sentiment=True, + summarize=True, + ) + + # Parsed fields still populated for the framework to serialise. + assert result.status == "success" + assert result.sentiment == "positive" + assert result.summary == "A summary of the content" + mock_console.print.assert_not_called() + + @patch("deepctl_cmd_read.command.get_output_format", return_value="default") + @patch("deepctl_cmd_read.command.console") + def test_default_mode_prints_analysis( + self, mock_console, _fmt, command, analyze_response + ): + client = Mock(spec=DeepgramClient) + client.analyze_text.return_value = analyze_response + + command.handle( + config=Mock(spec=Config), + auth_manager=Mock(spec=AuthManager), + client=client, + text="Hello world", + sentiment=True, + summarize=True, + ) + + assert mock_console.print.called diff --git a/packages/deepctl-cmd-requests/tests/unit/test_requests_command.py b/packages/deepctl-cmd-requests/tests/unit/test_requests_command.py index 3e8cf49..7c8d611 100644 --- a/packages/deepctl-cmd-requests/tests/unit/test_requests_command.py +++ b/packages/deepctl-cmd-requests/tests/unit/test_requests_command.py @@ -4,7 +4,11 @@ import pytest from deepctl_cmd_requests.command import RequestsCommand -from deepctl_cmd_requests.models import RequestInfo, RequestsResult +from deepctl_cmd_requests.models import ( + RequestDetailResult, + RequestInfo, + RequestsResult, +) from deepctl_core import AuthManager, BaseResult, Config, DeepgramClient @@ -222,6 +226,94 @@ def test_handle_error( assert "API connection failed" in result.message +class TestRequestsOutputGating: + """stdout stays machine-parseable in json/yaml/csv modes. + + In any non-``default`` output mode the command must not write its human + table/detail to the stdout ``console`` — the framework serialises the + returned result to stdout, so a stray print here would prepend + non-parseable text to piped JSON. Status chrome uses ``status_console`` + (stderr) and is intentionally unaffected. + """ + + @pytest.fixture + def command(self): + return RequestsCommand() + + def _handle_list(self, command, client): + return command.handle( + config=Mock(spec=Config), + auth_manager=Mock(spec=AuthManager), + client=client, + ) + + @staticmethod + def _list_response(): + return { + "requests": [ + { + "request_id": "req-1", + "created": "2024-01-01T00:00:00Z", + "path": "/v1/listen", + "method": "sync", + "response": {"code": 200}, + "duration": 1.5, + } + ] + } + + @patch("deepctl_cmd_requests.command.get_output_format", return_value="json") + @patch("deepctl_cmd_requests.command.console") + def test_list_json_mode_writes_nothing_to_stdout( + self, mock_console, _fmt, command + ): + client = Mock(spec=DeepgramClient) + client.list_requests.return_value = self._list_response() + + result = self._handle_list(command, client) + + # Result still fully populated for the framework to serialise. + assert isinstance(result, RequestsResult) + assert result.count == 1 + assert result.requests[0].request_id == "req-1" + # Nothing written to the stdout console. + mock_console.print.assert_not_called() + + @patch("deepctl_cmd_requests.command.get_output_format", return_value="default") + @patch("deepctl_cmd_requests.command.console") + def test_list_default_mode_renders_table(self, mock_console, _fmt, command): + client = Mock(spec=DeepgramClient) + client.list_requests.return_value = self._list_response() + + self._handle_list(command, client) + + assert mock_console.print.called + + @patch("deepctl_cmd_requests.command.get_output_format", return_value="json") + @patch("deepctl_cmd_requests.command.console") + def test_show_json_mode_returns_detail_only(self, mock_console, _fmt, command): + client = Mock(spec=DeepgramClient) + client.get_request.return_value = { + "request_id": "req-1", + "path": "/v1/listen", + } + + result = command.handle( + config=Mock(spec=Config), + auth_manager=Mock(spec=AuthManager), + client=client, + show="req-1", + ) + + assert isinstance(result, RequestDetailResult) + assert result.detail == {"request_id": "req-1", "path": "/v1/listen"} + # Dedicated model: no empty requests/count noise beside the detail. + dumped = result.model_dump() + assert "requests" not in dumped + assert "count" not in dumped + mock_console.print.assert_not_called() + + class TestRequestsModels: """Test cases for requests models.""" From c35084e4573160bab80c1123e0c228613d6a25ad Mon Sep 17 00:00:00 2001 From: Greg Holmes Date: Tue, 18 Aug 2026 15:15:27 +0100 Subject: [PATCH 5/5] fix: correct speak --encoding docs and keep billing period header on stdout Review follow-ups on #97: - llms-full.txt: the `dg speak` flag table listed `--encoding` as (mp3, linear16, flac, wav, ogg). wav/ogg are `--container` values, not encodings, and Flux (the default engine) rejects everything but linear16/mulaw/alaw. Rewrote the `--encoding` row to match the CLI's own help (Aura vs Flux sets) and added a `--container` row. This file feeds AI crawlers, so the stale list actively taught broken commands. - billing: in default mode the "Billing Period" header printed to the stderr status console while its table printed to stdout, so `dg billing --breakdown > out.txt` dropped the header. Route the header through the stdout console to match the table (json/yaml/csv still return early and render nothing). Added stream-routing tests. --- .../src/deepctl_cmd_billing/command.py | 2 +- .../tests/unit/test_billing_command.py | 70 +++++++++++++++++-- web/public/llms-full.txt | 3 +- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/deepctl-cmd-billing/src/deepctl_cmd_billing/command.py b/packages/deepctl-cmd-billing/src/deepctl_cmd_billing/command.py index 9ef94d4..080ede3 100644 --- a/packages/deepctl-cmd-billing/src/deepctl_cmd_billing/command.py +++ b/packages/deepctl-cmd-billing/src/deepctl_cmd_billing/command.py @@ -196,7 +196,7 @@ def _show_breakdown( period = resolution.get("period", "") amount = resolution.get("amount", 0) if period: - status_console.print( + console.print( f"\n[green]Billing Period:[/green] {period} ({amount} units)" ) diff --git a/packages/deepctl-cmd-billing/tests/unit/test_billing_command.py b/packages/deepctl-cmd-billing/tests/unit/test_billing_command.py index df525ee..57f6ae4 100644 --- a/packages/deepctl-cmd-billing/tests/unit/test_billing_command.py +++ b/packages/deepctl-cmd-billing/tests/unit/test_billing_command.py @@ -1,6 +1,6 @@ """Tests for billing command.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from deepctl_cmd_billing.command import BillingCommand @@ -222,9 +222,7 @@ def test_handle_breakdown_with_dates( grouping="tags", ) - def test_handle_error( - self, command, mock_config, mock_auth_manager, mock_client - ): + def test_handle_error(self, command, mock_config, mock_auth_manager, mock_client): """Test client exception returns error status.""" mock_client.get_balances.side_effect = Exception("API connection failed") @@ -284,3 +282,67 @@ def test_billing_result_serialization(self): assert data["balances"][0]["balance_id"] == "bal-1" assert data["balances"][0]["amount"] == 100.0 assert data["breakdown"]["resolution"]["period"] == "monthly" + + +class TestBillingBreakdownStreamRouting: + """In default mode the breakdown header and table share the stdout stream. + + The 'Billing Period' header must go to the stdout ``console`` (with the + table), not ``status_console`` (stderr) — otherwise a default-mode + ``dg billing --breakdown > out.txt`` keeps the table but silently drops + the header. In json/yaml/csv modes nothing is rendered (the framework + serialises the returned result), so no human output touches either stream. + """ + + @pytest.fixture + def command(self): + return BillingCommand() + + @staticmethod + def _breakdown_response(): + return { + "resolution": {"period": "monthly", "amount": 1}, + "results": [ + {"start": "2026-08-01", "amount": 12.5, "units": "usd"}, + ], + } + + @patch("deepctl_cmd_billing.command.get_output_format", return_value="default") + @patch("deepctl_cmd_billing.command.status_console") + @patch("deepctl_cmd_billing.command.console") + def test_default_mode_period_on_stdout_not_stderr( + self, mock_console, mock_status_console, _fmt, command + ): + client = Mock(spec=DeepgramClient) + client.get_billing_breakdown.return_value = self._breakdown_response() + + command._show_breakdown(client, None, BillingResult(status="success")) + + stdout_text = " ".join( + str(c.args[0]) for c in mock_console.print.call_args_list if c.args + ) + stderr_text = " ".join( + str(c.args[0]) for c in mock_status_console.print.call_args_list if c.args + ) + # Header rides stdout with the table... + assert "Billing Period" in stdout_text + # ...and never leaks to stderr; only the "Fetching" chrome does. + assert "Billing Period" not in stderr_text + assert "Fetching billing breakdown" in stderr_text + + @patch("deepctl_cmd_billing.command.get_output_format", return_value="json") + @patch("deepctl_cmd_billing.command.status_console") + @patch("deepctl_cmd_billing.command.console") + def test_json_mode_renders_no_human_output( + self, mock_console, mock_status_console, _fmt, command + ): + client = Mock(spec=DeepgramClient) + client.get_billing_breakdown.return_value = self._breakdown_response() + result = BillingResult(status="success") + + command._show_breakdown(client, None, result) + + # Result still carries the breakdown for the framework to serialise. + assert result.breakdown == self._breakdown_response() + # No table/header printed to stdout in json mode. + mock_console.print.assert_not_called() diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index 2544e4d..ca3eced 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -158,7 +158,8 @@ dg speak --file script.txt # From file | `--model` | Voice model — `flux-alexis-en` (default, streaming) or `aura-2-asteria-en` / `aura-2-selena-es` (REST batch) | | `--speed` | Flux TTS only — speech-rate multiplier in 0.05 steps from 0.85 to 1.15 (1.00 = nominal) | | `--expressivity` | Flux TTS only (beta) — expressive range -2 to 2 (0 = nominal) | -| `--encoding` | Output encoding (mp3, linear16, flac, wav, ogg) | +| `--encoding` | Audio encoding — Aura: mp3, linear16, flac, mulaw, alaw, opus, aac; Flux: linear16 (default), mulaw, alaw | +| `--container` | Audio container — none, wav, ogg (Aura only; Flux auto-wraps linear16 in WAV) | | `--file` | Read text from file | ---