Skip to content

Release 0.5.0: faster startup, agent mode, streaming and one transport - #165

Merged
dg-coreylweathers merged 101 commits into
mainfrom
release/0.5.0
Nov 12, 2026
Merged

dg-coreylweathers merged 101 commits into
mainfrom
release/0.5.0

Conversation

@dg-coreylweathers

@dg-coreylweathers dg-coreylweathers commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

The 0.5.0 release, based on 0.4.1 (#159). Review after 0.4.1, and merge only after 0.4.1 is released. 88 commits, on 0.4.1's head 55d3e980.

Startup, lockstep versions and dependency floors (1–10)

Startup work: dg --version and dg mcp start without importing click, commands load from a generated manifest, plugin discovery and help are cached, and config loads without pydantic on the help path. Also adds the competitive benchmark harness, versions every first-party package in lockstep, and raises the dependency floors (click 8.2, caps on runtime majors).

Commits
  • a6a212a feat(bench): add competitive benchmark harness with gates
  • 27f5859 perf(cli): start dg without importing click for --version and mcp
  • 1ccf4a0 perf(core): import deepctl_core names lazily
  • a3c698a build(release): version every first-party package in lockstep
  • 92d0130 build(deps): require click 8.2 and cap majors of runtime deps
  • b7fd286 feat(cli): load commands from a generated manifest
  • 745e258 perf(cli): cache plugin discovery and help output
  • edab526 refactor(commands): keep network modules out of command import time
  • 394f6c2 perf(core): load config without pydantic on the help path
  • 680e5fb perf(cli): render subcommand help from specs

Login for agents, and listen, speak and debug streaming (11–28)

dg login accepts real API keys and asks for --yes instead of cancelling, and agents can finish device login with --complete. Live dg listen finishes cleanly on Ctrl-C and end of input with bounded memory; dg speak streams audio as it arrives and gains --out-file (-o/--output for the audio file is deprecated). Agent mode drops status chrome from stderr. dg debug network sends no stored credentials, debug browser and probe run on websockets, and numpy, ffmpeg-python and requests are dropped.

Commits
  • a5052b6 fix(login): accept real API keys and ask for --yes instead of cancelling
  • 582c3f0 feat(login): let agents finish device login with --complete
  • e80a8a3 perf(core): drop the pre-flight key check and reuse one HTTP client
  • b0e0780 fix(output): drop status chrome from stderr in agent mode
  • c58d7b5 fix(listen): finish live streams cleanly on Ctrl-C and end of input
  • 9ddea92 perf(listen): bound live-stream memory and frame latency
  • b80e66f fix(listen): keep machine output to the final result
  • ee0b1cc perf(speak): stream audio to pipes and players as it arrives
  • 361bf3e feat(speak): add --out-file and deprecate -o/--output for the audio file
  • 6244ab9 fix(speak): don't read stdin or write audio to a pipe in agent mode
  • c915891 fix(listen): report a failed stdout write as an output error
  • 1550284 fix(cli): treat Ctrl-C while the CLI is loading as cancelled
  • 8cc787e perf(listen): stream prerecorded uploads from disk
  • ecba215 feat(listen): add --timeout for prerecorded transcription
  • 4491eb1 perf(listen): check URL sources without fetching them
  • 61eaa62 fix(debug): send no stored credentials from debug network
  • 960b0c3 build(deps): drop numpy, ffmpeg-python and requests from commands
  • 9466058 refactor(debug): run debug browser and probe on websockets

Credentials resolver, transport, release workflow and update check (29–36)

Credentials are resolved in one place and every API request goes through one transport, so the host checks from 0.4.1 apply everywhere. Releases publish from release.yml with trusted publishing. The update check never waits, detects pipx, uv and npm installs correctly, and reports an unreachable index as an error.

Commits
  • b9d015a feat(core): resolve credentials in one place
  • 1a409f2 fix(core): send API requests through one transport
  • 85dedaf fix(debug): take the probe's and network's host from the transport
  • 327547e ci: keep the CLI smoke runs from sending telemetry
  • 4d194c0 ci: publish every release from release.yml with trusted publishing
  • 9dc82c1 fix(update): never wait for the update check
  • 9c18f1b fix(update): detect pipx, uv and npm installs correctly
  • fc32cc3 fix(update): report an unreachable index as an error

Telemetry switches, dg init, JSON output and listen output formats (37–49)

Telemetry switches (DO_NOT_TRACK, CI, DEEPCTL_TELEMETRY=log|off) are accepted; this version still sends no telemetry. dg init clones only Deepgram templates, keeps repository settings from running programs, can resume, and needs --yes for setup steps outside a terminal. JSON output off a terminal skips rich. dg listen prints the server body for -o json, adds -o text, and saves large results to a file in agent sessions.

Commits
  • a3c4a4a feat(telemetry): honor DO_NOT_TRACK and CI
  • 6096c73 perf(telemetry): skip the flush when no report was queued
  • f76f032 feat(telemetry): add DEEPCTL_TELEMETRY=log and =off
  • 707672e docs(telemetry): publish the report's field schema
  • 8823fb0 fix(init): clone only Deepgram templates, with plain names
  • 4a762a2 fix(init): keep repository settings from running programs
  • bbaa6ba feat(init): resume a rerun in a clone dg init recorded
  • a4eefcb feat(init): setup steps need --yes outside a terminal
  • 5e85c87 perf(output): write JSON off a terminal without rich
  • dd53c99 test(transport): close the shared client after each test
  • 1034dba feat(listen): print the server's body for prerecorded -o json
  • 3aa3436 feat(listen): add -o text for the transcript alone
  • 39b2847 feat(listen): save large results to a file in agent sessions

dg mcp, agent mode, CLI and config fixes (50–73)

dg mcp shares one proxy and tool table and keeps stdout for JSON-RPC from the start. AI agents are detected from one documented table, and dg help agent-mode explains agent mode. Warnings stay off stdout, config fixes (XDG on macOS, locked config files), Flux model naming, per-OS stdin and microphone help, and coded messages for cancel and unexpected errors. Ends with review fix-ups (a plugin named like a built-in alias is refused before import, dg login --complete checks the base URL, test and README fixes).

Commits
  • 0a33a42 refactor(mcp): share one proxy and tool table for every dg mcp path
  • 9b32d50 fix(mcp): keep stdout for JSON-RPC from the start of dg mcp
  • 4eee896 docs(mcp): describe dg mcp as the documentation search it is
  • eee53c1 feat(core): detect AI agents from one documented table
  • 6a74a66 feat(help): explain agent mode with dg help agent-mode
  • aefb1ba fix(cli): keep warnings and notices off stdout
  • b7d589d style: say what each check does in config and skills comments
  • c1dc7e9 fix(cli): escape characters the output encoding can't write
  • 4318989 fix(login): show a profile's config-file key in profiles --list
  • 24c31b1 fix(config): count ~/.config on macOS when XDG_CONFIG_HOME names it
  • ac2d323 fix(config): wait for a locked config file once per run
  • 337ba3c fix(listen,speak): name Flux models, not API versions
  • 37e2414 fix(listen): give each OS its own stdin and microphone help
  • b9048ef test: give timing checks room on a busy host
  • 26a7890 fix(cli): use coded messages for cancel and unexpected errors
  • 7c415cb test(debug-probe): run the ignored-SIGINT check only on Linux
  • bf22611 fix(plugin): refuse a plugin named like a built-in alias before importing it
  • 020cec3 fix(login): check the base URL before dg login --complete saves a key
  • 085b54c test: merge the two fixtures that clear AI agent markers
  • d19dad9 docs: compare with 0.4.1 in comments, the release before 0.5.0
  • 62fe497 build: regenerate the stale package READMEs and run the generator with uv
  • b2e1889 docs(telemetry): say this version sends no telemetry
  • 659e71b test(cli): patch both built-in tables in the missing-package test
  • c5511b2 test(mcp): let the docs lint pass the entry point's transport refusal

Review and CI fixes, and the release marker (74–88)

Fixes from the review and from this PR's first CI run. Windows: the pending device login and dg init's records are saved by path where a directory can't be opened; --out-file and -o work without os.fchmod, write in binary mode, and don't take a pipe or console for another file; the tests now collect and run there. macOS: the agent trust root follows ~/.config only where platformdirs does, and the terminal, config path, socket and signal tests pass. Tests that signal their own process run in a child pytest process. The help cache key records the telemetry sink, an agent's state directory is under the account's real home, an update check that didn't finish is retried after an hour, dg mcp needs mcp 1.23.1, and the tests run against installed packages at their floors. Ends with chore: release 0.5.0, which carries Release-As: 0.5.0.

Commits
  • f8467e8 fix(login): save the pending login and init's records on Windows
  • df1505f fix(speak): write --out-file and -o files correctly on Windows
  • 9935ef2 fix(speak): don't take a Windows pipe or console for another file
  • 6930c53 test: let the 0.5.0 tests run on Windows
  • 63762d0 fix(config): on macOS, follow ~/.config only where platformdirs does
  • df1e7da test: make the terminal, config path and socket tests pass on macOS
  • e880751 test(speak): send each test signal to one thread, in a child process
  • fc69d48 build(deps): require mcp 1.23.1 or later for dg mcp
  • 5217565 test: let the unit tests run against installed packages at their floors
  • ea7c121 fix(cli): key cached root help on the telemetry sink setting
  • 4ebe797 fix(init): keep an agent's resume records and logins under the real home
  • dfc9e9b fix(update): retry an update check that didn't finish after an hour
  • d8f8096 test(debug-probe): check the Origin refusal on a running proxy
  • cedf89c test(cli): run 0.4.1's output encoding checks on 0.5.0
  • 2e97002 chore: release 0.5.0

Upgrade notes

  • Telemetry: none is sent, as in 0.4.1. DO_NOT_TRACK, CI and DEEPCTL_TELEMETRY=log|off are accepted, and only affect a local loopback sink set with DEEPCTL_TELEMETRY_DSN, which is for tests. --no-telemetry is still accepted and documented as doing nothing.
  • Versions: every package moves to 0.5.0 together; deepctl-telemetry, for example, goes from 0.0.7 to 0.5.0. Each package pins deepctl==0.5.0. Upgrading one deepctl-cmd-* now upgrades the whole set, or fails to resolve, and an older deepctl-cmd-* can't be mixed with a newer deepctl. The packages no longer get their own GitHub releases or tags.
  • --version: prints the name it was run as: dg, version 0.5.0 or deepgram, version 0.5.0 (0.4.x printed deepctl, version … for every console script). deepctl and python -m deepctl still print deepctl, version ….
  • listen:
    • -o json and -o yaml print the server's response body, for everyone, not only in agent sessions. A 2xx response whose body isn't a JSON object now exits 1. DEEPCTL_LISTEN_JSON=sdk brings back 0.4.x's SDK-shaped JSON for prerecorded audio.
    • Live -o json and -o yaml print only the final result, not every interim message.
    • In an agent session, dg listen with no source (no file, URL or --stdin) is refused instead of opening the microphone.
    • URL sources are no longer fetched or checked before the request; --check-url asks for the check.
    • Without the pre-flight key check, a bad or missing key is reported from the API's answer, so those error texts changed.
  • speak: -o/--output FILE is deprecated in favor of --out-file. -o FILE now replaces the file through a temp file, so it never writes through a symlink.
  • Agent sessions:
    • no status chrome on stderr, and warnings stay off stdout;
    • large listen results are saved to a file;
    • speak doesn't read stdin or write audio to a pipe;
    • dg init's resume records and a pending device login live under the account's own home directory, not $HOME.
  • init: setup steps need --yes when not run from a terminal, and outside a terminal the app starts only with --start. Only Deepgram templates are cloned.
  • login:
    • -y/--yes answers the confirmations, and -f/--force-write still works as the same flag;
    • without a terminal, the confirmations return confirmation_required instead of "cancelled";
    • --complete finishes an agent's device login.
  • api: dg api sends a User-Agent, X-Fern-Platform and a session ID header, only to hosts the key may be sent to.
  • debug network: sends no stored credentials.
  • Update check: never waits. A check that failed, or didn't finish before dg exited, is tried again after an hour instead of the next day.
  • Dependencies:
    • click >= 8.2, with caps below the next major of click, httpx, pydantic, rich, platformdirs, keyring and websockets;
    • deepctl-cmd-mcp needs mcp >= 1.23.1 (was 1.22.0);
    • numpy, ffmpeg-python, aiohttp and typer are no longer installed, and the commands no longer depend on requests. The deepctl package itself still lists requests.
  • Plugin API: DeepgramClient.ws_base_url() is removed, and deepctl_core.websocket.RedirectRefused is now the transport's error, no longer a click.ClickException.
  • Releases: published from release.yml with trusted publishing. This needs the release environment and the release GitHub App (RELEASE_APP_CLIENT_ID and RELEASE_APP_PRIVATE_KEY) set up before the 0.5.0 release PR.

Notes

  • Tests on this head: Docker (Linux) Python 3.10 5,648 passed and Python 3.14 5,649 passed, 0 failed; lint, types, lock, floors, READMEs and build pass. The lowest-direct-dependencies job passes on Python 3.10 and 3.12 in Docker. macOS (local) Python 3.10 5,406 passed and Python 3.14 5,407 passed, 0 failed, and the signal and config race tests passed 30 runs in a row on each. Windows can't be run locally: the Windows paths are tested on every OS by taking away what Windows lacks, and CI on this PR runs the real matrix.
  • After 0.4.1's release PR merges, this branch is rebased onto main and the lockstep versions are re-baselined to the versions that release set.
  • Merge with a merge commit, not a squash: release-please reads the individual commits. The last commit carries Release-As: 0.5.0; without it, release-please would propose a patch release instead of 0.5.0.

🤖 Generated with Claude Code

@GregHolmes GregHolmes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes

Four blockers remain:

  1. The macOS Python 3.10 job fails because the forged-clone fixture calls TarFile.extractall(filter="tar"), unsupported on that runtime.
  2. Every Windows job blocks for six hours because a test writes 5,000 bytes to an anonymous pipe before starting its reader.
  3. The authenticated MCP client inherits HTTP_PROXY and can send its token to an unapproved proxy.
  4. A closed WebVTT output pipe only silences stdout; live listen still opens the microphone/stdin and uploads audio.

I also left should-fix notes on buffered Flux output, MCP short-option parsing, and the help-path Pydantic fallback.

Verified: 4,313 focused Linux Docker tests pass, but required macOS and Windows CI remain red.

tar.add(work, arcname=".")
dest.mkdir()
with tarfile.open(archive) as tar:
tar.extractall(dest, filter="tar") # type: ignore[call-arg]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[B1] This helper breaks the supported Python 3.10 CI job. TarFile.extractall(filter="tar") is unavailable there, producing four deterministic macOS failures. Keep the forged-clone setup safe with a Python 3.10-compatible extraction helper, then rerun the macOS matrix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5b52a1c. Uses the tar filter when the runtime has it, otherwise extracts member by member (dirs and regular files only, all inside dest). setup-python's macOS 3.10 is 3.10.11, before the backport. Reproduced the 4 failures on a 3.10.11 container and they pass now.

import httpx
from deepctl_core.transport import _no_cookies

client = httpx.AsyncClient(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[B3] Disable environment proxies for this authenticated client. An allowlisted loopback HTTP MCP base is valid, but with HTTP_PROXY set and NO_PROXY empty, HTTPX routes the request and its Authorization: Token ... header to the proxy. Set trust_env=False and add a capture-proxy regression test.

@dg-coreylweathers dg-coreylweathers Nov 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3abbae9. trust_env=False on the MCP client, plus a capture-proxy test for HTTP_PROXY, http_proxy and ALL_PROXY with NO_PROXY empty. The test failed before the change.


def test_reader_reads_capped_frames_then_eof():
r, w = os.pipe()
os.write(w, b"a" * 5000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[B2] This blocks every Windows test job before the reader starts. Windows anonymous pipes have a smaller buffer, so this synchronous 5,000-byte write hangs until the six-hour Actions limit. Start the reader first or write from a producer thread/task; add a per-job timeout so a future deadlock fails promptly.

@dg-coreylweathers dg-coreylweathers Nov 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 352d52f. The writer runs in a thread after the reader starts, the test has a 30s timeout, every test gets --timeout=300, and the matrix job has timeout-minutes: 45. Windows jobs on the last run finished in about 4 min instead of hanging. They're still red, but those are the same 42 Windows failures that were already there behind the hang. None of them are new.

note=note if caption_format == "webvtt" else None
)
except BrokenPipeError:
live.silence_stdout()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[B4] A closed caption pipe must stop before audio capture starts. This only redirects later stdout writes; the method then opens the microphone and WebSocket. dg listen --webvtt ... | head -n1 can continue uploading and billing after its reader exits. Return an output-closed result here and add a header-specific broken-pipe test for both microphone and stdin paths.

@dg-coreylweathers dg-coreylweathers Nov 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 04b73cf. If the header write gets EPIPE we return the quiet closed-output result right there, before the mic opens, stdin is read or the socket connects. Added tests for both the mic and stdin paths, and they failed before the change.

Adds bench/, a Docker-based harness that times dg and a pinned set of
other CLIs and MCP servers, and checks the results against gates.json.

- hyperfine -N timing for --version, --help, subcommand help, cold help
  (page cache dropped), MCP initialize and tools/list, data-path rows
  against a loopback fake API, install size and interpreter floors
- deterministic rows: connect() count under strace plus a
  never-answering proxy, import allowlist and forbidden-module lists,
  source compiles under PYTHONVERBOSE
- rows fail closed: a non-zero exit, unparsed interpreter output or an
  untraceable command records an error, never a value
- every measured container runs with --network none; competitor pins are
  versioned and sha256-verified, base images pinned by digest
- check.py: gates activate at their since version; timing gates are
  report-only until marked blocking; paired base/head rule
  max(10%, 3xMAD, floor), with failed rounds counted as red
- a release profile (dg only, rows that can block), a self-test against
  a fake CLI, and unit tests
- competitive-bench.yml (SHA-pinned actions, read-only permissions, no
  secrets), callable with a ref for a future release job
deepctl.main now imports only sys and os and peeks at argv:

- `dg --version` prints the version constant, byte-identical to 0.4.x
  ("deepctl, version V" for every console script), and exits 0.
- `dg mcp` (stdio, no other options) runs deepctl.mcp._fast, a
  stdlib-only loop that answers initialize, ping and tools/list from
  fixed local text and a local tool table. Every other method
  (server/discover included) gets -32601 locally and never causes a
  connection. The first tools/call after initialize goes to
  deepctl_cmd_mcp.serve_after_handshake, which replays the handshake to
  the local MCP server in deepctl_cmd_mcp.proxy. If the proxy can't
  start, that call gets an isError result and the server keeps running:
  "dg mcp can't start the documentation search: ..." for local causes,
  "dg mcp couldn't reach Deepgram's documentation search: ..." for
  network ones, and 0.4.1's E_KEY_UNTRUSTED + "A person must fix this
  file..." when an agent session left the config file's key unused
  (config notices are flushed to stderr first). stdin EOF exits 0.
- Everything else imports deepctl._cli (the old main.py) and runs Click.

Also:

- deepctl/__init__ is stdlib-only and exports no `main`; new
  deepctl/__main__ makes `python -m deepctl` (and -I) work.
- `dg --version` into a closed pipe or with stdout closed exits quietly.
- main() pops DEEPCTL_LAUNCHER first thing into deepctl.main.LAUNCHER.
- The rich traceback hook (show_locals=False) loads only when an
  exception reaches the top level.
- --timing counts from main()'s first line (startup_imports).
- Past --version, main() marks httpx._main as unavailable, so httpx uses
  its stub CLI and skips pygments and rich.syntax.
- Commands load inside run()'s error handling, not at import. An
  unreadable HOME, plugin venv or cache gives a single warning line
  ("WARN: plugins unavailable: <reason> (while loading <what>)"); other
  plugin errors keep their per-plugin message. Startup notices skip an
  unreadable cache; `dg --help` and `dg <command> --help` run without a
  readable config (help for a group's subcommands still needs one), and
  the telemetry help notice says off when it can't read one.

Old names (cli, load_commands, helpers) still resolve from deepctl.main
(reads only; patch deepctl._cli). git shows _cli.py as a new file, not a
rename of main.py.
deepctl_core/__init__ maps each public name to its submodule through a
PEP 562 __getattr__, so `import deepctl_core` imports nothing else and
`from deepctl_core import Config` imports only config. A TYPE_CHECKING
block keeps the names visible to mypy, pyright and griffe, __dir__ lists
them, `from deepctl_core import *` still resolves all of __all__ (which
is unchanged), and submodules stay reachable as attributes. The module
__getattr__ hooks exist only at run time, so type checkers flag a
misspelled name instead of typing it Any.

Import moves only, no logic changes:

- client.py: the Deepgram SDK loads on first use (_sdk() binds DGClient,
  DeepgramClientEnvironment and ApiError; a module __getattr__ keeps
  patch("deepctl_core.client.DGClient") working).
- auth.py: httpx and keyring load when the first AuthManager is built,
  and `deepctl_core.auth.keyring` is still the real module, so patches
  of it behave as before; PasswordDeleteError and rich.progress move
  into the functions that use them.
- output.py: console and stderr_console are built, and rich imported, on
  first use; rich renderables are imported where they are rendered.

With the httpx CLI guard in main(), `dg --help` no longer loads
pygments.
Every first-party package (the root plus the 29 packages under
packages/, not the plugin example) now ships at the root's version, and
every first-party dependency is pinned `==` that version.

- release-please has one component for all of them. Its extra-files move
  each package's version and every pin line marked `#
  x-release-please-version`, so a release PR proposes one version and
  one tag. The packages no longer get their own GitHub releases. The
  plugin example stays its own component, with the root excluding its
  path.
- Every package declares `deepctl==` the same version. Without that
  back-edge, `pip install -U deepctl-cmd-listen` upgraded only that
  package and left the CLI and the other packages behind; with it, the
  resolver moves the whole set or refuses.
- deepctl-cmd-plugin now depends on deepctl-cmd-update, which it
  imports.
- scripts/check_dependency_floors.py becomes the lockstep check: it
  fails on any version or pin that differs, a `>=` floor, a missing
  back-edge, a package root does not list, an unmarked pin line, a
  missing workspace source, or a first-party requirement it can't read
  line by line, and `--fix` repairs what it can. The CI job is now
  "Lockstep versions".
- uv.lock is regenerated (uv 0.13.6); generated package READMEs no
  longer list first-party pins.

Mixing an older deepctl-cmd-* with a newer deepctl now conflicts;
upgrade deepctl itself.

Release PRs are re-pinned in two jobs, so no third-party code runs
next to a write credential:

- sync-release-pr has no token and no secrets. It runs the lockstep
  check with --fix, `uv lock` and the check again with uv 0.13.6 and a
  hash-pinned `packaging`, refuses any change outside the pyprojects and
  uv.lock, and hands the result over as a patch artifact.
- push-release-pr runs no Python and no uv. In the `release` environment
  it mints a token from the release GitHub App, scoped to this
  repository with contents: write only, applies the patch on the commit
  it was computed against, checks the patch touches only the pyprojects
  and uv.lock, and pushes. The token reaches git through GIT_CONFIG_*
  environment entries, never a URL, an argument or .git/config, with
  hooks off.

Commits pushed with the App token trigger the PR's workflows, so the
required checks run on the release PR's final head; a GITHUB_TOKEN push
triggered none of them. Both jobs refuse anything but a release-please
branch. The App's client ID is the RELEASE_APP_CLIENT_ID variable and
its private key the RELEASE_APP_PRIVATE_KEY secret of the `release`
environment, which only main may deploy to.

For tests, tests/upgrade/run.sh runs the upgrade matrix behind the lockstep
layout against a local PEP 503 index, in --network none containers:

- lockstep: fresh installs with pip, uv pip, uv tool and pipx resolve
  exactly the 30 first-party dists; `-U` of any one dist from a release
  moves all of them to the next one; upgrades from the field versions
  land on the release with every dist at the same version; and a
  sentry-sdk 3.0 prerelease can't be resolved into an install.
- release-please: runs the release-please version the release workflow
  uses over the tree, offline, and checks that one release PR moves
  every version and pin and nothing else.
- layout: the planned 1.0 layout (relocated code, tombstone packages)
  against the field versions.

CI checks the closure at its floors. Every package declares `deepctl==`
the release version, so any install pulls the whole closure. A new
test.yml job (Python 3.10 and 3.12) builds every package and installs
that closure once with every third-party dependency at the lowest
version the 30 pyprojects together allow (wheels only). In that
environment it runs `pip check`, `dg --help` and every command's and
subcommand's --help, and the unit tests, one suite per process. It also
lists declared floors that sit below what the closure resolves. The job
is part of the "All checks" rollup.
Every package now requires click>=8.2.0. The CLI's own tests rely on
click 8.2 APIs (CliRunner capturing stderr separately, and
click.exceptions.NoArgsIsHelpError), so 8.2 is the oldest click the CLI
is tested against; at 8.1 those suites fail even though the commands
themselves still load.

The root also drops its typer dependency: nothing in deepctl imports
typer, so installing deepctl no longer pulls typer (or shellingham) in.

click, httpx, pydantic, rich, platformdirs, keyring and websockets get
an upper bound below their next major version (click<9, httpx<1,
pydantic<3, rich<16, platformdirs<5, keyring<26, websockets<18), next to
the existing sentry-sdk<3 and deepgram-sdk<8 caps. A new major's
prerelease can then never be resolved into an install, the way a
sentry-sdk 3.0 prerelease breaks every uncapped release. The caps change
no resolved version in uv.lock.
Each built-in command has a fragment in src/deepctl/_manifest.d/ with
its module, class, distribution, help summary, hidden aliases, whether
it is a group and whether it needs auth.
deepctl._manifest_build.assemble() checks the fragments and the rules
across them (a command, alias or tool name may appear once; a
subcommand's group must exist) and builds deepctl/_manifest.json. The
setuptools build_py hook writes that file into every wheel (setuptools
77 or newer); a source tree assembles the fragments in memory instead.
The JSON Schema is docs/contracts/manifest.schema.json. A missing or
invalid manifest ends the run with one line on stderr and exit 1.

The root group is a LazyGroup. Root --help lists the built-ins from the
manifest, and a command's module is imported only when that command runs
or shows its own help. Built-ins load from the manifest's module, never
through an entry point, and the reserved names in plugin_manager, hidden
aliases included, come from the manifest. Plugins are discovered only
for a name that isn't built in, for root --help, for shell completion,
for dg plugin list and for a built-in listed in plugins.allow_override,
with the 0.4.1 checks unchanged. A command whose module can't be
imported reports dependency_missing (exit 1, details.dependency names
the distribution).

Hidden aliases such as debug-audio replace the hyphenated-command
preprocessor and its entry point scan; only the command word is
rewritten. An alias always runs its built-in, unless allow_override
lists it and a plugin registers that name. Any other <group>-<x> runs
<group> <x> when <group> is a built-in group and <x> one of its
subcommands, a plugin's included; an unknown one falls through to plugin
discovery. DEEPCTL_EAGER_LOAD=1 loads every command up front as 0.4.x
did (0.5.x only). DEEPCTL_PROG_NAME sets the program name in usage
lines, python -m deepctl shows dg and leaves the working directory off
sys.path. For people, a usage error starts with Click's usage and Try
lines; in agent mode (CI included) it prints only "ERROR: <message>"
and "HINT: Run <prog> <command> --help to see valid options." The hint
for a global option given after the command names the real program and
command. The dependency_missing agent hint reads "Tell the user <prog>
needs <dependency>. Suggested fix: <hint>". Help runs skip the
background update and AI-CLI checks. jsonschema becomes a test
dependency.

dg mcp's fragment has requires_auth false: it reads the key on each
tool call.
Root --help lists plugin commands, which took an entry point scan and an
import of every plugin on each run. The discovery result is now kept in
the user cache dir and reused while its stamp holds: the mtimes of every
sys.path directory and of the plugin venv under $HOME and the real home,
plus agent mode and the working directory under an agent. A cache hit
lists plugins without importing them, pydantic or Config. When no plugin
is installed at all, a quick entry point check skips discovery.

Only a result the cache can reproduce exactly is kept. A discovery that
wrote to stderr (a skipped plugin, a load error) or let a plugin replace
a built-in runs again next time, so its warnings show on every --help.
Each row stores its summary as the paragraphs Click shows, so a plugin
with multi-line help caches too. Rows are checked when written and again
when read: an invalid or reserved name, or text with a character in
Unicode categories Cc, Cf (other than ZWNJ and ZWJ), Zl, Zp, Co or Cn,
keeps a discovery out of the cache, and a file holding one is ignored.
Built-ins always win.

Under an AI agent the discovery cache lives under the real home's cache
directory, never one HOME or XDG_CACHE_HOME points to.

For the native launcher, a root dg --help run saves its output as <real
cache>/help/<version>-<prog>-<width>-<tty>.txt, with <entry>.key.json
listing every file (path, mtime_ns, size, inode or absent), environment
variable, width and tty state the text depends on, and text_sha256,
which binds the key to the text written before it. That happens only
when the launcher sets DEEPCTL_HELP_CACHE=1 (removed from the
environment at startup), outside an AI agent, for a clean run (exit 0,
nothing on stderr) whose stdout is UTF-8, and only if the key's inputs
didn't change while the help was rendered. No key over 32 files or 64
environment variables, and no entry over 256 KiB, is written. The cache
directory comes from the account (getpwuid or the LocalAppData known
folder), never from HOME or XDG_CACHE_HOME. An unchanged entry isn't
rewritten, and entries for other versions are pruned.
docs/contracts/help-cache.md and help-cache-key.schema.json describe the
contract.

Cache directories are 0700 and files 0600, written through a temp file
in the same directory and renamed into place; stale temp files are
pruned. A cache directory that isn't a real directory owned by this
user, or that its group or others can write, is neither read nor
written, and any error skips the cache.

The help paths also stop importing modules they don't use: yaml in
deepctl_core.output loads only to write YAML, secrets in config only to
name a temp file, and rich.markup in hosts only to print a warning.
dg <cmd> --help imports the command's module, so a module-level import
of asyncio, ssl, httpx, websockets or the SDK was paid by every help and
every run of that command. Those imports move into the functions that
use them: asyncio in listen, mcp, update and debug browser/probe; httpx
in api, debug audio, debug toolkit, init, the update version check and
shared input validation; aiohttp in debug browser/probe; requests and
urllib3 in debug network. Modules whose tests patch asyncio or httpx
through the command module resolve the name lazily. deepctl_cmd_update
exports its names lazily, so the per-run startup check no longer
imports the update command.

A test imports every built-in command module from the manifest in a
fresh interpreter and fails if any of these modules loads, and another
runs dg <cmd> --help for every built-in against the subcommand-help
forbidden list. dg debug <sub> --help still imports httpx, ssl and
keyring through the debug group callback; that check is a strict xfail.
Config(scan_only=True) reads the user file and deepgram.yaml and runs
the same trust, permission, custom-key and project-file checks as a full
load, queueing the same notices, but doesn't build the settings. It
merges a file's data only when the merge could fail, so a file the merge
can't take prints the same warning; the environment and --base-url
steps, which only set values, are skipped. A scan-only Config keeps
config_path and user_config_text for telemetry and raises if its
settings are read.

The settings models move to deepctl_core.config_models and load on first
use; yaml loads when a file is parsed. deepctl_core.config and
_plugin_trust import neither, and the old import paths still work.

setup_output() stores quiet on the lazy console instead of building the
rich Console, which is applied when the console is first used.
Each built-in fragment now carries a "help" block: its command's --help
as data (help text with examples and the agent line, and every parameter
with its type, flags and metavar). deepctl._help_spec turns a Click
command into a spec and a spec back into a callback-less command.

python -m deepctl._manifest_build --write-help writes the blocks from
each command as PluginManager builds it, after rebuilding the command
from the spec and comparing the rendered help at three widths. --check
now also fails when a block no longer matches its command. A command
that uses a Click feature a spec can't carry (a shown default, an
environment variable, a custom class) is refused instead of written
incompletely.

assemble() stays stdlib-only. With a help directory it writes one file
per command to deepctl/_help/ (the build hook does this for every
wheel); the blocks never go into _manifest.json, which root --help and
dg mcp parse. _manifest.help_spec() reads the file, or the fragment in a
source tree. The manifest schema describes the block.

dg <cmd> --help and dg <group> <sub> --help for a built-in leaf now
build the command from its help spec instead of importing it, and the
root callback only scans the config files (Config(scan_only=True)).
Click still renders the help, so width, program name and the telemetry
footer are unchanged, and every config notice prints as before.

The spec path is taken only for exactly those argv shapes, for a leaf
with a spec, when plugins.allow_override lists neither the command nor
its group, outside DEEPCTL_EAGER_LOAD, and for a subcommand only when
every entry point its group would scan is the built-in's own; that scan
reads every distribution, so a "yes" is kept next to the discovery cache
for the same plugin stamp, with the same directory and agent rules.
Group help, a command without a spec and everything else take the normal
path. The spec path never writes a help cache entry.

Intended changes: a built-in whose package can't be imported still
prints its help (running it still reports dependency_missing), and no
command module is imported for a leaf's help.

dg debug <sub> --help with an unreadable HOME now exits 0, lazy or eager:
Click runs the debug group's callback before the subcommand
parses --help, and that callback built a Config, which failed. When the
root found no readable config for a --help run, a group callback now
leaves the subcommand to show its help (a subcommand that runs still
fails on its own Config). The toolkit subcommand also treats an
unreadable cache as an empty one instead of failing to load.

New tests compare spec help with the lazy path and with eager loading
across every leaf, width, output environment and config fixture.

CI now runs the debug-toolkit package tests.
dg login no longer checks for an sk-/pk- prefix: Deepgram keys don't
have one, and login_with_api_key already verifies the key with the API.
A 40-hex key used to stop with "Login cancelled" whenever no one could
answer the "Continue anyway?" prompt.

The two login confirmations (DEEPGRAM_API_KEY is set; the profile
already has a saved login) are asked only of a person at a terminal. In
any other run they return confirmation_required, exit 1, with
details.flag "--yes" and a next_step that reruns the command with
--yes, or null when the command line holds a key.
They no longer return "cancelled", which is kept for a person saying no.
-y/--yes answers both; -f/--force-write stays as the same
flag.

BaseCommand gains can_prompt(), the check confirm() already made, so a
command can tell "no one can answer" from "the answer was no".
In an AI agent session (or with DG_AGENT=1, --no-browser, or neither
stdin nor stdout a terminal), dg login now asks for a device code,
saves it and returns at once with the link and code (status "pending",
exit 0). dg login --complete checks once: auth_pending
with details.retry_after until the user approves, then saves the key
as before; auth_denied or auth_expired otherwise, and auth_expired with
next_step "dg login" when no login is waiting. --wait keeps 0.4.0's
behavior of polling until done; a person at a terminal still gets it
by default.

The pending code lives in {state_dir}/login-pending.json: written 0600
through O_CREAT|O_EXCL|O_NOFOLLOW and an atomic replace, in a 0700
directory, holding the device code, interval, expiry, login host and
profile, never a key. state_dir() is ~/.deepctl, the one state-dir
helper later changes reuse. In an AI agent session the file must be
owned by this user with no group or other write bit; otherwise it is
ignored with one WARN: line and left in place. A code is only sent back
to the login host that issued it.

Before any request, login checks that it could save the result: an
unwritable state or config directory is state_unwritable
(E-STATE-UNWRITABLE); in an AI agent session, a user config dg won't
save over is state_unwritable with details.reason "untrusted_config".
Both come before the --yes confirmations, and both apply
to --api-key and --complete too. A home dg can't write no longer stops
every command while Config picks its default path.

The token poll is a POST to the RFC 8628 token endpoint, so the device
code never lands in a URL, and a fixed device name replaces this
machine's hostname. Polls are at least 6 s apart whatever the server
asks; slow_down, 429 and server errors back off by 5 s or the server's
Retry-After, and --wait keeps polling through them until the code
expires. A device-code answer that isn't a JSON object is
network_unavailable. The login link must be https (or loopback http).

Status lines go to stderr. In mode A they are dropped, and the link and
code are an INFO: line; a person's terminal shows the same line instead
of the separate code and link lines. --complete refuses --profile for
another profile than the pending login's, and --save-base-url. The
legacy config migration prints to stderr.
Every authed command listed projects before its real request, twice
(once in BaseCommand.execute and again when the SDK client was built),
on a second TCP connection. That cost two round trips per command and
31 s on a stalled network.

- guard() checks only that a key is present. The host check still runs
  first, before any key is read, and the missing-key (auth_required) and
  E_KEY_UNTRUSTED paths are unchanged. The removed check's error text
  ("Network error during verification: ...", "Your API key may have
  expired or been revoked.") is gone.
- New deepctl_core.transport: one httpx.Client per process, created on
  first use and passed to the SDK as httpx_client. It carries no base
  URL, no credentials and keeps no cookies; DeepgramClient still takes
  the base URL from hosts.check_credential_host(). Its timeout matches
  the SDK's own client (60 s). It follows no redirects, as 0.4.1's SDK
  client doesn't, and a 3xx keeps 0.4.1's refusal.
- Request failures map to the catalogue's transport_mapping codes: DNS,
  connect, TLS, reset, a proxy refusal, a cut-off body (E-NETWORK-CUT)
  or a 2xx body that won't parse (E-NETWORK-UNREADABLE) ->
  network_unavailable; timeout -> timeout; 401 auth_invalid; 403
  auth_forbidden; 404 not_found; 429 rate_limited; 400, 422, other 4xx
  invalid_argument; 5xx server_error. The SDK's own retries still run
  first. Other failures keep their 0.4.0 wording.
- BaseCommand renders a mapped failure as two stderr lines (ERROR:/HINT:
  in mode A, a cross and the hint otherwise) and, under a machine
  format, a coded result on stdout with code, hint, next_step,
  schema_version 1 and details; exit 1. details.key_source follows dg
  whoami's order. The 0.4.1 coded results keep their keys.
- dg speak marks stdout as its payload stream once audio starts, so a
  failure mid-stream goes to stderr only, never as JSON after the audio.
- projects --set-default still warns and asks when the key can't see the
  project (404/403); other request failures stop it.

The 0.4.1 host tests that used the pre-flight as their probe now send
the command's own request through the shared client.
In mode A every stderr line carries a prefix and
status chrome is suppressed. The commands that use the shared client
(billing, keys, listen, members, models, projects, read, requests,
speak, transcribe, usage) now run their handler with stderr filtered:
only ERROR:/WARN:/INFO:/HINT:/OK:/DEPRECATED: lines, and 0.4.0
"Error"/"Warning" lines, get through. Each write is kept or dropped as a
whole. --verbose keeps everything, and every other command (login's
device code, dg api, debug-probe) is unchanged.

Lines in those commands that a caller needs now render as warnings or
errors instead of chrome, so they survive the filter: listen's Flux
--diarize note, its --encoding assumption and the microphone audio
warning; speak's --play note; projects' "Failed to ..." errors and the
missing project_id warning.

The listen and speak warnings print under --quiet too (print_warning
gains ignore_quiet), since --quiet hides only INFO and OK lines.
Live stdin and microphone streams run through one session engine
(deepctl_cmd_listen.live) with defined endings:

- Ctrl-C (Ctrl-Break on Windows): send CloseStream, keep receiving for
  at most 1 s, keep the finals and --save-to, exit 2. On --mic, Ctrl-C
  is how the session ends: the same drain, then exit 0 with the
  transcript so far, also when it comes before the session has
  connected. Ctrl-Break stays a hard stop (exit 2) on --mic. A second
  Ctrl-C exits at once. The handler is signal.signal, not
  add_signal_handler, so it works on every platform and on Python 3.10.
- SIGTERM on POSIX (docker stop, systemd, Kubernetes): the same drain,
  then the process re-raises SIGTERM with the default action, so the
  wait status stays "killed by signal 15" (143 in a shell). A second
  signal ends it at once.
- End of input: send CloseStream and wait for the server's close under
  an idle deadline (10 s since the last server message) and an absolute
  cap (max(30 s, 2 x the audio still behind for PCM/mulaw/alaw, or
  2 x the time spent sending otherwise)). If either expires the run ends
  with stream_drain_timeout, exit 1. Ctrl-C during the drain switches to
  the 1 s cap and exit 2.
- A signal that arrives while the server is closing still counts: the
  run ends as stopped by that signal.
- The server closes: exit 0, or exit 1 with server_error when it
  reported an error.
- The connection drops: exit 1 with network_unavailable.
- Whoever reads stdout goes away (`| head -n 1`, `| true`): the stream
  ends quietly and exits 0. Captions are flushed line by line, so they
  also arrive as they are produced.
- stdin can't be read (a write-only or broken descriptor, EIO on a
  hang-up): exit 1 with invalid_argument and a hint to pass a file or
  URL instead.

Each failure is a coded error whose details carry the finals received
so far (transcript, transcript_chars, saved_to), so nothing that
arrived is lost. Text the server sends back (close reasons, Flux error
descriptions) is stripped of control characters and of the API key and
any Token/Bearer value before it is shown or saved.

Stdin is read with os.read in a daemon thread that hands each buffer to
the loop through run_coroutine_threadsafe and waits for it to be taken.
The thread checks a closing event before every read and hand-off,
shutdown sets that event before it cancels any task, and anything raised
in the thread ends it quietly. This removes the interpreter abort (exit
134, "Fatal Python error") that a reader blocked in sys.stdin.buffer
caused at shutdown. The reader blocks SIGINT and SIGTERM so they always
reach the main thread, and waits on a stdin left non-blocking by its
parent instead of failing. Regular files, pipes, /dev/null and TTYs all
work.

At interpreter exit the remaining objects are frozen out of the garbage
collector, so the process ends a few milliseconds after the server's
close instead of about 100 ms later.

The session opens through deepctl_core.websocket.open_async, the same
no-redirect open as connect_async but returning the connection and
taking websockets options, so a 3xx on the upgrade is still refused
before a second connection (mic and stdin), as in 0.4.1. deepctl-core
now declares websockets>=13.1,<18, which that module imports.
dg-coreylweathers and others added 10 commits November 12, 2026 11:33
0.4.1 checks the cp1252 stream with `dg --version` too, imports the
function it tests by name and keeps PYTHONUTF8 from turning UTF-8 mode
on in the child. Use that version here, with 0.5.0's
`dg help agent-mode` case added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This release adds features and changes defaults that need upgrade notes,
so it is a minor release. With bump-patch-for-minor-pre-major set,
release-please would otherwise propose a patch release.

Release-As: 0.5.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…0.11

setup-python's macOS 3.10 is 3.10.11, which predates the extraction
filter backport, so extractall(filter="tar") raised TypeError. Use the
filter where it exists and otherwise extract member by member, allowing
only directories and regular files inside the destination.
…e out CI

test_reader_reads_capped_frames_then_eof wrote 5,000 bytes into an
anonymous pipe before any reader ran. A Windows pipe holds 4 KiB, so the
write blocked and every Windows job sat until the six-hour limit. Write
from a thread after the reader starts. Give the test a 30 s timeout, every
test 300 s, and the matrix job 45 minutes, so a deadlock fails fast.

lowest_direct.sh installs with --refresh: this branch still builds as
0.4.1, and the restored uv cache held release/0.4.1's deepctl 0.4.1
wheels, which uv installed instead of the ones just built.
dg mcp's remote client sent each tool call, with Authorization: Token,
through HTTP_PROXY/ALL_PROXY when NO_PROXY didn't cover the MCP host,
including an approved loopback base. Build it with trust_env=False and
check with a capture proxy that it never sees the request.
…is gone

A WebVTT header that hit a closed pipe only pointed stdout at the null
device: dg then opened the microphone (or read stdin), connected and
uploaded audio for nobody, as in `dg listen --mic --webvtt | head -n0`.
End there with the quiet closed-output result instead, before any
capture or socket, on both the microphone and stdin paths.
…t-mode help

- speak: critical_section read signal.SIG_BLOCK before checking that
  pthread_sigmask exists, so every output-file open, write, commit and
  abort raised AttributeError on Windows, and hid the real error on the
  failure paths. The attribute is now read only behind the guard.
- A reader that has gone away shows up on Windows as OSError(EINVAL) on a
  pipe, not BrokenPipeError. Add signals.reader_gone() (BrokenPipeError on
  any OS; EINVAL only on win32 when the stream is a pipe) and use it where
  speak, listen, the entry point, the MCP fast path and agent-mode help
  exit quietly on a closed pipe. POSIX behaviour is unchanged.
- update: should_check compared datetime.now() with a time.time() stamp,
  so a check in the same clock tick as the write counted as the clock
  moving back and ran again. It now uses the same clock as the cache write.
- init: same_directory skipped the device and inode comparison wherever
  directory handles are unavailable, so on Windows a directory swapped for
  an identical copy during init or a resume went unnoticed. It now
  compares the volume and file ID recorded for the directory, treating a
  file ID of 0 as unknown. POSIX behaviour is unchanged. The resume tests
  removed a fresh clone with shutil.rmtree, which fails on Windows because
  git object files are read-only; they now clear the bit first, and the
  swap tests run a second time with directory handles turned off so Linux
  exercises the same branch. The one test skipped in that variant is
  test_owned_by_someone_else, since Windows has no owners; the original
  still runs on POSIX.
- agent-mode help: on Windows the config-file section told the user to
  make sure they own the file and then gave no command, because dg does
  not check the owner or permissions there. It now says there is nothing
  to fix. The text on other systems is unchanged.
Each test below failed on the Windows runners because it relied on a POSIX
path, shell, clock or environment, not because the code under test is
wrong. The only changes that allow more are the import-budget
allowlist and the Windows-only telemetry skip, both stated below.

- update: patch platformdirs for the config location; Windows ignores
  HOME and XDG_CONFIG_HOME.
- init, login, speak: on Windows the next_step builders return None when
  an argument needs shell quoting, which every Windows path does. Expect
  that on nt, keep the strict quoting checks on POSIX, and add direct
  tests of both flavours.
- import budget: allow the pywin32 startup modules that the mcp dependency
  loads from its .pth file (bench/gates.json and its test mirror).
- manifest: read the UTF-8 fragment files as UTF-8, not the Windows code
  page.
- upload: on Windows the open descriptor blocks os.replace; assert the
  PermissionError and that the body still reads the original bytes.
- telemetry: a real refused loopback connect is delayed (about 2 s per
  attempt) on Windows, and the test's own 4 s timeout then ran out. The
  real-socket test stays on other systems, and is skipped on Windows
  only; a second test refuses the connection immediately, on every OS,
  with the same 2.5 s limit. The limit is unchanged.
- output: give the Python 3.10 subprocess SYSTEMROOT, which it needs to
  start on Windows.
- speak: classify a pipe from a fake stat result so the check is the same
  on every OS.
On Windows, init, login and speak returned no next_step whenever an
argument needed quoting, which every Windows path does, so an agent was
always told to ask the user to run the command. They now return a command
that runs unchanged in cmd, Windows PowerShell, PowerShell 7 and Git Bash.

- Add deepctl_core.nt_command (stdlib only, imported only on Windows):
  tokens made of [A-Za-z0-9_+=:./-] stay bare, anything else is wrapped in
  double quotes (a single-dash token ending in ":" too, which PowerShell
  reads as a parameter), and the whole command is refused (next_step stays
  null) when a token cannot be passed to all four shells unchanged. The
  line is parsed back and refused if it does not give the original tokens.
  Refused: empty and non-ASCII tokens, control characters, a trailing or
  doubled backslash, the spellings Git Bash rewrites as paths, and any of
  " % $ ` ! & | < > ^ ; ( ) { } ' * ? [. The program path may not contain
  "=" either, because cmd ends a command name there and would run a
  sibling file such as p.bat for C:\p=1\Scripts\dg.exe.
  Typed into any of the four shells the line gives the program the
  original tokens. If an agent wraps it once in cmd /c, powershell
  -Command, pwsh -Command or bash -c without quoting it again, the program
  may get other arguments, but no token runs code or creates a file,
  whatever files are in the current directory: the refused characters are
  the ones that would (a quoted * or ? is expanded against the directory
  once the wrapper strips the quotes). A --name=value token whose value is
  a URL, a drive path or a relative path (--base-url=https://h/v1,
  --dir=C:/x) is no longer refused as a path. POSIX quoting is unchanged.
- init passes --dir with forward slashes on Windows, which every shell
  accepts and which resolves to the same directory.
- prog() returned null on Windows when dg was not on PATH, and on Python
  3.10 and 3.11 also when it was, because the console-script launcher
  strips .exe from argv[0] on every Python version and shutil.which adds
  it back only from 3.12 (spelled dg.EXE). It now looks for argv[0] plus
  .exe itself, and gives a forward-slash absolute path when dg is not on
  PATH. Login's private copy and, on Windows, speak now call it.

Text with an apostrophe, semicolon, parenthesis, question mark or glob
character (It's, Hi; there, app (1), How are you?) gets no next_step on
Windows, as before this change.

A test runs the same commands through cmd, powershell, pwsh and Git Bash
on Windows and checks the program receives the original arguments.
dg-coreylweathers added a commit that referenced this pull request Nov 12, 2026
## Why

`@deepgram/cli` on npm is the obvious name for "the Deepgram CLI", and
it's broken:

- `npm i @deepgram/cli` fails with `notarget`. The only versions are two
old betas (`0.3.0-beta.0`, `0.4.0-beta.0`) from
`lukeocodes/deepgram-cli`.
- `npm i @deepgram/cli@0.4.0-beta.0` installs, then crashes on start:
`MODULE_NOT_FOUND …/src/hooks/init/log`.

Coding agents try npm first. In our build-journey evals, agents asked to
"use the Deepgram CLI" went to `@deepgram/cli`, hit both failures, and
gave up on the CLI.

This PR makes `@deepgram/cli` the real CLI.

## What it does

**New `npm/` package.** `@deepgram/cli` has no dependencies and no
install scripts. It provides `dg` and `deepgram`, and runs the Python
`deepctl` at the **same version as the npm package**:

1. A cached private venv (`~/.cache/deepgram-cli/venvs/<V>`) if one is
already built. This is fast and works offline.
2. Otherwise, with uv present, a venv built with `uv pip install
--require-hashes --no-deps --only-binary :all:` against the hash-pinned
`requirements-<V>.txt` the package ships. No local Python is needed.
3. Otherwise, a Python 3.10+ with `venv` builds the same hash-checked
venv the same way, in place under a lock. All bootstrap output goes to
stderr, so `dg -o json` and `dg mcp` stdout stay clean on the first run.
4. Otherwise, a clear error with the three fixes: install uv, install
`python3-venv`, or use `install.sh`/brew.

`DEEPCTL_NPM_RUNNER=uvx|venv|path` forces a strategy. `path` uses a
`deepctl` already on PATH, and only when asked.

**Python side.** `InstallMethod.NPM` is detected through
`DEEPCTL_LAUNCHER`, which `deepctl_core._launcher` reads once and
removes from the environment so it doesn't leak to child processes. With
it:
- `dg update` and the update banner tell npm and npx users the npm
command.
- Plugins go into the isolated plugin venv, so they survive npm version
bumps.
- The skills prompt says `dg`.

**Release.**
- release-please bumps `npm/package.json` along with the root package
(`extra-files`).
- A new `publish-npm` job runs after `verify-published`, so npm never
points at a version PyPI can't install.
- It uses npm trusted publishing (OIDC, environment `npm-publish`, npm
pinned to 11.21.0) and sets provenance.
- It's safe to re-run: it skips a version that's already published, and
it fails closed if the dist-tag lookup errors.
- If the version is lower than the current `latest`, it publishes under
`v<maj>.<min>-maint` instead of moving `latest`.
- Recovery is "Re-run failed jobs".

**CI.** Three new jobs, added to `all-checks`:
- `launcher` (Linux, macOS, Windows): Node unit tests, `cmp LICENSE
npm/LICENSE`, and a check that stdin stays blocking for deepctl.
- `launcher-node` (Linux): Node unit tests on every version
`package.json`'s `engines` admits (20.0.0, 20, 22, 24).
- `launcher-hashed` (Windows, macOS): the release path end to end
against a local package index — builds this commit's packages, writes
the hash-locked `requirements-<V>.txt`, and runs the launcher through
both uv and the pip fallback.
- A `release-bump-gate` status (`scripts/check_release_bumps.py`) fails
the build if a package's shipped code changed since its last release tag
without a version bump — the drift that would otherwise let a sibling
package ship against a `deepctl-core` PyPI hasn't released yet. It runs
again before publish with the same check.

**Docs.** npm/npx install lines in `README.md` and
`web/src/data/llms-full.txt`. The latter also fixes the
`deepgram/tap/deepctl` → `deepgram/tap/deepgram` typo.

The first npm version will be **0.4.1**, from the next release PR.
`0.4.1` is newer than `0.4.0-beta.0`, so `latest` moves and plain `npm i
@deepgram/cli` works.

## Hardening

This went through five rounds of independent review, each followed by a
fix round. Every finding was reproduced in Docker before it was fixed,
and each fix was checked against its repro. Highlights:

**Cache security.**
- Every cache ancestor must be owned by you or root.
- Group/other-writable ancestors must be sticky.
- Group-write is never trusted, even for a user-private group (`gid ==
uid`); a shared-group cache falls back to a private temp dir.
- The `/tmp` fallback is `0700` and owner-checked.

These checks block another user from planting or swapping a cached venv.
Tested by pre-creating the cache, with a symlink, and with a
150-iteration swap loop.

**Locking.**
- `mkdir` lock with owner, pid and host.
- A lock is stale when its pid is dead, it's older than 60 min, or it
was taken by our own pid after a pid reuse.
- Stale locks are renamed aside and their owner verified before
deletion.
- Release is ownership-checked.

**Signals and stdio.**
- SIGTERM and SIGHUP are forwarded.
- With a terminal attached, SIGINT and SIGQUIT come from the process
group, so each Ctrl-C reaches deepctl exactly once and cancel still
exits 2.
- With no terminal (IDEs, agent hosts), a SIGINT sent only to the
launcher is forwarded after 1s.
- The launcher never constructs a Node std stream. That stream would put
the inherited stdin into non-blocking mode and break `dg mcp` over
stdio, `ffmpeg | dg listen`, and similar pipes.
- Launcher messages to stderr are best effort, so a full or closed
stderr can't break a run.

**Self-repair and pruning.**
- A venv whose base Python was removed gets rebuilt.
- Old version venvs are pruned only after 30 days unused, and never
while locked or in use.

## Testing

**Docker** (linux/arm64; the uvx, venv, signal and stdin cases also on
amd64): 43/43 cases pass. They cover:
- no Python;
- uv only;
- Python 3.12 venv: cold, warm, and offline warm and cold;
- Debian with and without `python3-venv` (PEP 668);
- Python 3.9 rejected;
- Alpine/musl;
- two concurrent first runs;
- SIGTERM, SIGINT and SIGHUP, with and without a TTY;
- unwritable HOME and an arbitrary uid;
- OpenShift-style uid with gid 0;
- shared primary group;
- the npx and npm labels;
- `npx ./tgz`;
- clean JSON stdout on a cold cache;
- an MCP `initialize` sent 1s late.

**Unit and lint:**
- Node: 111/111 on Node 20, 22 and 24 (the `engines` floor).
- Python: 2280 passed, 12 skipped in Docker as root; 2283 passed, 9
skipped as a non-root user (three permission tests skip under root).
- ruff, mypy, `check_dependency_floors.py` and actionlint are clean.
- The `test_release_bumps.py` tests that read this repository's git
history now skip when git or a git checkout isn't available, instead of
failing with `FileNotFoundError`. CI has both, so they still run there.
- The `pip install` steps in `release.yml` and the `release-bump-gate`
job are pinned to the versions in `uv.lock` (`build==1.5.0`,
`twine==7.0.0`, `packaging==26.3`).

**Not yet run:** real macOS and Windows runs, which the new `launcher`
job does on this PR, and the `publish-npm` job, which runs on the first
release now that the owner setup below is in place.

## Before the first release, owner steps (done 2026-10-23)

- [x] Trusted publisher on `@deepgram/cli` (set as `deepgramai`): GitHub
Actions, repo `deepgram/cli`, workflow `release.yml`, environment
`npm-publish`, permission **publish**. Note for anyone redoing this: npm
now separates `publish` from `stage publish`; the first attempt from the
web UI granted only `stage publish`, which a plain `npm publish` is
rejected under, so it was revoked and re-added with `npm trust github
@deepgram/cli --repo deepgram/cli --file release.yml --env npm-publish
--allow-publish`. Verify with `npm trust list @deepgram/cli`.
- [x] GitHub environment **`npm-publish`** created on this repo,
deployment branches limited to `main`, no required reviewer.
- [x] Both 2023 betas carry a deprecation message pointing at the new
install: `Unsupported 2023 beta. Install the Deepgram CLI with: npm i -g
@deepgram/cli@latest (0.4.1 or newer)`. (npm Support had deprecated them
on 2026-09-11 with its generic "no longer supported" text; the flag is
per version and does not block publishing.)
- [ ] After 0.4.1 publishes: confirm `npm view @deepgram/cli
dist-tags.latest` is `0.4.1`, then turn on "Require 2FA and disallow
tokens" for the package.

Until the first publish, `npx @deepgram/cli` still runs the old 2023
beta (now with the pointer message), so the README line goes live
slightly ahead of the package.

## Floors

`deepctl-cmd-update` and `deepctl-cmd-skills` already declare
`deepctl-core>=0.3.0` on this branch, and `deepctl-cmd-plugin` already
declares `deepctl-cmd-update>=0.2.7` for the `InstallMethod.NPM` import
it's always made without declaring. No hand edits are needed on the
release PR: the release sync job (`check_dependency_floors.py --fix`)
keeps these floors current automatically, and `release-bump-gate`
(above) catches it if a future change needs a bump this job doesn't
cover.

A normal `pip install -U deepctl` is already safe, because the root
floors pin everything.

## Known limits and follow-ups (not in this PR)

**Accepted limits:**
- A `pip install deepctl` into the same prefix overwrites npm's `dg`,
and brew and npm both want `dg`. The npm README says to use one install
method.
- Group-writable caches on shared-group systems (macOS `staff`, SUSE
`users`) fall back to a private temp dir. Any user-private-group member
could interfere, which is a limit of that convention.
- `nohup dg …` doesn't survive SIGHUP the way `nohup deepctl …` does,
because of how Node handles it. Use `setsid` or deepctl directly for
detached runs.
- With a TTY attached, a SIGINT sent only to the launcher's pid (not the
process group) isn't forwarded.
- npm and npx don't work on Intel Macs or Windows on ARM64. The launcher
installs with `--only-binary :all:`, and `cryptography` 50.0.0 has no
wheel for either platform. `README.md`, `npm/README.md` and
`llms-full.txt` say so. On an Intel Mac they point to Homebrew, because
the tap formula brings the Rust toolchain that a source build of
`cryptography` needs.
- The launcher sets `DEEPCTL_LAUNCHER=npm` for both a global install and
a project dependency, because it has no reliable, portable way to tell
them apart. `dg update` shows both the global and the project-local
command (`npm install @deepgram/cli@latest`); the update notice shows
only the global one. `npm/README.md` covers both and has a new Uninstall
section.

**Notes for reviewers:**
- `deepctl_core._launcher.capture_launcher()` still checks
`deepctl.main.LAUNCHER_CAPTURED` first. It can look like dead code on
this branch, but it's kept on purpose. In 0.5.0 (#165), `deepctl.main`
becomes stdlib-only and removes `DEEPCTL_LAUNCHER` before `deepctl_core`
is imported, so that flag is the only way npm installs are still
detected. The docstrings now explain this.
- `publish-on-release.yml` (line 46) also runs an unpinned `pip install
build twine`. That file isn't changed by this PR, so it's left for a
follow-up.

**Existing bugs found along the way:**
- `dg update` tells Homebrew users to run `brew upgrade deepctl`, but
the formula is `deepgram`.
- deepctl 0.4.0 crashes when HOME is unreadable (`PermissionError` on
`~/.deepctl/plugins/venv`).
- The `actions/checkout` pins are commented `# v4` but resolve to
v7.0.1.

**Phase 2:** PyInstaller per-platform binaries through
`optionalDependencies`, so npm installs need no Python or uv at all.

The history includes a manual re-publish path that was added and later
removed, so **squash on merge**.
@dg-coreylweathers
dg-coreylweathers deleted the branch release/0.4.1 November 12, 2026 18:30
@dg-coreylweathers
dg-coreylweathers changed the base branch from release/0.4.1 to main November 12, 2026 19:44
Bring the 0.4.1 release into 0.5.0: the @deepgram/cli npm launcher (#146),
the Homebrew bump that merges itself (#135), and the 0.4.1 fixes (#159).

How the two sides fit:

- src/deepctl/main.py is 0.5.0's. Its main() reads and removes
  DEEPCTL_LAUNCHER before deepctl_core is imported and keeps the value in
  LAUNCHER / LAUNCHER_CAPTURED, which deepctl_core._launcher (from 0.4.1)
  and deepctl_cmd_update.channel both read. Crash tracebacks still never
  show frame locals (install(show_locals=False), now installed lazily).
- deepctl-cmd-update is 0.5.0's, which already detects npm and npx
  installs; 0.4.1's duplicate detection there is dropped. The plugin
  command keeps 0.4.1's isolated venv for npm installs, and the skills
  prompt keeps 0.4.1's rule that an npm launch is not one-shot.
- Versions are lockstep at 0.4.1 (what main last released); the plugin
  example stays at its released 0.1.13. release-please now also moves
  npm/package.json with the root version.
- scripts/check_release_bumps.py and its release-bump-gate are removed:
  with lockstep versions every release bumps every package, so no
  package's code can reach PyPI under an already-published version. The
  build job checks lockstep versions once more before publishing.
- release.yml keeps 0.5.0's re-pin jobs, trusted publishing and tag
  recovery, and adds 0.4.1's pinned build tools, fresh-venv PyPI check,
  Homebrew auto-merge and publish-npm. test.yml adds the three npm
  launcher jobs to All checks.
- conftest.py forgets the launcher main() captured between tests, so a
  test that ran main() no longer decides what later launcher tests see.
c62445a added OPENCODE_CLIENT to the agent signal table, and
dg help agent-mode prints it, but the two golden files were not
regenerated, so test_golden failed on every test and lowest-direct job.
Regenerated from the renderer; the only change is that row.
"""A short, non-reversible fingerprint of a key, for comparisons."""
import hashlib

return hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
The workflow_call trigger (and its `ref` input) had no caller; the file
header and bench/README.md "Release gating" both said so. Its
`ref: ${{ inputs.ref }}` checkouts are what CodeQL flagged as cache
poisoning via execution of untrusted code (alerts #26/#27,
actions/cache-poisoning/poisonable-step) in the full job's Run and Check
steps.

Remove the dead trigger, the inputs.ref suffix on the concurrency group,
the ref: lines on the selftest and full checkouts, and the now-moot
`inputs.ref == ''` clause on the aa job. This closes the finding by
deleting the dead code rather than suppressing it. The README now says
the hook gets added when a release-gating caller is wired up.
…ME.md

Addresses two nits from devrel-review on 53b720f: the rewrapped Release
gating sentence was much longer than surrounding lines, and dropping the
workflow_call comment from competitive-bench.yml lost the reason a
push: tags trigger wouldn't work for release gating (release-please
pushes tags with GITHUB_TOKEN, which starts no workflow). Restored that
reasoning as a bullet in the Release gating checklist instead.
@dg-coreylweathers
dg-coreylweathers merged commit 2602db3 into main Nov 12, 2026
69 of 70 checks passed
@dg-coreylweathers
dg-coreylweathers deleted the release/0.5.0 branch November 12, 2026 23:15
@linear-code

linear-code Bot commented Nov 12, 2026

Copy link
Copy Markdown

DG-2828

dg-coreylweathers added a commit that referenced this pull request Nov 12, 2026
PR #165 merged 97 feature/fix commits via a merge commit whose own
title isn't conventional-commit-formatted, and release-please's commit
walk doesn't descend into a nested merge inside that branch's own
history (the internal main-into-release/0.5.0 sync merge). As a
result release-please never saw any of #165's commits and proposed an
empty patch bump (PR #168) instead of the real 0.5.0 minor release.

This marker commit sits directly on main, outside any merge, so
release-please will see it and regenerate #168 correctly: Features
and fixes mean a minor release, so with bump-patch-for-minor-pre-major
set, release-please would otherwise propose only a patch.

Release-As: 0.5.0
dg-coreylweathers added a commit that referenced this pull request Nov 12, 2026
…istory

release-please's commit walk doesn't descend into #165's own nested sync
merge, so it only saw 3 trivial commits sitting after it. Two problems
followed from that, now fixed by hand:

1. CHANGELOG.md's 0.5.0 entry only listed those 3 commits. Replaced with
   the full set of 71 visible commits (feat/fix/perf/docs) from the true
   v0.4.1..#165 range, correctly ordered and sectioned, plus a Behavior
   changes section for upgrade-note-worthy changes to defaults.

2. The empty marker commit used to force the Release-As trailer had no
   file changes, so release-please's path-splitting misattributed it to
   packages/deepctl-plugin-example (proposing a bogus 0.1.13 -> 0.5.0
   jump for an independently-versioned, untouched package). Reverted
   its pyproject.toml and CHANGELOG.md, and fixed the manifest back to
   0.1.13 for that component.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants