Repository navigation
Release 0.5.0: faster startup, agent mode, streaming and one transport - #165
Conversation
28234d4 to
2e97002
Compare
GregHolmes
left a comment
There was a problem hiding this comment.
Request changes
Four blockers remain:
- The macOS Python 3.10 job fails because the forged-clone fixture calls
TarFile.extractall(filter="tar"), unsupported on that runtime. - Every Windows job blocks for six hours because a test writes 5,000 bytes to an anonymous pipe before starting its reader.
- The authenticated MCP client inherits
HTTP_PROXYand can send its token to an unapproved proxy. - 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] |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
bf46e59 to
04b73cf
Compare
e5aed1f to
c7d9e35
Compare
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.
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.
f0345a5 to
c62445a
Compare
## 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**.
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.
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
…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.
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 --versionanddg mcpstart 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
Login for agents, and listen, speak and debug streaming (11–28)
dg loginaccepts real API keys and asks for--yesinstead of cancelling, and agents can finish device login with--complete. Livedg listenfinishes cleanly on Ctrl-C and end of input with bounded memory;dg speakstreams audio as it arrives and gains--out-file(-o/--outputfor the audio file is deprecated). Agent mode drops status chrome from stderr.dg debug networksends no stored credentials, debug browser and probe run on websockets, and numpy, ffmpeg-python and requests are dropped.Commits
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.ymlwith trusted publishing. The update check never waits, detects pipx, uv and npm installs correctly, and reports an unreachable index as an error.Commits
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 initclones only Deepgram templates, keeps repository settings from running programs, can resume, and needs--yesfor setup steps outside a terminal. JSON output off a terminal skips rich.dg listenprints the server body for-o json, adds-o text, and saves large results to a file in agent sessions.Commits
dg mcp, agent mode, CLI and config fixes (50–73)
dg mcpshares one proxy and tool table and keeps stdout for JSON-RPC from the start. AI agents are detected from one documented table, anddg help agent-modeexplains 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 --completechecks the base URL, test and README fixes).Commits
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-fileand-owork withoutos.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~/.configonly 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 mcpneeds mcp 1.23.1, and the tests run against installed packages at their floors. Ends withchore: release 0.5.0, which carriesRelease-As: 0.5.0.Commits
Upgrade notes
DO_NOT_TRACK,CIandDEEPCTL_TELEMETRY=log|offare accepted, and only affect a local loopback sink set withDEEPCTL_TELEMETRY_DSN, which is for tests.--no-telemetryis still accepted and documented as doing nothing.deepctl-telemetry, for example, goes from 0.0.7 to 0.5.0. Each package pinsdeepctl==0.5.0. Upgrading onedeepctl-cmd-*now upgrades the whole set, or fails to resolve, and an olderdeepctl-cmd-*can't be mixed with a newerdeepctl. The packages no longer get their own GitHub releases or tags.--version: prints the name it was run as:dg, version 0.5.0ordeepgram, version 0.5.0(0.4.x printeddeepctl, version …for every console script).deepctlandpython -m deepctlstill printdeepctl, version ….-o jsonand-o yamlprint 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=sdkbrings back 0.4.x's SDK-shaped JSON for prerecorded audio.-o jsonand-o yamlprint only the final result, not every interim message.dg listenwith no source (no file, URL or--stdin) is refused instead of opening the microphone.--check-urlasks for the check.-o/--output FILEis deprecated in favor of--out-file.-o FILEnow replaces the file through a temp file, so it never writes through a symlink.dg init's resume records and a pending device login live under the account's own home directory, not$HOME.--yeswhen not run from a terminal, and outside a terminal the app starts only with--start. Only Deepgram templates are cloned.-y/--yesanswers the confirmations, and-f/--force-writestill works as the same flag;confirmation_requiredinstead of "cancelled";--completefinishes an agent's device login.dg apisends aUser-Agent,X-Fern-Platformand a session ID header, only to hosts the key may be sent to.deepctl-cmd-mcpneeds mcp >= 1.23.1 (was 1.22.0);deepctlpackage itself still listsrequests.DeepgramClient.ws_base_url()is removed, anddeepctl_core.websocket.RedirectRefusedis now the transport's error, no longer aclick.ClickException.release.ymlwith trusted publishing. This needs thereleaseenvironment and the release GitHub App (RELEASE_APP_CLIENT_IDandRELEASE_APP_PRIVATE_KEY) set up before the 0.5.0 release PR.Notes
Release-As: 0.5.0; without it, release-please would propose a patch release instead of 0.5.0.🤖 Generated with Claude Code