Comprehensive guide for AI coding assistants and human contributors working on the deepctl CLI.
deepctl is the official Deepgram CLI for speech recognition and audio intelligence. It's a plugin-based Python CLI built on Click, Rich, and Pydantic, distributed as a UV workspace monorepo.
- Entry points:
deepctl,deepgram,dg(all equivalent) - Python:
>=3.10 - Package manager:
uv - Linter/formatter:
ruff(not black/flake8) - Type checker:
mypy(strict mode) - Tests:
pytest
deepctl/
├── src/deepctl/ # Root package (entry point, CLI group)
│ └── main.py # main() → Click group → PluginManager
├── packages/
│ ├── deepctl-core/ # BaseCommand, Config, Auth, Output, Client
│ ├── deepctl-shared-utils/ # Validation helpers (audio, URLs, dates)
│ ├── deepctl-cmd-*/ # Built-in command packages
│ ├── deepctl-plugin-example/ # Reference external plugin
│ └── ...
├── scripts/
│ └── generate_readmes.py # README auto-generation
└── .github/workflows/ # CI/CD
The CLI uses Python entry points for zero-config plugin discovery:
main()creates a Click group and callsPluginManager.load_plugins(cli)PluginManagerscans three entry point groups in order:deepctl.commands— built-in top-level commandsdeepctl.plugins— external plugin commands- Plugin venv entries from
~/.deepctl/plugins/venv/
- For each
BaseGroupCommand, it also scans:deepctl.subcommands.<group-name>— built-in subcommandsdeepctl.subplugins.<group-name>— plugin subcommands
- Each entry point class is instantiated and wrapped in a Click command
User runs: dg transcribe audio.wav --output json
│
▼
main() → preprocess_hyphenated_commands() → cli()
│
▼
Click routes to TranscribeCommand (discovered via entry points)
│
▼
BaseCommand.execute(ctx, **kwargs)
├── Config from ctx.obj
├── AuthManager(config)
├── DeepgramClient(config, auth_manager)
├── auth_manager.guard() [if requires_auth]
├── handle(config, auth_manager, client, **kwargs) ← you implement this
└── output_result(result, config) ← auto JSON/YAML/table/CSV
packages/deepctl-cmd-<name>/
├── pyproject.toml
├── README.md # Auto-generated by make readmes
├── src/deepctl_cmd_<name>/
│ ├── __init__.py
│ ├── command.py # YourCommand(BaseCommand)
│ └── models.py # Pydantic result models
└── tests/
├── __init__.py
└── unit/
├── __init__.py
└── test_<name>.py
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "deepctl-cmd-<name>"
version = "0.1.10" # x-release-please-version
description = "<Description> command for deepctl"
readme = "README.md"
license = "MIT"
authors = [{ name = "Deepgram", email = "devrel@deepgram.com" }]
maintainers = [{ name = "Deepgram", email = "devrel@deepgram.com" }]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
keywords = ["deepgram", "cli", "<name>"]
requires-python = ">=3.10"
dependencies = [
"deepctl-core>=0.1.10",
"click>=8.0.0",
"rich>=13.0.0",
"pydantic>=2.0.0",
]
[project.entry-points."deepctl.commands"]
<name> = "deepctl_cmd_<name>.command:<Name>Command"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
include = ["deepctl_cmd_<name>*"]
[tool.uv.sources]
deepctl-core = { workspace = true }Critical: The # x-release-please-version comment is required on the version = line only (not on dependency lines) — release-please uses these markers.
"""Models for <name> command."""
from deepctl_core import BaseResult
from pydantic import BaseModel, Field
class SomeInfo(BaseModel):
"""Data model for your command."""
id: str
name: str
class YourResult(BaseResult):
"""Result from <name> command."""
# BaseResult provides: status="success", message=None
items: list[SomeInfo] = Field(default_factory=list)"""<Name> command for deepctl."""
from typing import Any
from deepctl_core import AuthManager, BaseCommand, Config, DeepgramClient
from rich.console import Console
from .models import YourResult
console = Console()
class YourCommand(BaseCommand):
name = "<name>"
help = "Full help text shown in dg <name> --help"
short_help = "One-liner for dg --help listing"
requires_auth = False # Set True if command needs API key
requires_project = False # Set True if command needs project ID
ci_friendly = True # Affects confirm()/prompt() in CI
examples = [
"dg <name>",
"dg <name> --option value",
]
agent_help = (
"Extended description for AI agents and MCP tool use. "
"Explain what the command does and when to use it."
)
def get_arguments(self) -> list[dict[str, Any]]:
return [
# Positional argument (singular "name" key):
{
"name": "input",
"help": "The input value",
"type": str,
"required": True,
},
# Option (plural "names" key + is_option):
{
"names": ["--format", "-f"],
"help": "Output format",
"type": str,
"default": "json",
"is_option": True,
},
# Flag:
{
"names": ["--verbose", "-v"],
"help": "Enable verbose output",
"is_flag": True,
"is_option": True,
},
]
def handle(
self,
config: Config,
auth_manager: AuthManager,
client: DeepgramClient,
**kwargs: Any,
) -> Any:
input_val = kwargs.get("input")
fmt = kwargs.get("format", "json")
# Do work...
console.print(f"[blue]Processing:[/blue] {input_val}")
return YourResult(
status="success",
message="Done",
)"""<Name> command package for deepctl."""
from .command import YourCommand
from .models import YourResult
__all__ = ["YourCommand", "YourResult"]Update these files:
/pyproject.toml (root):
# In [project] dependencies:
"deepctl-cmd-<name>>=0.0.1",
# In [tool.uv.sources]:
deepctl-cmd-<name> = { workspace = true }/.github/release-please-config.json:
// In "packages":
"packages/deepctl-cmd-<name>": {
"component": "deepctl-cmd-<name>",
"include-component-in-tag": true
}/.github/.release-please-manifest.json:
"packages/deepctl-cmd-<name>": "0.0.1"/.github/workflows/test.yml: Add packages/deepctl-cmd-<name>/tests to the pytest path list.
uv sync # Install new package
make check # ruff format, ruff check, mypy
uv run pytest packages/deepctl-cmd-<name>/tests/ -v
make readmes # Regenerate READMEsUse BaseGroupCommand when your command has subcommands (like dg debug audio, dg debug network).
from deepctl_core import BaseGroupCommand
class MyGroupCommand(BaseGroupCommand):
name = "mygroup"
help = "Group of related subcommands"
invoke_without_command = False # Shows help when called bare
def handle_group(self, config, auth_manager, client, **kwargs):
"""Optional: runs when group itself is invoked."""
passRegister as deepctl.commands:
[project.entry-points."deepctl.commands"]
mygroup = "deepctl_cmd_mygroup.command:MyGroupCommand"Each subcommand is a separate package extending BaseCommand, registered under deepctl.subcommands.<group-name>:
[project.entry-points."deepctl.subcommands.mygroup"]
sub1 = "deepctl_cmd_mygroup_sub1.command:Sub1Command"The CLI supports hyphenated invocation: dg mygroup-sub1 is equivalent to dg mygroup sub1.
Alternatively, override setup_commands() to define subcommands directly in the group (see SkillsCommand for reference):
def setup_commands(self) -> list[click.Command]:
return [self._create_status_command(), self._create_install_command()]External plugins use deepctl.plugins instead of deepctl.commands:
[project.entry-points."deepctl.plugins"]
myplugin = "deepctl_plugin_myplugin.command:MyPluginCommand"Users install plugins via dg plugin install <package-name>. Plugins are isolated in ~/.deepctl/plugins/venv/ for non-development installs.
To add subcommands under an existing group:
[project.entry-points."deepctl.subplugins.debug"]
myplugin = "deepctl_plugin_myplugin.command:MySubcommand"See packages/deepctl-plugin-example/ for a complete reference.
from deepctl_core import (
# Base classes
BaseCommand, BaseGroupCommand, BaseResult, ErrorResult,
# Infrastructure
Config, AuthManager, DeepgramClient,
# Output
OutputFormatter, get_console, setup_output,
print_error, print_info, print_output, print_success, print_warning,
# Auth
AuthenticationError,
# Plugins
PluginInfo, PluginManager,
# Profiles
ProfileInfo, ProfilesResult,
# Timing
TimingContext, enable_timing, get_timing_summary,
is_timing_enabled, print_timing_summary,
)- User config:
~/.config/deepctl/config.yaml(Linux) /~/Library/Application Support/deepctl/config.yaml(macOS) - Project config:
./deepgram.yamlin current directory - Environment variables:
DEEPGRAM_API_KEY,DEEPGRAM_PROJECT_ID,DEEPGRAM_BASE_URL,DEEPGRAM_OUTPUT_FORMAT,DEEPGRAM_PROFILE - Profile precedence: explicit
--profileflag >active_profile(fromdg profiles switch) >default_profile
- Credential precedence: explicit
--api-keyflag > OS keyring (profile-scoped) > config file > env vars - Keyring service:
com.deepgram.dx.deepctl, keys stored asapi-key.<profile_name> - Device flow: OAuth via
community.deepgram.com/api/auth/device/* auth_manager.guard()verifies the API key by hittingGET /v1/projectsauth_manager.get_api_key()returns the key following the precedence chainauth_manager.is_authenticated()returns True if any valid key is available
The --output / -o global flag controls format: json (default), yaml, table, csv.
Commands that print Rich output directly (tables, status messages) should override output_result() to suppress the automatic JSON dump in default mode:
def output_result(self, result: Any, config: Config) -> None:
from deepctl_core.output import get_output_format
if get_output_format() in ("default",):
return # Rich output already printed
super().output_result(result, config)Wraps the deepgram-sdk. The SDK client is lazily created on first access. Key methods:
client.transcribe_file(path, options)/client.transcribe_url(url, options)client.get_projects()/client.get_project(id)client.get_usage(project_id, start, end)client.validate_api_key()/client.test_connection()
from deepctl_shared_utils import (
FileInfo, # Pydantic model for file metadata
validate_audio_file, # Check file exists, is audio, is readable
validate_date_format, # ISO 8601 validation
validate_url, # URL format + optional HEAD probe
)The get_arguments() return value is a list of dicts. Key differences:
| Type | Key for name | is_option |
Example |
|---|---|---|---|
| Positional arg | "name" (singular string) |
absent or False |
{"name": "source", "type": str, "required": True} |
| Option | "names" (list of strings) |
True |
{"names": ["--model", "-m"], "type": str, "is_option": True} |
| Flag | "names" (list of strings) |
True |
{"names": ["--verbose"], "is_flag": True, "is_option": True} |
| Multi-value | "names" (list of strings) |
True |
{"names": ["-H", "--header"], "multiple": True, "is_option": True} |
Click converts --my-option to my_option in kwargs. Arguments are processed in reverse order (Click decorator stacking).
| Target | Description |
|---|---|
make dev |
Full dev cycle: format + lint-fix + test |
make check |
Quick quality check: format-check + lint-check + typecheck (no tests) |
make test |
Run pytest |
make format |
Auto-format with ruff |
make lint-fix |
Auto-fix lint issues |
make typecheck |
Run mypy (strict) |
make readmes |
Regenerate all READMEs from pyproject.toml metadata |
make readmes-check |
Check READMEs are up to date (CI) |
make clean |
Remove build artifacts and caches |
Aliases: make t (test), make tl (lint), make f (format), make l (lint-fix), make q (check)
READMEs are auto-generated from pyproject.toml metadata. Never manually edit sections between <!-- BEGIN:* --> and <!-- END:* --> markers.
make readmes # Regenerate all
python3 scripts/generate_readmes.py # Same thingThe script updates:
- Root
README.md: architecture tree, commands table, packages table - Each sub-package
README.md: description, commands, dependencies - Skips
deepctl-plugin-example(has manual README)
The skills system generates AI coding assistant integration files:
dg skills status # Show detected AI CLIs and installed skills
dg skills install # Install skill files for detected AI CLIs
dg skills update # Regenerate skill files (e.g., after adding a command)
dg skills remove # Remove installed skill filesSupported AI CLIs: Claude Code, Codex, Cursor, Gemini, Aider, Amazon Q, OpenCode, Cline.
After adding a new command, run dg skills update to include it in skill files.
uv run pytest # All tests
uv run pytest packages/deepctl-cmd-<name>/tests/ -v # Single package
uv run pytest -x # Stop on first failure
uv run pytest -xvs # Verbose with stdoutfrom unittest.mock import Mock, patch
from deepctl_core import AuthManager, Config, DeepgramClient
@pytest.fixture
def mock_config():
config = Mock(spec=Config)
config.get.return_value = None
return config
@pytest.fixture
def mock_auth_manager():
auth = Mock(spec=AuthManager)
auth.get_api_key.return_value = None
auth.is_authenticated.return_value = False
return auth
@pytest.fixture
def mock_client():
return Mock(spec=DeepgramClient)
class TestYourCommand:
def test_handle(self, mock_config, mock_auth_manager, mock_client):
cmd = YourCommand()
result = cmd.handle(
config=mock_config,
auth_manager=mock_auth_manager,
client=mock_client,
some_arg="value",
)
assert result.status == "success"Use @patch("deepctl_cmd_<name>.command.some_module") to mock external dependencies. Use tmp_path fixture for filesystem tests.
Tests run on Python 3.10-3.14 across Ubuntu, Windows, and macOS. The lint job runs ruff format, ruff check, and mypy on all src/ and packages/*/src paths.
- Line length: 88
- Target: Python 3.10
- Key rules: pycodestyle (E/W), pyflakes (F), isort (I), bugbear (B), comprehensions (C4), pyupgrade (UP), unused-args (ARG), simplify (SIM), type-checking (TCH), ruff-specific (RUF)
- Per-file ignores:
__init__.pyignores F401;test_*.pyignores F401 and ARG;command.pyignores ARG002/ARG005/RUF012
- Strict mode enabled globally
- Override:
deepctl_cmd_mcp.*disablescall-arg(FastMCP incomplete stubs)
- Use
from __future__ import annotationsfor forward references - Move type-only imports into
TYPE_CHECKINGblocks (enforced by TCH rules) - Ruff auto-sorts imports (isort rules)
Each package is versioned independently via release-please.
# x-release-please-versioncomments onversion =lines mark what gets bumped (NOT on dependency lines).github/.release-please-manifest.jsontracks current versions per package.github/release-please-config.jsondefines package components
- PRs merge to
main release-please.ymlcreates/updates a release PR with changelogs- Merging the release PR creates a
v*tag release.ymlbuilds all packages and publishes to PyPI viatwine
- Add to
.github/release-please-config.json"packages"section - Add to
.github/.release-please-manifest.jsonwith initial version (e.g."0.0.1")
| Command | Package | Auth | Description |
|---|---|---|---|
dg login |
deepctl-cmd-login |
No | Browser-based or API key authentication |
dg logout |
deepctl-cmd-login |
No | Clear credentials |
dg profiles |
deepctl-cmd-login |
No | Manage named profiles |
dg transcribe |
deepctl-cmd-transcribe |
Yes | Speech-to-text for files and URLs |
dg projects |
deepctl-cmd-projects |
Yes | List/manage Deepgram projects |
dg usage |
deepctl-cmd-usage |
Yes | View usage statistics |
dg api |
deepctl-cmd-api |
Yes | Raw API requests (dg api GET /v1/projects) |
dg init |
deepctl-cmd-init |
No | Scaffold starter apps from templates gallery |
dg mcp |
deepctl-cmd-mcp |
No | Run MCP server for AI assistants |
dg debug |
deepctl-cmd-debug |
No | Debug group (audio, browser, network, probe, stream) |
dg debug probe |
deepctl-cmd-debug-probe |
No | Live ffprobe analysis during streaming |
dg ffprobe |
deepctl-cmd-ffprobe |
No | FFprobe configuration |
dg update |
deepctl-cmd-update |
No | Check for and install updates |
dg plugin |
deepctl-cmd-plugin |
No | Manage external plugins |
dg skills |
deepctl-cmd-skills |
No | AI coding assistant integrations |
- One package per command — each command is an independent installable package
- Entry points for discovery — no hardcoded imports in the main CLI
- BaseCommand for leaf commands — implement
handle()andget_arguments() - BaseGroupCommand for groups — use entry points or
setup_commands()for subcommands - Pydantic models for results — subclass
BaseResultfor structured output - Rich for human output — tables, spinners, colored text; override
output_result()to control when JSON is emitted - Override
output_result()— if your command prints Rich output, suppress auto-JSON in default mode make readmesafter changes — always regenerate READMEs after adding/modifying packagesdg skills updateafter adding commands — regenerate AI assistant skill files- Version markers on
version =lines only —# x-release-please-version(not on dependency lines)