Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fcc4381
refactor(mcp): share one proxy and tool table for every dg mcp path
dg-coreylweathers Nov 1, 2026
efebe2a
fix(mcp): keep stdout for JSON-RPC from the start of dg mcp
dg-coreylweathers Nov 1, 2026
ade3f5e
docs(mcp): describe dg mcp as the documentation search it is
dg-coreylweathers Nov 1, 2026
4e39fad
feat(core): detect AI agents from one documented table
dg-coreylweathers Nov 1, 2026
891f16e
feat(help): explain agent mode with dg help agent-mode
dg-coreylweathers Nov 1, 2026
155263c
fix(cli): keep warnings and notices off stdout
dg-coreylweathers Nov 1, 2026
dfed13b
style: say what each check does in config and skills comments
dg-coreylweathers Nov 2, 2026
a6c2847
fix(cli): escape characters the output encoding can't write
dg-coreylweathers Nov 2, 2026
59c66e1
fix(login): show a profile's config-file key in profiles --list
dg-coreylweathers Nov 2, 2026
a82791a
fix(config): count ~/.config on macOS when XDG_CONFIG_HOME names it
dg-coreylweathers Nov 2, 2026
47aceb9
fix(config): wait for a locked config file once per run
dg-coreylweathers Nov 2, 2026
1f1c66d
fix(listen,speak): name Flux models, not API versions
dg-coreylweathers Nov 2, 2026
366017b
fix(listen): give each OS its own stdin and microphone help
dg-coreylweathers Nov 2, 2026
2f690c7
test: give timing checks room on a busy host
dg-coreylweathers Nov 2, 2026
f83a8c6
fix(cli): use coded messages for cancel and unexpected errors
dg-coreylweathers Nov 2, 2026
930e345
test(debug-probe): run the ignored-SIGINT check only on Linux
dg-coreylweathers Nov 7, 2026
4bc6cc7
fix(plugin): refuse a plugin named like a built-in alias before impor…
dg-coreylweathers Nov 7, 2026
2df0098
fix(login): check the base URL before dg login --complete saves a key
dg-coreylweathers Nov 7, 2026
02ca0e2
test: merge the two fixtures that clear AI agent markers
dg-coreylweathers Nov 7, 2026
4eac835
docs: compare with 0.4.1 in comments, the release before 0.5.0
dg-coreylweathers Nov 7, 2026
8ffaa27
build: regenerate the stale package READMEs and run the generator wit…
dg-coreylweathers Nov 7, 2026
01b5615
docs(telemetry): say this version sends no telemetry
dg-coreylweathers Nov 7, 2026
7cb4b00
test(cli): patch both built-in tables in the missing-package test
dg-coreylweathers Nov 7, 2026
30b7177
test(mcp): let the docs lint pass the entry point's transport refusal
dg-coreylweathers Nov 7, 2026
28234d4
chore: release 0.5.0
dg-coreylweathers Nov 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ body:
attributes:
label: CLI version
description: Output of `dg --version`
placeholder: "deepctl, version 0.2.2"
placeholder: "dg, version 0.5.0"
validations:
required: true

Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,10 @@ verify-packages: ## Verify built packages with twine
twine check dist/*

readmes: ## Generate sub-package READMEs from pyproject.toml metadata
python3 scripts/generate_readmes.py
uv run python scripts/generate_readmes.py

readmes-check: ## Check sub-package READMEs are up to date
python3 scripts/generate_readmes.py --check
uv run python scripts/generate_readmes.py --check

floors-check: ## Check lockstep versions: every package at the root version, every first-party pin ==
uv run python scripts/check_dependency_floors.py
Expand Down
25 changes: 16 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ dg requests --status failed --endpoint listen # Debug failed requests
# Hit any endpoint directly
dg api /v1/projects --jq '.projects[0].name'

# MCP server for AI editors (Claude Code, Cursor, etc.)
# MCP server (stdio) that lets AI editors search Deepgram's docs
dg mcp
```

Expand Down Expand Up @@ -268,8 +268,11 @@ dg debug probe --port 3100 # Live stream audio analysis

### MCP Server

Give AI coding assistants (Claude Code, Cursor, etc.) Deepgram documentation
search over stdio.
`dg mcp` runs a local MCP server over stdio for AI coding assistants (Claude
Code, Cursor, etc.). It has one tool, `search_deepgram_knowledge_sources`,
which searches Deepgram's documentation. It uses your API key from `dg login`
or `DEEPGRAM_API_KEY`; don't put the key in the MCP config. SSE and HTTP
transports aren't supported.

```bash
dg mcp # Start MCP server (stdio)
Expand Down Expand Up @@ -406,9 +409,13 @@ CI=1 dg listen recording.wav
```

Also recognised: `--agent-friendly` (alias intended for AI coding tools — same
effect plus JSON metadata output), and the auto-detected env vars
`CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT`, `CODEX_SANDBOX`, and Aider's
`OR_APP_NAME` / `OR_SITE_URL`.
effect plus JSON metadata output). dg also detects AI coding agents from the
variables they set (`AI_AGENT`, `CLAUDECODE`, `CODEX_SANDBOX`, `GEMINI_CLI`
and others). `dg help agent-mode` prints the full table with its sources,
what your shell matched, and how to turn it off. `DG_AGENT=1` forces agent
mode and `DG_AGENT=0` turns it off for output and prompts; it doesn't change
the config file rules for agent sessions (see `dg help agent-mode`).
`NO_COLOR` only turns color off.

## Plugins

Expand Down Expand Up @@ -522,7 +529,7 @@ cli/
│ ├── deepctl-cmd-keys/ # API keys management command for deepctl
│ ├── deepctl-cmd-listen/ # Listen (live speech-to-text) command for deepctl
│ ├── deepctl-cmd-login/ # Login command for deepctl
│ ├── deepctl-cmd-mcp/ # MCP server for deepctl: Deepgram documentation search over stdio
│ ├── deepctl-cmd-mcp/ # Local stdio MCP server for deepctl: search Deepgram's documentation from AI editors
│ ├── deepctl-cmd-members/ # Members management command for deepctl
│ ├── deepctl-cmd-models/ # Models command for deepctl
│ ├── deepctl-cmd-plugin/ # Plugin management command for deepctl
Expand Down Expand Up @@ -563,7 +570,7 @@ cli/
| `deepctl listen` | Listen (live speech-to-text) command for deepctl |
| `deepctl login` | Login command for deepctl |
| `deepctl logout` | Login command for deepctl |
| `deepctl mcp` | MCP server for deepctl: Deepgram documentation search over stdio |
| `deepctl mcp` | Local stdio MCP server for deepctl: search Deepgram's documentation from AI editors |
| `deepctl members` | Members management command for deepctl |
| `deepctl models` | Models command for deepctl |
| `deepctl plugin` | Plugin management command for deepctl |
Expand Down Expand Up @@ -598,7 +605,7 @@ cli/
| [`deepctl-cmd-keys`](packages/deepctl-cmd-keys) | API keys management command for deepctl |
| [`deepctl-cmd-listen`](packages/deepctl-cmd-listen) | Listen (live speech-to-text) command for deepctl |
| [`deepctl-cmd-login`](packages/deepctl-cmd-login) | Login command for deepctl |
| [`deepctl-cmd-mcp`](packages/deepctl-cmd-mcp) | MCP server for deepctl: Deepgram documentation search over stdio |
| [`deepctl-cmd-mcp`](packages/deepctl-cmd-mcp) | Local stdio MCP server for deepctl: search Deepgram's documentation from AI editors |
| [`deepctl-cmd-members`](packages/deepctl-cmd-members) | Members management command for deepctl |
| [`deepctl-cmd-models`](packages/deepctl-cmd-models) | Models command for deepctl |
| [`deepctl-cmd-plugin`](packages/deepctl-cmd-plugin) | Plugin management command for deepctl |
Expand Down
23 changes: 7 additions & 16 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ def _clean_ai_agent_env(monkeypatch):
"""Run as a person would, even when an AI coding agent launched pytest.

Agent markers (AI_AGENT_ENV_MARKERS, Aider's OR_*) switch dg into its
agent trust mode. Tests that cover that mode set them, or patch
is_ai_agent, themselves.
agent trust mode and its agent output rules, and a developer's own shell
may set them (CLAUDECODE=1). Tests that cover agent mode set them, or
patch is_ai_agent or agent_mode, themselves. DG_AGENT is cleared with
the DEEPGRAM_* variables below.
"""
from deepctl_core.output import AI_AGENT_ENV_MARKERS

Expand All @@ -49,23 +51,12 @@ def _clean_ai_agent_env(monkeypatch):

@pytest.fixture(autouse=True)
def _clean_deepgram_env(monkeypatch):
"""Remove all DEEPGRAM_* environment variables for test isolation."""
"""Remove all DEEPGRAM_* environment variables, and the DG_AGENT
override, for test isolation."""
for key in list(os.environ):
if key.startswith("DEEPGRAM_"):
monkeypatch.delenv(key, raising=False)


@pytest.fixture(autouse=True)
def _speak_tests_run_as_a_person(request, monkeypatch):
"""Speak tests run as a person by default: the agent rules key off hard
agent markers, which a developer's own shell may set (CLAUDECODE=1).
Tests that need agent mode patch ``is_ai_agent`` themselves."""
if "deepctl-cmd-speak" not in request.path.parts:
return
from deepctl_core.output import AI_AGENT_ENV_MARKERS

for name in (*AI_AGENT_ENV_MARKERS, "OR_APP_NAME", "OR_SITE_URL"):
monkeypatch.delenv(name, raising=False)
monkeypatch.delenv("DG_AGENT", raising=False)


@pytest.fixture(autouse=True)
Expand Down
8 changes: 5 additions & 3 deletions docs/contracts/help-cache-key.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@
{
"type": "object",
"additionalProperties": false,
"required": ["path", "mtime_ns", "size", "inode"],
"required": ["path", "mtime_ns", "size", "inode", "mode", "uid"],
"properties": {
"path": {"type": "string", "minLength": 1},
"mtime_ns": {"type": "integer"},
"size": {"type": "integer", "minimum": 0},
"inode": {"type": "integer", "minimum": 0},
"cwd_relative": {"description": "Reserved; never written.", "const": true}
"mode": {"description": "st_mode: file type and permission bits.", "type": "integer", "minimum": 0},
"uid": {"description": "st_uid (0 on Windows).", "type": "integer", "minimum": 0},
"cwd_relative": {"description": "path is relative to the working directory.", "const": true}
}
},
{
Expand All @@ -38,7 +40,7 @@
"properties": {
"path": {"type": "string", "minLength": 1},
"absent": {"const": true},
"cwd_relative": {"description": "Reserved; never written.", "const": true}
"cwd_relative": {"description": "path is relative to the working directory.", "const": true}
}
}
]
Expand Down
17 changes: 10 additions & 7 deletions docs/contracts/help-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ An entry is named `<version>-<prog>-<width>-<tty>.txt`.

- **`<version>`:** the deepctl version, for example `0.5.0`.
- **`<prog>`:** the program name in the usage line, with every character outside ASCII `[A-Za-z0-9._-]` replaced by `_`. Python derives the name in this order:
1. `DEEPCTL_PROG_NAME`, with surrounding whitespace removed, when that leaves it non-empty.
1. `DEEPCTL_PROG_NAME`, with surrounding whitespace removed, when that leaves a plain program name: an ASCII letter or digit, then ASCII letters, digits, `.`, `_` or `-`, at most 64 characters. Any other value is ignored, as if unset.
2. Otherwise `dg` under `python -m deepctl`.
3. Otherwise Click's name: the basename of `argv[0]` (on Windows, pip's launcher has already dropped `.exe`).

Expand Down Expand Up @@ -68,16 +68,19 @@ The launcher serves an entry only when every one of the following holds. Anythin
- `prog` equals the program name the launcher will show, **before** the file-name sanitizing above, with surrounding whitespace stripped. That is the `DEEPCTL_PROG_NAME` value it sets.
- `width` and `isatty` equal the values computed above.
2. **`executable`** is exactly equal, as a string, to the bundle interpreter's `sys.executable`. It isn't resolved through symlinks, so `…/python3` and `…/python3.12` don't match.
3. **Each item in `files`** is checked with `stat()` on its `path`, following symlinks:
- **`absent: true`** matches only when the path doesn't exist. Any `stat` error counts as not existing.
- **Any other item** matches only when `mtime_ns`, `size` and `inode` all equal the current values. `mtime_ns` is in nanoseconds; directories are included, and their `size` is whatever `stat` reports (0 on Windows).
3. **Each item in `files`** is checked on its `path`:
- **`cwd_relative: true`** means `path` is relative: resolve it against the launcher's own working directory. Python writes one such item, `deepgram.yaml`. Any other item's `path` is absolute.
- **`absent: true`** matches only when nothing is there, not even a dangling symlink: `lstat()` fails.
- **Any other item** is checked with `stat()`, following symlinks, and matches only when `mtime_ns`, `size`, `inode`, `mode` and `uid` all equal the current values. A `stat` error is a mismatch. `mtime_ns` is in nanoseconds; directories are included, and their `size` is whatever `stat` reports (0 on Windows). `mode` is the whole `st_mode` (type and permission bits) and `uid` is `st_uid`: chmod and chown change neither mtime nor size, and the trust checks read both.
- **On Windows, `mtime_ns`** is Python's conversion of the last-write FILETIME: `(filetime - 116444736000000000) * 100`, where `filetime` counts 100 ns intervals since 1601-01-01 UTC.
- **On Windows, `inode`** is Python's `st_ino`, the file ID from `GetFileInformationByHandle(Ex)`. It can be wider than 64 bits on ReFS (Python 3.12 and later), so compare it as an arbitrary-precision integer.
- **`cwd_relative`** is reserved and never written. A reader that finds it treats the key as a miss.
- **On Windows, `mode` and `uid`** are Python's `st_mode`, which it builds from the file attributes and the file name's extension (CPython's `attributes_to_mode`), and `st_uid`, which is always 0.
4. **Each name in `env`** has the same value in the launcher's environment, and `null` means the variable must be unset. On Windows, look the names up case-insensitively, as the OS does. A variable that isn't listed doesn't matter. `DEEPCTL_PROG_NAME` and `DEEPCTL_HELP_CACHE` are never listed: the launcher sets them, and `prog` already records the name.
5. **`text_sha256`** (lowercase hex) equals the SHA-256 of the entry bytes the launcher read. This binds the text to its key: Python writes the text first and the key second, so text from another run under this key is a miss and is never served.

A bundle install (`-I`, no user site, `HOME` is the real home) lists about 10 files and 21 environment variables. Python writes no key over the limits (32 files, 64 variables), so an install with a long `sys.path` (an editable install, say) gets no entry and always takes the Python path.
**Agent state.** `files` and `env` together hold every input of the agent detection table (`deepctl_core.agents`): each variable a row reads, `PATH` (the pi row) and `DG_AGENT` in `env`, and the Devin marker file `/opt/.devin` (POSIX only) in `files`. Python never writes an entry in an agent session, so a launcher that checks the key as above never serves one there and needs no copy of the table.

A bundle install (`-I`, no user site, `HOME` is the real home) lists about 14 files and 34 environment variables. Python writes no key over the limits (32 files, 64 variables), so an install with a long `sys.path` (an editable install, say) gets no entry and always takes the Python path.

**Paths and values that aren't valid Unicode.**
- **Unix:** a path or environment value with bytes that don't decode under the filesystem encoding reaches Python as a lone surrogate (`\udcXX`).
Expand Down Expand Up @@ -107,4 +110,4 @@ Writes are atomic. The data goes to a `mkstemp` file (`.<random>.tmp`, mode 0600

If the entry and key on disk are already byte-equal to the new ones, nothing is written. Entries for other versions are pruned, as are `.tmp` files older than an hour.

Root `--help` reads no project file (`deepgram.yaml`), so no project-file warning is ever part of it.
The key records `deepgram.yaml` in the working directory (`cwd_relative`), so an entry written in one project is never served in a directory whose project file differs.
2 changes: 1 addition & 1 deletion docs/contracts/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
},
"$defs": {
"name": {
"description": "A command or alias name: the 0.4.2 plugin name rule.",
"description": "A command or alias name: the 0.4.1 plugin name rule.",
"type": "string",
"pattern": "^[a-z][a-z0-9-]{0,63}$"
},
Expand Down
19 changes: 11 additions & 8 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
# Telemetry

dg sends one error report when a command fails with an unexpected error. Nothing is sent when a command succeeds, for a usage error or another error dg reports to you, or for `--help` and `--version`. The README's Telemetry section says where reports go.
This version of dg sends no telemetry: no error reports, usage data or other telemetry, whether a command succeeds or fails. It has no built-in destination for reports. dg still contacts the Deepgram API for the commands you run, and pypi.org to check for updates.

The error-reporting code is still in dg, and it runs only when `DEEPCTL_TELEMETRY_DSN` points at a sink on this machine (a loopback address such as `127.0.0.1`), which is for tests and local development. A value that points anywhere else is ignored. This page describes what that code does with a loopback sink: when it runs, the switches that turn it off, and the exact fields of a report.

## When it runs

- **At most one report per run**, and only for an unexpected error.
- **Only with a loopback `DEEPCTL_TELEMETRY_DSN`.** Without one, dg installs no error hooks, never loads the reporting library, and the last line of `dg --help` says "This version of dg sends no telemetry."
- **At most one report per run**, and only for an unexpected error. Nothing is reported when a command succeeds, for a usage error or another error dg reports to you, or for `--help` and `--version`.
- **No network or reporting code on the success path.** The reporting library is loaded only after an unexpected error, so a command that succeeds opens no connection for telemetry.
- **A short time budget.** dg waits about 0.6 s at most for a report, loading included, and doesn't wait at all when no report was queued. With blocked or slow egress, a failing command takes at most that much longer.
- **A short time budget.** dg waits about 0.6 s at most for a report, loading included, and doesn't wait at all when no report was queued.

## Turn it off

Any one of these turns telemetry off. They are checked in this order:
With a loopback sink set, any one of these turns reporting off. They are checked in this order:

| Switch | Scope |
|---|---|
Expand All @@ -21,14 +24,14 @@ Any one of these turns telemetry off. They are checked in this order:
| `CI` set (as CI services do) | Any CI job |
| `telemetry.enabled: false` in your `config.yaml` | Always |

For `DEEPCTL_TELEMETRY_DISABLED`, `DO_NOT_TRACK` and `CI`, any value except an empty one, `0`, `false`, `no` or `off` turns telemetry off. `DEEPCTL_TELEMETRY` turns it off when set to `off`, `0`, `false`, `no` or `disabled`; `log` is described below, and any other value changes nothing. A config file dg won't load (unreadable, too large, not valid YAML, a repeated `telemetry` key, or one dg doesn't trust in an AI agent session) counts as turned off too. A project's `deepgram.yaml` can't change these settings. The last line of `dg --help` shows whether telemetry is on.
For `DEEPCTL_TELEMETRY_DISABLED`, `DO_NOT_TRACK` and `CI`, any value except an empty one, `0`, `false`, `no` or `off` turns reporting off. `DEEPCTL_TELEMETRY` turns it off when set to `off`, `0`, `false`, `no` or `disabled`; `log` is described below, and any other value changes nothing. A config file dg won't load (unreadable, too large, not valid YAML, a repeated `telemetry` key, or one dg doesn't trust in an AI agent session) counts as turned off too. A project's `deepgram.yaml` can't change these settings.

## See what would be sent
## See a report

Run a command with `DEEPCTL_TELEMETRY=log`. If it fails with an unexpected error and telemetry is on, dg prints the report it would have sent to stderr, as one JSON line, and sends nothing.
With a loopback sink set, run a command with `DEEPCTL_TELEMETRY=log`. If it fails with an unexpected error and reporting is on, dg prints the report to stderr as one JSON line and sends nothing, not even to the loopback sink.

```sh
DEEPCTL_TELEMETRY=log dg listen recording.wav
DEEPCTL_TELEMETRY_DSN=http://key@127.0.0.1:9000/1 DEEPCTL_TELEMETRY=log dg listen recording.wav
```

## Field schema
Expand Down
2 changes: 1 addition & 1 deletion packages/deepctl-cmd-debug-probe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pipx run deepctl --help
- `click>=8.2.0,<9`
- `rich>=13.0.0,<16`
- `pydantic>=2.0.0,<3`
- `websockets>=13.0,<18`
- `websockets>=13.1,<18`

## License

Expand Down
11 changes: 8 additions & 3 deletions packages/deepctl-cmd-debug-probe/tests/unit/test_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import json
import sys
from unittest.mock import Mock, patch

import pytest
Expand Down Expand Up @@ -364,6 +365,10 @@ def custom(signum, frame): # pragma: no cover - never called
assert seen == []
assert "Stopping: open connections can finish" in capsys.readouterr().err

@pytest.mark.skipif(
not sys.platform.startswith("linux"),
reason="reads the SigIgn mask from /proc/self/status (Linux only)",
)
def test_ignored_sigint_stays_ignored(self):
"""A background job in a script starts with SIGINT ignored: kill -INT
must not stop the proxy, and SIGINT stays ignored afterwards."""
Expand Down Expand Up @@ -394,8 +399,8 @@ def hooks(proxy, loop, port):
out = json.loads(proc.stdout.strip().splitlines()[-1])
assert out["status"] == "success"
assert out["ignored"] is True
if out["sigign"] is not None: # Linux: the SIGINT bit is still set
assert int(out["sigign"], 16) & (1 << (2 - 1))
assert out["sigign"] is not None
assert int(out["sigign"], 16) & (1 << (2 - 1)) # the SIGINT bit is still set
assert "Stopping: open connections can finish" not in proc.stderr

def test_keyboard_interrupt_path_first_and_second_press(self, capsys):
Expand Down Expand Up @@ -459,7 +464,7 @@ async def never(reader, writer):


class TestProbeConnectionChecks:
"""0.4.2's Host and Origin cases, on the websockets proxy: checked in
"""0.4.1's Host and Origin cases, on the websockets proxy: checked in
process_request, before the handshake and any upstream connection."""

KEY = "fa1ce0000000000000000000000000000000000d"
Expand Down
Loading
Loading