From fcc438191592e3ee876d1edad0091dffa16a1ae1 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 1 Nov 2026 13:33:19 -0500 Subject: [PATCH 01/25] refactor(mcp): share one proxy and tool table for every dg mcp path The base runs dg mcp from a minimal copy of the vendored proxy in deepctl_cmd_mcp.proxy. dg mcp's fast path and the command now share it, and the port is finished: - initialize and tools/list come from one fixed local instructions and tool table, in deepctl.mcp._fast; the proxy's own copy goes, so the two paths can't drift; - the fast path's hand-off serves through the proxy (its LineWriter drops the reply to the replayed initialize) instead of its own adapter, so nothing connects until a tool call and a remote failure is that call's tool error; - key lookup (key_or_error) lives in the hand-off module and serves both paths: no key, an unreadable config or a config key an agent session doesn't trust give the same text; - the HTTP client takes the transport's no-cookies setting; it still follows no redirects, and a 3xx keeps core's refusal text. The base's tests that duplicate these now live in test_handoff.py; the rest stay in test_proxy.py, pointed at the shared table. The base's end-to-end tests (with the --debug and same-origin redirect cases) are kept as they are. --- .../src/deepctl_cmd_mcp/command.py | 37 +-- .../src/deepctl_cmd_mcp/handoff.py | 95 ++------ .../src/deepctl_cmd_mcp/proxy.py | 98 +++----- .../tests/e2e/test_stdio_e2e.py | 2 +- .../tests/unit/test_handoff.py | 220 +++++++++++++----- .../tests/unit/test_mcp_command.py | 54 +---- .../deepctl-cmd-mcp/tests/unit/test_proxy.py | 5 +- src/deepctl/mcp/_fast.py | 20 +- 8 files changed, 250 insertions(+), 281 deletions(-) diff --git a/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py b/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py index 4fab92d7..7fa3f92a 100644 --- a/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py +++ b/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py @@ -4,7 +4,6 @@ import os import signal -import sys from typing import TYPE_CHECKING, Any from deepctl_core import AuthManager, BaseCommand, Config, DeepgramClient, output @@ -22,15 +21,7 @@ from . import messages as mcp_msg from .models import MCPServerResult, TransportType -from .proxy import ( - CANT_START, - DEFAULT_BASE_URL, - NO_KEY, - isolate_stdout, - reason, - report_error, - run_proxy, -) +from .proxy import DEFAULT_BASE_URL, isolate_stdout, report_error, run_proxy if TYPE_CHECKING: pass @@ -52,30 +43,6 @@ def __getattr__(name: str) -> Any: console = stderr_console -def key_or_error(config: Any, auth_manager: Any) -> str: - """The API key, or RuntimeError with the complete text for the client.""" - from contextlib import redirect_stdout - - from deepctl_core import messages as msg - - try: - with redirect_stdout(sys.stderr): - api_key = auth_manager.get_api_key() - except Exception as exc: - raise RuntimeError(CANT_START.format(reason=reason(exc))) from exc - if api_key: - return str(api_key) - if getattr(config, "api_key_untrusted", False) is True: - # The config file's key was left unused (W-PERM-AGENT / - # W-CFG-UNTRUSTED): send the agent to a person, never to dg login. - raise RuntimeError( - msg.E_KEY_UNTRUSTED.format(user_config=msg.display_path(config.config_path)) - + " " - + msg.W_CFG_UNTRUSTED_FILE_HINT - ) - raise RuntimeError(CANT_START.format(reason=NO_KEY)) - - def _refuse_transport(transport: str) -> None: """Report an unsupported --transport on stderr and exit 1. @@ -221,6 +188,8 @@ def handle( report(err) raise SystemExit(1) from err + from .handoff import key_or_error + debug = kwargs.get("debug", False) # Set up signal handling for graceful shutdown diff --git a/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/handoff.py b/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/handoff.py index b00d7ce1..b3287a4b 100644 --- a/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/handoff.py +++ b/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/handoff.py @@ -4,7 +4,8 @@ calls ``serve_after_handshake`` on the first request it can't answer. This replays the client's ``initialize`` to the local MCP server in ``deepctl_cmd_mcp.proxy`` (dropping that second reply), then feeds it the -pending request and the rest of stdin. +pending request and the rest of stdin. Nothing connects to Deepgram until a +tool call, and a remote failure is that call's tool error. Contract (see ``deepctl.mcp._fast``): returns the exit code when the session ends; raises an ``Exception`` whose message the client sees only when it @@ -19,6 +20,8 @@ from contextlib import redirect_stdout from typing import TYPE_CHECKING, Any, BinaryIO +from .proxy import CANT_START, NO_KEY, LineWriter, reason, report_error + if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -26,28 +29,14 @@ _REPLAY_ID = "deepctl-fast-path-initialize" -def _report_error(exc: BaseException) -> None: - """Errors-only telemetry, as `dg mcp` reports proxy failures.""" - try: - from deepctl_telemetry import report_exception - - report_exception(exc) - except Exception: - pass - - class _ReplayThenStdin: """Async lines: the replayed handshake and pending request, then stdin.""" def __init__(self, lines: list[str], stdin: BinaryIO) -> None: self._lines = lines self._stdin = stdin - # Set once the server starts reading: from then on a failure is - # reported on stderr, not as a start failure. - self.started = False def __aiter__(self) -> AsyncIterator[str]: - self.started = True return self._lines_then_stdin() async def _lines_then_stdin(self) -> AsyncIterator[str]: @@ -60,46 +49,12 @@ async def _lines_then_stdin(self) -> AsyncIterator[str]: yield line -class _DropReplayReply: - """stdout for the proxy server, minus its reply to the replayed initialize.""" - - def __init__(self, stdout: BinaryIO) -> None: - self._stdout = stdout - self._dropped = False - - async def write(self, data: str) -> None: - if not self._dropped: - try: - reply_id = json.loads(data).get("id") - except (ValueError, AttributeError): - reply_id = None - if reply_id == _REPLAY_ID: - self._dropped = True - return - self._stdout.write(data.encode("utf-8")) - - async def flush(self) -> None: - self._stdout.flush() - - -# The client sees these texts (as a tool error) and stderr gets them after -# "ERROR: dg mcp: ". A local cause (no key, unreadable config) never reads -# as Deepgram being down. -CANT_START = "dg mcp can't start the documentation search: {reason}" -CANT_REACH = "dg mcp couldn't reach Deepgram's documentation search: {reason}" -NO_KEY = ( - "No API key is set. Run dg login or set DEEPGRAM_API_KEY, then restart " - "the MCP server." -) - - def _api_key() -> str: """The key `dg mcp` would use (flag-less: profile, keyring or env). Raises RuntimeError with the client-facing text when there is none. """ from deepctl_core import AuthManager, Config - from deepctl_core import messages as msg from deepctl_core.config import flush_config_notices try: @@ -114,11 +69,24 @@ def _api_key() -> str: init_telemetry(config) except Exception: pass - api_key = AuthManager(config).get_api_key() + return key_or_error(config, AuthManager(config)) + except RuntimeError: + raise except Exception as exc: - raise RuntimeError(CANT_START.format(reason=_reason(exc))) from exc + raise RuntimeError(CANT_START.format(reason=reason(exc))) from exc + + +def key_or_error(config: Any, auth_manager: Any) -> str: + """The API key, or RuntimeError with the complete text for the client.""" + from deepctl_core import messages as msg + + try: + with redirect_stdout(sys.stderr): + api_key = auth_manager.get_api_key() + except Exception as exc: + raise RuntimeError(CANT_START.format(reason=reason(exc))) from exc if api_key: - return api_key + return str(api_key) if getattr(config, "api_key_untrusted", False) is True: # The config file's key was left unused (W-PERM-AGENT / # W-CFG-UNTRUSTED): send the agent to a person, never to dg login. @@ -136,7 +104,7 @@ def serve_after_handshake( stdin: BinaryIO, stdout: BinaryIO, ) -> int: - """Serve the rest of a stdio session through the local MCP server.""" + """Serve the rest of a stdio session with the documentation search.""" import asyncio from deepctl_core.hosts import canonical_url @@ -156,12 +124,11 @@ def serve_after_handshake( json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}), json.dumps(pending_message), ] - lines = _ReplayThenStdin(replay, stdin) try: asyncio.run( serve( - lines, - _DropReplayReply(stdout), + _ReplayThenStdin(replay, stdin), + LineWriter(stdout, drop_id=_REPLAY_ID), base_url=canonical_url(DEFAULT_BASE_URL), api_key=lambda: api_key, ) @@ -169,19 +136,7 @@ def serve_after_handshake( except KeyboardInterrupt: return 0 except Exception as exc: - _report_error(exc) - reason = _reason(exc) - if not lines.started: - raise RuntimeError(CANT_REACH.format(reason=reason)) from exc - print(f"ERROR: dg mcp: {reason}", file=sys.stderr) + report_error(exc) + print(f"ERROR: dg mcp: {reason(exc)}", file=sys.stderr) return 1 return 0 - - -def _reason(exc: BaseException) -> str: - """One line: the innermost cause of an (exception-group) failure.""" - # ExceptionGroup is 3.11+; anyio raises its backport on 3.10. - while getattr(exc, "exceptions", None): - exc = exc.exceptions[0] # type: ignore[attr-defined] - text = str(exc).strip().splitlines() - return text[0] if text else type(exc).__name__ diff --git a/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/proxy.py b/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/proxy.py index fcc6c2fc..d54cc28c 100644 --- a/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/proxy.py +++ b/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/proxy.py @@ -4,8 +4,8 @@ remote can never break the local session: - ``initialize`` and ``tools/list`` are answered from fixed local text - (``INSTRUCTIONS`` and ``TOOLS`` below). The remote's instructions and - tool descriptions are never passed through. + (``deepctl.mcp._fast.INSTRUCTIONS`` and ``TOOLS``). The remote's + instructions and tool descriptions are never passed through. - Nothing connects until a ``tools/call``. Each call opens its own session to ``{base_url}/kapa/mcp``, so a remote that is down, refuses the key or restarted is an ``isError`` result for that call, and the next call tries @@ -13,7 +13,8 @@ - The remote's ``CallToolResult`` is returned whole: ``content``, ``isError``, ``structuredContent`` and ``_meta``. - The HTTP client follows no redirects, keeps no cookies and verifies - TLS: a 3xx is a tool error, so the key never goes to another origin. + TLS, like the rest of deepctl: a 3xx is a tool error, so the key never + goes to another origin. Callers decide which hosts may receive the key before they get here. """ @@ -35,42 +36,6 @@ DEFAULT_BASE_URL = DEFAULT_DX_URL REMOTE_PATH = "/kapa/mcp" -SERVER_NAME = "deepgram-mcp" - -# Fixed local text, never passed through from the remote service: its -# instructions and tool descriptions are text an attacker could influence. -INSTRUCTIONS = ( - "Deepgram developer documentation search. Call " - "search_deepgram_knowledge_sources with a question about Deepgram's " - "speech-to-text, text-to-speech, Voice Agent or audio intelligence APIs, " - "SDKs or CLI. Results are passages from Deepgram's documentation, each " - "with its source URL; cite the URL when you use a passage." -) - -# The tool names are the remote service's; the descriptions and schemas are -# ours. Calls are forwarded only for the names in this table. -TOOLS: list[dict[str, Any]] = [ - { - "name": "search_deepgram_knowledge_sources", - "title": "Search Deepgram documentation", - "description": ( - "Search Deepgram's documentation, API reference and guides. " - "Returns the most relevant passages, each with its source URL." - ), - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "A question or search terms, in natural language.", - } - }, - "required": ["query"], - }, - "annotations": {"readOnlyHint": True, "openWorldHint": True}, - } -] - # Opening the remote session (HTTP connect plus MCP initialize) and one call. CONNECT_TIMEOUT_S = 15.0 CALL_TIMEOUT_S = 120.0 @@ -185,16 +150,15 @@ def http_client( timeout: Any = None, auth: Any = None, ) -> Any: - """The MCP SDK's ``httpx_client_factory``: no redirects, no cookies. + """The MCP SDK's ``httpx_client_factory``: deepctl's client settings. - A 3xx could take the key to another origin. The SDK's own factory - follows every redirect before mcp 1.30, and 1.30 follows same-origin - ones itself up to ``max_redirects``, so that is 0 too: any 3xx is an - ``HTTPStatusError`` and a tool error. TLS is verified. + No redirects (a 3xx could take the key to another origin), no cookies, + TLS verified. The SDK's own factory follows every redirect before mcp + 1.30, and 1.30 follows same-origin ones itself up to ``max_redirects``, + so that is 0 too: any 3xx is an ``HTTPStatusError`` and a tool error. """ - from http.cookiejar import DefaultCookiePolicy - import httpx + from deepctl_core.transport import _no_cookies client = httpx.AsyncClient( headers=headers, @@ -204,28 +168,18 @@ def http_client( max_redirects=0, verify=True, ) - client.cookies.jar.set_policy(DefaultCookiePolicy(allowed_domains=[])) + _no_cookies(client) # type: ignore[arg-type] return client def local_tools() -> list[types.Tool]: """The fixed local tool table, as the MCP SDK's models.""" import mcp.types as types + from deepctl.mcp._fast import TOOLS return [types.Tool.model_validate(tool) for tool in TOOLS] -def _version() -> str: - from importlib.metadata import PackageNotFoundError, version - - for dist in ("deepctl", "deepctl-cmd-mcp"): - try: - return version(dist) - except PackageNotFoundError: - continue - return "0" - - async def call_remote( base_url: str, api_key: str, @@ -282,6 +236,8 @@ def build_server( the complete text for the client. It runs on each call, so a key set up after the server started (``dg login``) is picked up. """ + from deepctl import __version__ + from deepctl.mcp._fast import INSTRUCTIONS, SERVER_NAME from mcp.server.lowlevel import NotificationOptions, Server from mcp.server.models import InitializationOptions @@ -311,7 +267,7 @@ async def handle_call_tool( options = InitializationOptions( server_name=SERVER_NAME, - server_version=_version(), + server_version=__version__, capabilities=server.get_capabilities( notification_options=NotificationOptions(), experimental_capabilities={}, @@ -322,12 +278,27 @@ async def handle_call_tool( class LineWriter: - """The protocol stream for the MCP SDK's stdio server: UTF-8 lines.""" + """The protocol stream for the MCP SDK's stdio server: UTF-8 lines. - def __init__(self, stdout: BinaryIO) -> None: + Optionally drops the server's reply to one request id (a replayed + ``initialize`` that the client already got an answer to). + """ + + def __init__(self, stdout: BinaryIO, drop_id: object = None) -> None: self._stdout = stdout + self._drop_id = drop_id async def write(self, data: str) -> None: + if self._drop_id is not None: + import json + + try: + reply_id = json.loads(data).get("id") + except (ValueError, AttributeError): + reply_id = None + if reply_id == self._drop_id: + self._drop_id = None + return self._stdout.write(data.encode("utf-8")) async def flush(self) -> None: @@ -366,10 +337,7 @@ async def serve( api_key: Callable[[], str], debug: bool = False, ) -> None: - """Serve one MCP session over the given async line streams. - - ``stdin`` of ``None`` is the process's own stdin. - """ + """Serve one MCP session over the given async line streams.""" import mcp.server.stdio as stdio server, options = build_server(base_url=base_url, api_key=api_key, debug=debug) diff --git a/packages/deepctl-cmd-mcp/tests/e2e/test_stdio_e2e.py b/packages/deepctl-cmd-mcp/tests/e2e/test_stdio_e2e.py index ad156852..17f8164a 100644 --- a/packages/deepctl-cmd-mcp/tests/e2e/test_stdio_e2e.py +++ b/packages/deepctl-cmd-mcp/tests/e2e/test_stdio_e2e.py @@ -206,7 +206,7 @@ def test_handshake_and_tool_list_are_fixed_local_text( ) -> None: from importlib.metadata import version - from deepctl_cmd_mcp.proxy import INSTRUCTIONS, TOOLS + from deepctl.mcp._fast import INSTRUCTIONS, TOOLS async def body(session: Any, init: Any) -> Any: return init, await session.list_tools() diff --git a/packages/deepctl-cmd-mcp/tests/unit/test_handoff.py b/packages/deepctl-cmd-mcp/tests/unit/test_handoff.py index cb876497..64cc1255 100644 --- a/packages/deepctl-cmd-mcp/tests/unit/test_handoff.py +++ b/packages/deepctl-cmd-mcp/tests/unit/test_handoff.py @@ -1,4 +1,4 @@ -"""serve_after_handshake: the fast path's hand-off to the proxy.""" +"""serve_after_handshake: the fast path's hand-off to the local MCP server.""" from __future__ import annotations @@ -9,6 +9,7 @@ import pytest import deepctl_cmd_mcp.handoff as handoff +import deepctl_cmd_mcp.proxy as proxy INIT_PARAMS = { "protocolVersion": "2025-06-18", @@ -31,63 +32,173 @@ def key(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(handoff, "_api_key", lambda: "fake-key") -async def _echo_serve(stdin: Any, stdout: Any, **kwargs: Any) -> None: - """Stands in for proxy.serve: a stdio server answering every request.""" - import mcp.server.stdio - import mcp.types as types - from mcp.shared.message import SessionMessage - - assert kwargs["api_key"]() == "fake-key" - async with mcp.server.stdio.stdio_server(stdin=stdin, stdout=stdout) as ( - read, - write, - ): - async for item in read: - root = item.message.root - if isinstance(root, types.JSONRPCRequest): - reply = types.JSONRPCResponse( - jsonrpc="2.0", id=root.id, result={"echo": root.method} - ) - await write.send(SessionMessage(types.JSONRPCMessage(reply))) - await write.aclose() +def _remote(monkeypatch: pytest.MonkeyPatch, *outcomes: Any) -> list[Any]: + """Stand in for the remote: each call takes the next outcome.""" + calls: list[Any] = [] + pending = list(outcomes) + + async def fake_call_remote( + base_url: str, + api_key: str, + name: str, + arguments: dict[str, Any], + *, + debug: bool = False, + ) -> Any: + calls.append((base_url, api_key, name, arguments)) + outcome = pending.pop(0) + if isinstance(outcome, BaseException): + raise outcome + return outcome + + monkeypatch.setattr(proxy, "call_remote", fake_call_remote) + return calls + + +def _serve(*lines: dict[str, Any], first: dict[str, Any] = CALL) -> list[Any]: + stdin = io.BytesIO(b"".join(json.dumps(m).encode() + b"\n" for m in lines)) + stdout = io.BytesIO() + assert handoff.serve_after_handshake(INIT_PARAMS, first, stdin, stdout) == 0 + return [json.loads(line) for line in stdout.getvalue().splitlines()] def test_replays_the_handshake_and_drops_its_reply( key: None, monkeypatch: pytest.MonkeyPatch ) -> None: - import deepctl_cmd_mcp.proxy as proxy + import mcp.types as types - monkeypatch.setattr(proxy, "serve", _echo_serve) - stdin = io.BytesIO(b'{"jsonrpc":"2.0","id":4,"method":"ping"}\n') - stdout = io.BytesIO() - code = handoff.serve_after_handshake(INIT_PARAMS, CALL, stdin, stdout) - assert code == 0 - replies = [json.loads(line) for line in stdout.getvalue().splitlines()] + calls = _remote( + monkeypatch, + types.CallToolResult( + content=[types.TextContent(type="text", text="rate limited")], + structuredContent={"result": "rate limited"}, + isError=True, + _meta={"remote": "kept"}, + ), + ) + replies = _serve({"jsonrpc": "2.0", "id": 4, "method": "ping"}) # The replayed initialize's reply is dropped; the client sees only its own. - assert replies == [ - {"jsonrpc": "2.0", "id": 3, "result": {"echo": "tools/call"}}, - {"jsonrpc": "2.0", "id": 4, "result": {"echo": "ping"}}, + assert [r["id"] for r in replies] == [3, 4] + # The remote's result comes back whole. + assert replies[0]["result"] == { + "_meta": {"remote": "kept"}, + "content": [{"type": "text", "text": "rate limited"}], + "structuredContent": {"result": "rate limited"}, + "isError": True, + } + assert replies[1]["result"] == {} + assert calls == [ + ( + "https://api.dx.deepgram.com", + "fake-key", + "search_deepgram_knowledge_sources", + {"query": "x"}, + ) ] -def test_connection_failure_raises_before_touching_streams( +def _status_error(status: int) -> Exception: + import httpx + + request = httpx.Request("POST", "https://api.dx.deepgram.com/kapa/mcp") + response = httpx.Response(status, request=request) + return httpx.HTTPStatusError(str(status), request=request, response=response) + + +class _Group(Exception): + """Shaped like an ExceptionGroup (3.11+; a backport on 3.10).""" + + def __init__(self, *exceptions: BaseException) -> None: + super().__init__("unhandled errors in a TaskGroup") + self.exceptions = exceptions + + +REACH = "dg mcp couldn't reach Deepgram's documentation search: " + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + ( + _Group(ConnectionError("[Errno -3] Temporary failure in name resolution")), + "[Errno -3] Temporary failure in name resolution", + ), + ( + _Group(_Group(_status_error(401))), + "Deepgram refused the API key (HTTP 401).", + ), + ( + _status_error(429), + "too many requests (HTTP 429). Try again in a minute.", + ), + (_status_error(502), "the service answered HTTP 502."), + ( + _status_error(307), + "https://api.dx.deepgram.com answered with a redirect to ?. dg mcp " + "doesn't follow redirects, so nothing was sent there.", + ), + (proxy.RemoteTimeoutError(15.0), "no answer within 15 seconds."), + (TimeoutError(), "no answer within 135 seconds."), + ], +) +def test_remote_failure_is_a_tool_error_and_the_session_goes_on( + key: None, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + error: Exception, + reason: str, +) -> None: + import mcp.types as types + + ok = types.CallToolResult(content=[types.TextContent(type="text", text="ok")]) + _remote(monkeypatch, error, ok) + replies = _serve(dict(CALL, id=5)) + assert replies[0]["id"] == 3 + assert replies[0]["result"]["isError"] is True + assert replies[0]["result"]["content"] == [{"type": "text", "text": REACH + reason}] + # The next call connects again and succeeds. + assert replies[1]["id"] == 5 + assert replies[1]["result"]["content"][0]["text"] == "ok" + assert f"ERROR: dg mcp: {REACH}{reason}" in capsys.readouterr().err + + +def test_expected_failures_are_not_reported_as_bugs( + key: None, monkeypatch: pytest.MonkeyPatch +) -> None: + reported: list[BaseException] = [] + monkeypatch.setattr( + "deepctl_telemetry.report_exception", reported.append, raising=False + ) + bug = ValueError("unexpected") + _remote(monkeypatch, _status_error(401), bug) + _serve(dict(CALL, id=5)) + assert reported == [bug] + + +def test_unknown_tool_is_refused_locally( key: None, monkeypatch: pytest.MonkeyPatch ) -> None: - import deepctl_cmd_mcp.proxy as proxy + calls = _remote(monkeypatch) + call = dict(CALL, params={"name": "failing_tool", "arguments": {}}) + replies = _serve(first=call) + assert replies[0]["result"]["isError"] is True + assert replies[0]["result"]["content"][0]["text"] == ( + "Unknown tool: failing_tool. dg mcp has: search_deepgram_knowledge_sources." + ) + assert calls == [] - async def offline(stdin: Any, stdout: Any, **kwargs: Any) -> None: - raise ConnectionError("[Errno -3] Temporary failure in name resolution") - monkeypatch.setattr(proxy, "serve", offline) - stdin = io.BytesIO(b'{"jsonrpc":"2.0","id":4,"method":"ping"}\n') - stdout = io.BytesIO() - with pytest.raises( - RuntimeError, - match="^dg mcp couldn't reach Deepgram's documentation search: .*name resolution", - ): - handoff.serve_after_handshake(INIT_PARAMS, CALL, stdin, stdout) - assert stdin.tell() == 0 - assert stdout.getvalue() == b"" +def test_bad_arguments_are_refused_locally( + key: None, monkeypatch: pytest.MonkeyPatch +) -> None: + calls = _remote(monkeypatch) + call = dict( + CALL, params={"name": "search_deepgram_knowledge_sources", "arguments": {}} + ) + replies = _serve(first=call) + assert replies[0]["result"]["isError"] is True + assert "query" in replies[0]["result"]["content"][0]["text"] + assert calls == [] def test_no_api_key_is_a_start_failure( @@ -147,14 +258,19 @@ def test_untrusted_config_key_sends_an_agent_to_a_person( assert captured.err.strip() and captured.out == "" -class _Group(Exception): - """Shaped like an ExceptionGroup (3.11+; a backport on 3.10).""" +def test_reason_unwraps_exception_groups() -> None: + group = _Group(_Group(OSError("connection refused\nmore detail"))) + assert proxy.reason(group) == "connection refused" - def __init__(self, *exceptions: BaseException) -> None: - super().__init__("unhandled errors in a TaskGroup") - self.exceptions = exceptions +def test_http_client_follows_no_redirects_and_keeps_no_cookies() -> None: + import asyncio -def test_reason_unwraps_exception_groups() -> None: - group = _Group(_Group(OSError("connection refused\nmore detail"))) - assert handoff._reason(group) == "connection refused" + client = proxy.http_client(headers={"Authorization": "Token k"}) + try: + assert client.follow_redirects is False + assert client.max_redirects == 0 + assert client.headers["Authorization"] == "Token k" + assert not client.cookies.jar._policy.allowed_domains() + finally: + asyncio.run(client.aclose()) diff --git a/packages/deepctl-cmd-mcp/tests/unit/test_mcp_command.py b/packages/deepctl-cmd-mcp/tests/unit/test_mcp_command.py index 484968f3..8b284af1 100644 --- a/packages/deepctl-cmd-mcp/tests/unit/test_mcp_command.py +++ b/packages/deepctl-cmd-mcp/tests/unit/test_mcp_command.py @@ -58,8 +58,6 @@ def test_handle_no_api_key_still_serves(self): """No key: the server starts anyway; the key is read per tool call.""" auth_manager = MagicMock() auth_manager.get_api_key.return_value = None - config = MagicMock() - config.api_key_untrusted = False seen = {} with ( patch("deepctl_cmd_mcp.command.run_proxy", lambda **kw: seen.update(kw)), @@ -67,52 +65,24 @@ def test_handle_no_api_key_still_serves(self): patch("signal.signal"), ): result = McpCommand().handle( - config, auth_manager, MagicMock(), transport="stdio" + MagicMock(), auth_manager, MagicMock(), transport="stdio" ) assert result is None auth_manager.get_api_key.assert_not_called() with pytest.raises(RuntimeError, match="No API key is set"): seen["api_key"]() - def test_key_is_read_on_each_call(self): - auth_manager = MagicMock() - auth_manager.get_api_key.side_effect = ["k1", "k2"] + def test_proxy_gets_the_isolated_protocol_stream(self): + stream = object() seen = {} with ( + patch("deepctl_cmd_mcp.command.isolate_stdout", return_value=stream), patch("deepctl_cmd_mcp.command.run_proxy", lambda **kw: seen.update(kw)), patch("deepctl_cmd_mcp.command.asyncio.run"), patch("signal.signal"), ): - McpCommand().handle(MagicMock(), auth_manager, MagicMock()) - assert [seen["api_key"](), seen["api_key"]()] == ["k1", "k2"] - - def test_untrusted_config_key_sends_an_agent_to_a_person(self): - """The W-PERM-AGENT rule: never tell an agent to run dg login.""" - from deepctl_core import messages as msg - - from deepctl_cmd_mcp.command import key_or_error - - config = MagicMock() - config.api_key_untrusted = True - config.config_path = "/x/config.yaml" - auth_manager = MagicMock() - auth_manager.get_api_key.return_value = None - with pytest.raises(RuntimeError) as exc: - key_or_error(config, auth_manager) - text = str(exc.value) - assert text.endswith(msg.W_CFG_UNTRUSTED_FILE_HINT) - assert "dg login" not in text - - def test_key_lookup_failure_is_a_start_error(self): - from deepctl_cmd_mcp.command import key_or_error - - auth_manager = MagicMock() - auth_manager.get_api_key.side_effect = OSError("keyring locked\nmore") - with pytest.raises(RuntimeError) as exc: - key_or_error(MagicMock(), auth_manager) - assert str(exc.value) == ( - "dg mcp can't start the documentation search: keyring locked" - ) + McpCommand().handle(MagicMock(), MagicMock(), MagicMock()) + assert seen["stdout"] is stream def test_result_never_reaches_stdout(self, capsys): """stdout belongs to the MCP client, even for a failure result.""" @@ -121,18 +91,6 @@ def test_result_never_reaches_stdout(self, capsys): ) assert capsys.readouterr().out == "" - def test_proxy_gets_the_isolated_protocol_stream(self): - stream = object() - seen = {} - with ( - patch("deepctl_cmd_mcp.command.isolate_stdout", return_value=stream), - patch("deepctl_cmd_mcp.command.run_proxy", lambda **kw: seen.update(kw)), - patch("deepctl_cmd_mcp.command.asyncio.run"), - patch("signal.signal"), - ): - McpCommand().handle(MagicMock(), MagicMock(), MagicMock()) - assert seen["stdout"] is stream - @patch("deepctl_cmd_mcp.command.asyncio.run") @patch("signal.signal") def test_handle_stdio_transport_returns_none(self, mock_signal, mock_asyncio_run): diff --git a/packages/deepctl-cmd-mcp/tests/unit/test_proxy.py b/packages/deepctl-cmd-mcp/tests/unit/test_proxy.py index f04775d7..fb2d4478 100644 --- a/packages/deepctl-cmd-mcp/tests/unit/test_proxy.py +++ b/packages/deepctl-cmd-mcp/tests/unit/test_proxy.py @@ -9,6 +9,7 @@ import pytest import deepctl_cmd_mcp.proxy as proxy +from deepctl.mcp import _fast INIT = { "jsonrpc": "2.0", @@ -115,13 +116,13 @@ async def run() -> None: anyio.run(run) init, listed = [json.loads(line) for line in stdout.getvalue().splitlines()] - assert init["result"]["instructions"] == proxy.INSTRUCTIONS + assert init["result"]["instructions"] == _fast.INSTRUCTIONS assert init["result"]["serverInfo"]["name"] == "deepgram-mcp" assert [t["name"] for t in listed["result"]["tools"]] == [ "search_deepgram_knowledge_sources" ] assert ( - listed["result"]["tools"][0]["description"] == (proxy.TOOLS[0]["description"]) + listed["result"]["tools"][0]["description"] == (_fast.TOOLS[0]["description"]) ) assert calls == [] diff --git a/src/deepctl/mcp/_fast.py b/src/deepctl/mcp/_fast.py index 1dea724e..8c9a0fc8 100644 --- a/src/deepctl/mcp/_fast.py +++ b/src/deepctl/mcp/_fast.py @@ -8,12 +8,13 @@ ``bench/gates.json``). The first ``tools/call`` after ``initialize`` goes to -``deepctl_cmd_mcp.serve_after_handshake``, which connects to the service -and serves the rest of the session. When that can't start (no API key, no -network), the call gets a tool error and this loop keeps serving, so the -host never sees the server die. Every other method, ``server/discover`` -included, gets "Method not found" here and never causes a connection; a -``tools/call`` before ``initialize`` gets "not initialized". +``deepctl_cmd_mcp.serve_after_handshake``, which serves the rest of the +session and connects to the service once per tool call. When it can't +start (no API key, unreadable config), the call gets a tool error and this +loop keeps serving, so the host never sees the server die. Every other +method, ``server/discover`` included, gets "Method not found" here and +never causes a connection; a ``tools/call`` before ``initialize`` gets +"not initialized". Contract with ``deepctl_cmd_mcp.serve_after_handshake(initialize_params, pending_message, stdin, stdout)``: ``stdin``/``stdout`` are the binary @@ -40,8 +41,8 @@ SERVER_NAME = "deepgram-mcp" -# Fixed local text, never passed through from the remote service -# (SECURITY-S6). W4.1 owns this text and the tool table below. +# Fixed local text, never passed through from the remote service: its +# instructions and tool descriptions are text an attacker could influence. INSTRUCTIONS = ( "Deepgram developer documentation search. Call " "search_deepgram_knowledge_sources with a question about Deepgram's " @@ -51,7 +52,8 @@ ) # The tool names are the remote service's (kapa's); the descriptions and -# schemas are ours. W4.1 keeps this table in sync with the service. +# schemas are ours. deepctl_cmd_mcp.proxy serves this same table and +# forwards calls only for the names in it. TOOLS = [ { "name": "search_deepgram_knowledge_sources", From efebe2abe1b4fdc56ea75b58e1b80e6ecf87666c Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 1 Nov 2026 13:33:44 -0500 Subject: [PATCH 02/25] fix(mcp): keep stdout for JSON-RPC from the start of dg mcp Every dg mcp run that serves (not --help or --agent-friendly), with or without root options before mcp, now moves fd 1 to stderr before Click or any command code loads. The MCP server writes to a private copy of the original stdout, so a stray print, a library warning, a config notice or a child process can no longer corrupt the client's JSON-RPC stream. main() recognises `dg [root options] mcp` without Click; a test keeps its list of value-taking root options equal to the root group's. --- .../src/deepctl_cmd_mcp/command.py | 10 ++-- .../src/deepctl_cmd_mcp/proxy.py | 25 --------- .../deepctl-cmd-mcp/tests/unit/conftest.py | 12 ----- .../tests/unit/test_mcp_command.py | 2 +- .../deepctl-cmd-mcp/tests/unit/test_proxy.py | 19 ------- src/deepctl/main.py | 50 ++++++++++++++++-- src/deepctl/mcp/_fast.py | 51 +++++++++++++++++-- tests/unit/test_entry_point.py | 6 ++- tests/unit/test_main.py | 40 +++++++++++++++ tests/unit/test_mcp_fast.py | 32 ++++++++++++ 10 files changed, 174 insertions(+), 73 deletions(-) delete mode 100644 packages/deepctl-cmd-mcp/tests/unit/conftest.py diff --git a/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py b/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py index 7fa3f92a..fecf3ea4 100644 --- a/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py +++ b/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py @@ -21,7 +21,7 @@ from . import messages as mcp_msg from .models import MCPServerResult, TransportType -from .proxy import DEFAULT_BASE_URL, isolate_stdout, report_error, run_proxy +from .proxy import DEFAULT_BASE_URL, report_error, run_proxy if TYPE_CHECKING: pass @@ -188,6 +188,8 @@ def handle( report(err) raise SystemExit(1) from err + from deepctl.mcp._fast import protocol_stdout + from .handoff import key_or_error debug = kwargs.get("debug", False) @@ -200,17 +202,13 @@ def handle( import asyncio - # From here on only JSON-RPC reaches the client: anything else - # written to stdout (fd 1) goes to stderr. - protocol = isolate_stdout() - try: asyncio.run( run_proxy( base_url=canonical_url(base_url), api_key=lambda: key_or_error(config, auth_manager), debug=debug, - stdout=protocol, + stdout=protocol_stdout(), ) ) diff --git a/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/proxy.py b/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/proxy.py index d54cc28c..f32b9701 100644 --- a/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/proxy.py +++ b/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/proxy.py @@ -21,7 +21,6 @@ from __future__ import annotations -import os import sys from datetime import timedelta from typing import TYPE_CHECKING, Any, BinaryIO @@ -305,30 +304,6 @@ async def flush(self) -> None: self._stdout.flush() -def isolate_stdout() -> BinaryIO: - """Give the protocol its own copy of stdout and point fd 1 at stderr. - - After this, a stray ``print()``, a library warning or a child process - writing to fd 1 lands on stderr instead of corrupting the JSON-RPC - stream; the returned stream is the only way to the client. Falls back - to ``sys.stdout.buffer`` when the descriptors can't be duplicated. - """ - try: - sys.stdout.flush() - except (AttributeError, OSError, ValueError): - pass - try: - proto = os.dup(1) - except OSError: - return sys.stdout.buffer - try: - os.dup2(2, 1) - except OSError: - os.close(proto) - return sys.stdout.buffer - return os.fdopen(proto, "wb") - - async def serve( stdin: Any, stdout: Any, diff --git a/packages/deepctl-cmd-mcp/tests/unit/conftest.py b/packages/deepctl-cmd-mcp/tests/unit/conftest.py deleted file mode 100644 index 317b0c2a..00000000 --- a/packages/deepctl-cmd-mcp/tests/unit/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Keep the test process's own stdout: handle() would point fd 1 at stderr.""" - -from __future__ import annotations - -import io - -import pytest - - -@pytest.fixture(autouse=True) -def _no_fd_swap(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("deepctl_cmd_mcp.command.isolate_stdout", lambda: io.BytesIO()) diff --git a/packages/deepctl-cmd-mcp/tests/unit/test_mcp_command.py b/packages/deepctl-cmd-mcp/tests/unit/test_mcp_command.py index 8b284af1..5b762852 100644 --- a/packages/deepctl-cmd-mcp/tests/unit/test_mcp_command.py +++ b/packages/deepctl-cmd-mcp/tests/unit/test_mcp_command.py @@ -76,7 +76,7 @@ def test_proxy_gets_the_isolated_protocol_stream(self): stream = object() seen = {} with ( - patch("deepctl_cmd_mcp.command.isolate_stdout", return_value=stream), + patch("deepctl.mcp._fast.protocol_stdout", return_value=stream), patch("deepctl_cmd_mcp.command.run_proxy", lambda **kw: seen.update(kw)), patch("deepctl_cmd_mcp.command.asyncio.run"), patch("signal.signal"), diff --git a/packages/deepctl-cmd-mcp/tests/unit/test_proxy.py b/packages/deepctl-cmd-mcp/tests/unit/test_proxy.py index fb2d4478..beaf5278 100644 --- a/packages/deepctl-cmd-mcp/tests/unit/test_proxy.py +++ b/packages/deepctl-cmd-mcp/tests/unit/test_proxy.py @@ -287,22 +287,3 @@ def test_http_client_follows_no_redirects_and_keeps_no_cookies() -> None: assert not client.cookies.jar._policy.allowed_domains() finally: asyncio.run(client.aclose()) - - -def test_isolate_stdout_moves_fd1_to_stderr(tmp_path: Any) -> None: - """In a child process: stray stdout writes land on stderr.""" - import subprocess - import sys - - code = ( - "import sys\n" - "from deepctl_cmd_mcp.proxy import isolate_stdout\n" - "out = isolate_stdout()\n" - "print('stray')\n" - "sys.stdout.flush()\n" - "out.write(b'protocol\\n'); out.flush()\n" - ) - proc = subprocess.run([sys.executable, "-c", code], capture_output=True, timeout=60) - assert proc.returncode == 0, proc.stderr - assert proc.stdout == b"protocol\n" - assert b"stray" in proc.stderr diff --git a/src/deepctl/main.py b/src/deepctl/main.py index 5e97e670..0bb4ec83 100644 --- a/src/deepctl/main.py +++ b/src/deepctl/main.py @@ -8,6 +8,10 @@ - ``dg mcp`` (stdio, no other options) answers the MCP handshake locally in ``deepctl.mcp._fast`` and hands the session to the proxy on the first call. +Every ``dg mcp`` run except a help request first moves fd 1 to stderr +(``deepctl.mcp._fast.isolate_stdout``), so only the MCP server can write +to the client's stdout, whatever runs after it. + Everything else imports ``deepctl._cli`` and runs the Click CLI as before. Names that used to live here (``cli``, ``load_commands`` and the helpers) resolve from ``deepctl._cli`` through ``__getattr__``; ``cli`` comes back with @@ -48,6 +52,44 @@ ) +# Root options that take a value (``dg --profile staging mcp``). Must match +# the root group's non-flag options; tests/unit/test_main.py checks it. +_ROOT_VALUE_OPTIONS = frozenset( + { + "--config", + "-c", + "--profile", + "-p", + "--base-url", + "--api-key", + "--output", + "-o", + "--record-install-method", + } +) +# Arguments that make `dg mcp` print help or metadata to stdout instead of +# serving. +_MCP_HELP_ARGS = frozenset({"--help", "-h", "--agent-friendly"}) + + +def _is_mcp_server_run(args: "list[str]") -> bool: + """True when argv runs ``dg mcp`` as a server (not for help).""" + i = 0 + while i < len(args): + arg = args[i] + if arg == "--": + i += 1 + break + if not arg.startswith("-") or arg == "-": + break + if arg in _MCP_HELP_ARGS or arg == "--version": + return False + i += 2 if arg in _ROOT_VALUE_OPTIONS else 1 + if i >= len(args) or args[i] != "mcp": + return False + return not any(arg in _MCP_HELP_ARGS for arg in args[i + 1 :]) + + def capture_launcher() -> "str | None": """Read and remove ``DEEPCTL_LAUNCHER`` (first call only); return its value.""" global LAUNCHER, LAUNCHER_CAPTURED @@ -144,10 +186,12 @@ def main(prog: "str | None" = None) -> None: # and falls back to a stub on ImportError; this skips it. sys.modules.setdefault("httpx._main", None) # type: ignore[arg-type] - if args and args[0] == "mcp" and args[1:] in _FAST_MCP_ARGS: - from deepctl.mcp._fast import serve + if _is_mcp_server_run(args): + from deepctl.mcp._fast import isolate_stdout, serve - sys.exit(serve()) + isolate_stdout() + if args[0] == "mcp" and args[1:] in _FAST_MCP_ARGS: + sys.exit(serve()) sys.excepthook = _rich_excepthook try: diff --git a/src/deepctl/mcp/_fast.py b/src/deepctl/mcp/_fast.py index 8c9a0fc8..1698c13c 100644 --- a/src/deepctl/mcp/_fast.py +++ b/src/deepctl/mcp/_fast.py @@ -24,6 +24,10 @@ written stdout. Its message is the complete text shown to the client. Nothing but JSON-RPC is ever written to stdout. Diagnostics go to stderr. +``isolate_stdout()`` (called by ``deepctl.main`` for every ``dg mcp`` run) +keeps it that way for code that doesn't know about the protocol: fd 1 then +points at stderr, and the protocol stream is a private copy of the +original stdout. """ import json @@ -41,6 +45,9 @@ SERVER_NAME = "deepgram-mcp" +# The original stdout, once isolate_stdout() moved fd 1 to stderr. +_PROTOCOL_OUT: "BinaryIO | None" = None + # Fixed local text, never passed through from the remote service: its # instructions and tool descriptions are text an attacker could influence. INSTRUCTIONS = ( @@ -85,6 +92,40 @@ CANT_START = "dg mcp can't start the documentation search: {reason}" +def isolate_stdout() -> None: + """Give the protocol its own copy of stdout and point fd 1 at stderr. + + After this, a stray ``print()``, a library warning or a child process + writing to fd 1 lands on stderr instead of corrupting the JSON-RPC + stream; ``protocol_stdout()`` is the only way to the client. Safe to + call twice. Does nothing when stdout or stderr is closed. + """ + global _PROTOCOL_OUT + if _PROTOCOL_OUT is not None: + return + try: + sys.stdout.flush() + except (AttributeError, OSError, ValueError): + pass + try: + proto = os.dup(1) + except OSError: + return + try: + os.dup2(2, 1) + except OSError: + os.close(proto) + return + _PROTOCOL_OUT = os.fdopen(proto, "wb") + + +def protocol_stdout() -> "BinaryIO": + """The stream the client reads: the isolated copy, else stdout.""" + if _PROTOCOL_OUT is not None: + return _PROTOCOL_OUT + return sys.stdout.buffer + + def _write(out: "BinaryIO", message: "dict[str, Any]") -> None: out.write(json.dumps(message, separators=(",", ":")).encode("utf-8") + b"\n") out.flush() @@ -155,13 +196,13 @@ def _hand_off( return code -def _silence_stdout() -> None: +def _silence(out: "BinaryIO") -> None: """The client closed stdout: drop further writes, including at exit.""" try: devnull = os.open(os.devnull, os.O_WRONLY) - os.dup2(devnull, sys.stdout.fileno()) + os.dup2(devnull, out.fileno()) os.close(devnull) - except (OSError, ValueError): + except (AttributeError, OSError, ValueError): pass @@ -175,7 +216,7 @@ def serve( return 0 inp = sys.stdin.buffer if out is None: - out = sys.stdout.buffer + out = protocol_stdout() initialize_params: dict[str, Any] | None = None msg_id: object = None @@ -227,7 +268,7 @@ def serve( except KeyboardInterrupt: return 0 except BrokenPipeError: - _silence_stdout() + _silence(out) return 0 except Exception as exc: sys.stderr.write(f"ERROR: dg mcp stopped: {exc}\n") diff --git a/tests/unit/test_entry_point.py b/tests/unit/test_entry_point.py index 52d8cca2..4f05f6f2 100644 --- a/tests/unit/test_entry_point.py +++ b/tests/unit/test_entry_point.py @@ -142,7 +142,9 @@ def test_popped_on_every_path(self, tmp_path: Path) -> None: "import os; print(os.environ.get('DEEPCTL_LAUNCHER'))"], capture_output=True, text=True, ).stdout.strip() - print(json.dumps({"launcher": m.LAUNCHER, "child": child})) + # stderr: `dg mcp` points fd 1 at stderr for the rest of the run. + print(json.dumps({"launcher": m.LAUNCHER, "child": child}), + file=sys.stderr) """ ) for args in (["--version"], ["--quiet", "--version"], ["mcp"]): @@ -152,7 +154,7 @@ def test_popped_on_every_path(self, tmp_path: Path) -> None: DEEPCTL_LAUNCHER="npm", ) assert proc.returncode == 0, proc.stderr - result = json.loads(proc.stdout.strip().splitlines()[-1]) + result = json.loads(proc.stderr.strip().splitlines()[-1]) assert result == {"launcher": "npm", "child": "None"}, args def test_capture_is_once(self, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 132048f4..ab2c84b3 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -531,3 +531,43 @@ def test_every_alias_comes_from_the_manifest(self): def test_preprocessor_is_gone(self): assert not hasattr(_cli_mod, "preprocess_hyphenated_commands") + + +class TestMcpServerRun: + """Which argv runs `dg mcp` as a server (stdout isolated first).""" + + @pytest.mark.parametrize( + ("argv", "expected"), + [ + (["mcp"], True), + (["mcp", "--base-url", "https://x.example"], True), + (["mcp", "--debug"], True), + (["--profile", "staging", "mcp"], True), + (["-p", "mcp", "mcp"], True), + (["-o", "json", "--quiet", "mcp", "--transport", "sse"], True), + (["--profile=mcp", "mcp"], True), + (["mcp", "--help"], False), + (["mcp", "-h"], False), + (["mcp", "--agent-friendly"], False), + (["--help", "mcp"], False), + (["--profile", "mcp"], False), + (["models", "mcp"], False), + ([], False), + ], + ) + def test_detection(self, argv, expected): + from deepctl import main as main_module + + assert main_module._is_mcp_server_run(argv) is expected + + def test_root_value_options_match_the_cli(self): + from deepctl import main as main_module + from deepctl._cli import cli + + takes_value = { + opt + for param in cli.params + if isinstance(param, click.Option) and not param.is_flag + for opt in param.opts + } + assert takes_value == main_module._ROOT_VALUE_OPTIONS diff --git a/tests/unit/test_mcp_fast.py b/tests/unit/test_mcp_fast.py index 86ab451f..d152277c 100644 --- a/tests/unit/test_mcp_fast.py +++ b/tests/unit/test_mcp_fast.py @@ -249,3 +249,35 @@ def test_tool_table_shape() -> None: for tool in _fast.TOOLS: assert set(tool) >= {"name", "description", "inputSchema"} assert tool["inputSchema"]["type"] == "object" + + +def test_isolate_stdout_leaves_only_the_protocol_on_stdout() -> None: + """Stray prints and child processes land on stderr, not the client's stream.""" + import subprocess + + code = ( + "import os, subprocess, sys\n" + "from deepctl.mcp import _fast\n" + "_fast.isolate_stdout()\n" + "_fast.isolate_stdout()\n" + "print('stray print')\n" + "sys.stdout.flush()\n" + "subprocess.run([sys.executable, '-c', 'print(\"child\")'])\n" + "out = _fast.protocol_stdout()\n" + "out.write(b'{}\\n')\n" + "out.flush()\n" + ) + proc = subprocess.run( + [sys.executable, "-c", code], capture_output=True, timeout=60, check=True + ) + assert proc.stdout == b"{}\n" + assert b"stray print" in proc.stderr + assert b"child" in proc.stderr + + +def test_serve_writes_to_the_isolated_stream(monkeypatch: pytest.MonkeyPatch) -> None: + out = io.BytesIO() + monkeypatch.setattr(_fast, "_PROTOCOL_OUT", out) + assert _fast.protocol_stdout() is out + _fast.serve(_session(_init())) + assert _replies(out)[0]["id"] == 1 From ade3f5eb76f4289f0cc10030e2af1d3c7d7cfd4f Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 1 Nov 2026 13:39:22 -0500 Subject: [PATCH 03/25] docs(mcp): describe dg mcp as the documentation search it is The help, the READMEs, llms.txt, llms-full.txt and the landing page promised SSE and streamable HTTP transports, "every CLI command" as tools and direct API access, and one README put the API key in a project .mcp.json's args. Agents follow this text literally. Now they all say what the command does: a local stdio MCP server with one tool, search_deepgram_knowledge_sources, that searches Deepgram's documentation with the key from dg login or DEEPGRAM_API_KEY. The key never goes in the MCP config, a missing key is a tool error rather than a failed start (or, for a config file dg can't trust, an error that asks a person to fix it), and Claude Desktop's literal env values are called out. The --base-url and --debug help match their behaviour, including https unless the host is loopback. The help spec is regenerated with --write-help; the cmd-mcp README matches generate_readmes.py output for the new description. A test fails on MCP text that promises what dg mcp doesn't do. It scans the READMEs, web/public, web/src and the sources (help specs included) for: - a transport other than stdio in any spelling: --transport sse, --transport=http, -t sse, "--transport", "sse" in args, an /sse URL, streamable-http, "stdio/SSE"; - a --stdout option; - an API key on a command line (--api-key KEY, --api-key=KEY, a variable) or in an MCP config's args array, however it is wrapped. Only the API-key checks skip `dg login --api-key ...`, which gives the key to the command that stores it; every other check still applies on that line. Parametrized cases cover each variant and the true text that must pass. --- README.md | 15 +- packages/deepctl-cmd-mcp/README.extra.md | 72 ++++--- packages/deepctl-cmd-mcp/README.md | 74 ++++--- packages/deepctl-cmd-mcp/pyproject.toml | 2 +- .../src/deepctl_cmd_mcp/command.py | 32 ++- src/deepctl/_manifest.d/mcp.json | 10 +- tests/unit/test_mcp_docs_lint.py | 197 ++++++++++++++++++ web/public/llms-full.txt | 13 +- web/public/llms.txt | 2 +- web/src/pages/index.astro | 5 +- 10 files changed, 337 insertions(+), 85 deletions(-) create mode 100644 tests/unit/test_mcp_docs_lint.py diff --git a/README.md b/README.md index b4184fd4..55019992 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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) @@ -522,7 +525,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 @@ -563,7 +566,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 | @@ -598,7 +601,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 | diff --git a/packages/deepctl-cmd-mcp/README.extra.md b/packages/deepctl-cmd-mcp/README.extra.md index 80ac8910..ea37a8f2 100644 --- a/packages/deepctl-cmd-mcp/README.extra.md +++ b/packages/deepctl-cmd-mcp/README.extra.md @@ -1,36 +1,43 @@ ## MCP Server Setup -The `deepctl mcp` command runs an MCP (Model Context Protocol) server over -stdio that gives AI code editors one tool: search of Deepgram's documentation. +`dg mcp` (also `deepctl mcp`) runs a local MCP (Model Context Protocol) server +over stdio. Your AI editor starts it and gets one tool, +`search_deepgram_knowledge_sources`, which searches Deepgram's documentation +and returns passages with their source URLs. Each search goes to Deepgram's +hosted documentation search and uses your Deepgram API key. `dg mcp` doesn't +transcribe, synthesize speech or call other Deepgram APIs. ### Prerequisites +The API key comes from `dg login` or the `DEEPGRAM_API_KEY` environment +variable. Never put the API key in the MCP config's `args`: a project config +such as `.mcp.json` is usually committed. + ```bash -# Login to Deepgram (stores credentials in system keyring) -deepctl login +# Log in once (stores the API key in the system keyring) +dg login -# Or set the environment variable +# Or set the environment variable in the shell that starts your editor export DEEPGRAM_API_KEY="your-api-key" ``` -### Claude Code +Without a key, the server still starts, and each search returns a tool error +that says to run `dg login` or set `DEEPGRAM_API_KEY`. If dg ignored the key in +your config file because other users can change the file or it's owned by +someone else, the error asks a person to fix the file instead; an AI agent +should ask the user. -Add to your project's `.mcp.json`: +Claude Desktop passes `env` values in its config literally, so +`"DEEPGRAM_API_KEY": "${DEEPGRAM_API_KEY}"` sends the text `${DEEPGRAM_API_KEY}` +as the key. Use `dg login` instead. -```json -{ - "mcpServers": { - "deepgram": { - "type": "stdio", - "command": "uvx", - "args": ["deepctl", "mcp"] - } - } -} +### Claude Code + +```bash +claude mcp add deepgram --scope project -- uvx deepctl mcp ``` -Or pass `DEEPGRAM_API_KEY` through the editor's `env` block, which forwards it -from your shell (a key saved by `deepctl login` takes precedence): +Or add to your project's `.mcp.json`: ```json { @@ -38,15 +45,12 @@ from your shell (a key saved by `deepctl login` takes precedence): "deepgram": { "type": "stdio", "command": "uvx", - "args": ["deepctl", "mcp"], - "env": { "DEEPGRAM_API_KEY": "${DEEPGRAM_API_KEY}" } + "args": ["deepctl", "mcp"] } } } ``` -Don't write the key itself into `.mcp.json` or any other file you commit. - ### Cursor Add to `.cursor/mcp.json`: @@ -77,6 +81,18 @@ Add to `~/.codeium/windsurf/mcp_config.json`: } ``` +### Installed CLI + +If `dg` is already installed (Homebrew, the install script, `pip`, `uv tool` +or `pipx`), use it directly in any of the above configurations: + +```json +{ + "command": "dg", + "args": ["mcp"] +} +``` + ### Alternative: pipx Replace `uvx` with `pipx run` in any of the above configurations: @@ -99,9 +115,9 @@ If running from a local clone of the repository: } ``` -### Transport modes +### Transport -The MCP server runs over stdio: your editor starts `dg mcp` and talks to it over -stdin and stdout. `--transport sse` was removed in deepctl 0.4.1. `--transport -streamable-http` was never supported. Both exit with an error, so remove them -from your MCP config. +The MCP server runs over stdio only: your editor starts `dg mcp` and talks to +it over stdin and stdout. SSE and HTTP transports aren't supported, and a +`--transport` other than `stdio` exits with an error, so remove it from your +MCP config. diff --git a/packages/deepctl-cmd-mcp/README.md b/packages/deepctl-cmd-mcp/README.md index d2fe5099..504c2bde 100644 --- a/packages/deepctl-cmd-mcp/README.md +++ b/packages/deepctl-cmd-mcp/README.md @@ -2,7 +2,7 @@ > Part of [deepctl](https://github.com/deepgram/cli) — Official Deepgram CLI -MCP server for deepctl: Deepgram documentation search over stdio +Local stdio MCP server for deepctl: search Deepgram's documentation from AI editors ## Installation @@ -33,37 +33,44 @@ pipx run deepctl --help ## MCP Server Setup -The `deepctl mcp` command runs an MCP (Model Context Protocol) server over -stdio that gives AI code editors one tool: search of Deepgram's documentation. +`dg mcp` (also `deepctl mcp`) runs a local MCP (Model Context Protocol) server +over stdio. Your AI editor starts it and gets one tool, +`search_deepgram_knowledge_sources`, which searches Deepgram's documentation +and returns passages with their source URLs. Each search goes to Deepgram's +hosted documentation search and uses your Deepgram API key. `dg mcp` doesn't +transcribe, synthesize speech or call other Deepgram APIs. ### Prerequisites +The API key comes from `dg login` or the `DEEPGRAM_API_KEY` environment +variable. Never put the API key in the MCP config's `args`: a project config +such as `.mcp.json` is usually committed. + ```bash -# Login to Deepgram (stores credentials in system keyring) -deepctl login +# Log in once (stores the API key in the system keyring) +dg login -# Or set the environment variable +# Or set the environment variable in the shell that starts your editor export DEEPGRAM_API_KEY="your-api-key" ``` -### Claude Code +Without a key, the server still starts, and each search returns a tool error +that says to run `dg login` or set `DEEPGRAM_API_KEY`. If dg ignored the key in +your config file because other users can change the file or it's owned by +someone else, the error asks a person to fix the file instead; an AI agent +should ask the user. -Add to your project's `.mcp.json`: +Claude Desktop passes `env` values in its config literally, so +`"DEEPGRAM_API_KEY": "${DEEPGRAM_API_KEY}"` sends the text `${DEEPGRAM_API_KEY}` +as the key. Use `dg login` instead. -```json -{ - "mcpServers": { - "deepgram": { - "type": "stdio", - "command": "uvx", - "args": ["deepctl", "mcp"] - } - } -} +### Claude Code + +```bash +claude mcp add deepgram --scope project -- uvx deepctl mcp ``` -Or pass `DEEPGRAM_API_KEY` through the editor's `env` block, which forwards it -from your shell (a key saved by `deepctl login` takes precedence): +Or add to your project's `.mcp.json`: ```json { @@ -71,15 +78,12 @@ from your shell (a key saved by `deepctl login` takes precedence): "deepgram": { "type": "stdio", "command": "uvx", - "args": ["deepctl", "mcp"], - "env": { "DEEPGRAM_API_KEY": "${DEEPGRAM_API_KEY}" } + "args": ["deepctl", "mcp"] } } } ``` -Don't write the key itself into `.mcp.json` or any other file you commit. - ### Cursor Add to `.cursor/mcp.json`: @@ -110,6 +114,18 @@ Add to `~/.codeium/windsurf/mcp_config.json`: } ``` +### Installed CLI + +If `dg` is already installed (Homebrew, the install script, `pip`, `uv tool` +or `pipx`), use it directly in any of the above configurations: + +```json +{ + "command": "dg", + "args": ["mcp"] +} +``` + ### Alternative: pipx Replace `uvx` with `pipx run` in any of the above configurations: @@ -132,12 +148,12 @@ If running from a local clone of the repository: } ``` -### Transport modes +### Transport -The MCP server runs over stdio: your editor starts `dg mcp` and talks to it over -stdin and stdout. `--transport sse` was removed in deepctl 0.4.1. `--transport -streamable-http` was never supported. Both exit with an error, so remove them -from your MCP config. +The MCP server runs over stdio only: your editor starts `dg mcp` and talks to +it over stdin and stdout. SSE and HTTP transports aren't supported, and a +`--transport` other than `stdio` exits with an error, so remove it from your +MCP config. ## Dependencies diff --git a/packages/deepctl-cmd-mcp/pyproject.toml b/packages/deepctl-cmd-mcp/pyproject.toml index 113e17aa..ee397b67 100644 --- a/packages/deepctl-cmd-mcp/pyproject.toml +++ b/packages/deepctl-cmd-mcp/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "deepctl-cmd-mcp" version = "0.4.1" # x-release-please-version -description = "MCP server for deepctl: Deepgram documentation search over stdio" +description = "Local stdio MCP server for deepctl: search Deepgram's documentation from AI editors" readme = "README.md" authors = [{ name = "Deepgram", email = "devrel@deepgram.com" }] requires-python = ">=3.10" diff --git a/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py b/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py index fecf3ea4..e00e320a 100644 --- a/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py +++ b/packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py @@ -55,14 +55,15 @@ def _refuse_transport(transport: str) -> None: class McpCommand(BaseCommand): - """MCP server that serves Deepgram documentation search over stdio.""" + """Local stdio MCP server that searches Deepgram's documentation.""" name = "mcp" help = ( - "Run an MCP server that exposes Deepgram documentation search to " - "your AI editor over stdio" + "Run a local MCP server over stdio that lets AI editors search " + "Deepgram's documentation, using your API key from dg login or " + "DEEPGRAM_API_KEY" ) - short_help = "Serve Deepgram docs search over MCP" + short_help = "Run an MCP server that searches Deepgram's docs" # No credential check at startup: the server answers the handshake with # or without a key, and a missing or refused key is a tool error on the @@ -77,9 +78,13 @@ class McpCommand(BaseCommand): "dg mcp --base-url https://api.dx.deepgram.com", ] agent_help = ( - "Run an MCP (Model Context Protocol) server that exposes Deepgram " - "documentation search to your AI editor. Configure " - "in your AI editor's MCP settings. It runs over stdio." + "Run a local MCP (Model Context Protocol) server over stdio for AI " + "editors. It has one tool, search_deepgram_knowledge_sources, which " + "searches Deepgram's documentation through Deepgram's hosted search " + "and returns passages with their source URLs. Register it with " + 'command "dg" and args ["mcp"]. The API key comes from dg login or ' + "DEEPGRAM_API_KEY; don't put it in the MCP config's args. Without a " + "key the server still starts and each search returns a tool error." ) def __init__(self) -> None: @@ -119,7 +124,12 @@ def get_arguments(self) -> list[dict[str, Any]]: }, { "names": ["--base-url"], - "help": "Base URL for Deepgram developer API", + "help": ( + "Base URL of Deepgram's documentation search service. It " + "must use https unless the host is localhost, and any host " + "other than Deepgram's must be in api.allowed_hosts in your " + "user config" + ), "type": str, "default": DEFAULT_BASE_URL, "required": False, @@ -127,7 +137,7 @@ def get_arguments(self) -> list[dict[str, Any]]: }, { "names": ["--debug"], - "help": "Enable debug logging", + "help": "Log each documentation search request to stderr", "is_flag": True, "is_option": True, }, @@ -153,7 +163,7 @@ def _handle_shutdown(self, signum: int, frame: Any) -> None: """Handle shutdown signals gracefully.""" if not self._shutdown_requested: self._shutdown_requested = True - console.print("\n[yellow]MCP server stopped by user[/yellow]") + console.print("\n[yellow]MCP server stopped.[/yellow]") os._exit(0) def handle( @@ -220,7 +230,7 @@ def handle( except KeyboardInterrupt: return MCPServerResult( status="cancelled", - message="MCP server stopped by user", + message="MCP server stopped.", transport=TransportType.STDIO, ) except Exception as e: diff --git a/src/deepctl/_manifest.d/mcp.json b/src/deepctl/_manifest.d/mcp.json index 41c8559c..abb632b7 100644 --- a/src/deepctl/_manifest.d/mcp.json +++ b/src/deepctl/_manifest.d/mcp.json @@ -3,13 +3,13 @@ "module": "deepctl_cmd_mcp.command", "attr": "McpCommand", "dist": "deepctl-cmd-mcp", - "summary": "Run MCP proxy for Deepgram AI", + "summary": "Run an MCP server that searches Deepgram's docs", "hidden": false, "requires_auth": false, "help": { "name": "mcp", - "help": "Run an MCP proxy that connects to Deepgram's developer API and exposes remote tools locally for AI editors\n\nExamples:\n dg mcp\n dg mcp --base-url https://api.dx.deepgram.com\n\n\nFor agents, use --agent-friendly.", - "short_help": "Run MCP proxy for Deepgram AI", + "help": "Run a local MCP server over stdio that lets AI editors search Deepgram's documentation, using your API key from dg login or DEEPGRAM_API_KEY\n\nExamples:\n dg mcp\n dg mcp --base-url https://api.dx.deepgram.com\n\n\nFor agents, use --agent-friendly.", + "short_help": "Run an MCP server that searches Deepgram's docs", "epilog": null, "hidden": false, "deprecated": false, @@ -35,7 +35,7 @@ "is_flag": true, "count": false, "hidden": false, - "help": "Enable debug logging" + "help": "Log each documentation search request to stderr" }, { "class": "option", @@ -56,7 +56,7 @@ "is_flag": false, "count": false, "hidden": false, - "help": "Base URL for Deepgram developer API" + "help": "Base URL of Deepgram's documentation search service. It must use https unless the host is localhost, and any host other than Deepgram's must be in api.allowed_hosts in your user config" }, { "class": "option", diff --git a/tests/unit/test_mcp_docs_lint.py b/tests/unit/test_mcp_docs_lint.py new file mode 100644 index 00000000..6a193537 --- /dev/null +++ b/tests/unit/test_mcp_docs_lint.py @@ -0,0 +1,197 @@ +"""Shipped text must not promise `dg mcp` behaviour it doesn't have. + +Agents follow docs literally, so this scans the READMEs, the website +(including llms.txt and llms-full.txt), the sources and the help specs +for text that was true of an older plan, not of the shipped command: + +- transports other than stdio, in any spelling (`--transport sse`, + `--transport=http`, `-t sse`, `"--transport", "sse"`, an `/sse` URL + over http or ws); +- a `--stdout` option `dg mcp` never had; +- an API key on a command line (any value after `--api-key` in a dg + command) or in an MCP config's args, including args arrays split over + several lines. + +Only the API-key checks have an exemption: `dg login --api-key ...` gives +the key to the command whose job is to store it. The exemption ends at a +shell operator, a backtick or the end of a sentence. Every other check +still applies to that line. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] + +_TRANSPORTS = r"(?:sse|http|https|streamable[-_]?http|ws|websocket)\b" +LINE_CHECKS = { + "non-stdio transport": re.compile( + r"(?:--transport(?:\s+|=)|(?)]*/sse(?![\w-])"), + "--stdout": re.compile(r"--stdout\b"), + "stdio/SSE/HTTP": re.compile(r"stdio\s*/\s*SSE", re.IGNORECASE), +} +# A key value after --api-key on a command line: a placeholder, a variable +# or something key-shaped. +KEY_CHECKS = { + "--api-key with a value": re.compile( + r"--api-key(?:\s+|=)[\"']?(?:YOUR|KEY\b|<|\$|sk-|[0-9a-f]{32})", + re.IGNORECASE, + ), + # Any value after --api-key in a dg command: dg, then options and + # arguments up to --api-key, with no shell operator or backtick between. + "--api-key on a command line": re.compile( + r"(?:^|[\s`$(])(?:dg|deepctl|deepgram)(?:\s+[^\s|;&`]+)*?\s+" + r"--api-key(?:\s+|=)[\"']?[\w$<{]" + ), +} +# The dg login command the key is given to: it ends at a shell operator, a +# backtick or the end of a sentence. +LOGIN = re.compile(r"\bdg\s+login\b(?:(?![.!?](?:\s|$))[^|;&`\n])*$") +# An MCP config's args array, however it is wrapped. +ARGS = re.compile(r"[\"']args[\"']\s*:\s*\[([^\]]*)\]", re.DOTALL) +ARGS_CHECKS = { + "--api-key in MCP args": re.compile(r"[\"']--api-key(?:=[^\"']*)?[\"']"), + "non-stdio transport in MCP args": re.compile( + r"[\"'](?:--transport|-t)[\"']\s*,\s*[\"']" + _TRANSPORTS + + r"|[\"']--transport=" + _TRANSPORTS, + re.IGNORECASE, + ), +} + +SKIP_DIRS = {"node_modules", "dist", ".astro", "__pycache__", "tests"} +TEXT_SUFFIXES = {".md", ".txt", ".py", ".astro", ".ts", ".tsx", ".js", ".json"} + + +def _files() -> list[Path]: + roots = [REPO / "web" / "public", REPO / "web" / "src", REPO / "src"] + files = sorted(REPO.glob("README*")) + sorted(REPO.glob("packages/*/README*")) + roots += sorted(p for p in REPO.glob("packages/*/src") if p.is_dir()) + for root in roots: + if not root.is_dir(): + continue + for path in sorted(root.rglob("*")): + if ( + path.is_file() + and path.suffix in TEXT_SUFFIXES + and not SKIP_DIRS.intersection(path.relative_to(REPO).parts) + ): + files.append(path) + return files + + +def check_text(text: str) -> list[tuple[int, str]]: + """(line number, check name) for every untrue MCP claim in ``text``.""" + hits = [] + for number, line in enumerate(text.splitlines(), 1): + for label, pattern in LINE_CHECKS.items(): + if pattern.search(line): + hits.append((number, label)) + for label, pattern in KEY_CHECKS.items(): + for match in pattern.finditer(line): + if not LOGIN.search(line[: line.index("--api-key", match.start())]): + hits.append((number, label)) + break + for match in ARGS.finditer(text): + number = text.count("\n", 0, match.start()) + 1 + for label, pattern in ARGS_CHECKS.items(): + if pattern.search(match.group(1)): + hits.append((number, label)) + return hits + + +def _hits() -> list[str]: + hits = [] + for path in _files(): + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + lines = text.splitlines() + rel = path.relative_to(REPO) + for number, label in check_text(text): + hits.append(f"{rel}:{number}: {label}: {lines[number - 1].strip()}") + return hits + + +def test_scans_the_shipped_text() -> None: + names = {p.relative_to(REPO).as_posix() for p in _files()} + assert "README.md" in names + assert "packages/deepctl-cmd-mcp/README.md" in names + assert "packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py" in names + if (REPO / "web" / "public" / "llms-full.txt").exists(): + assert "web/public/llms-full.txt" in names + + +def test_no_untrue_mcp_text() -> None: + assert _hits() == [] + + +@pytest.mark.parametrize( + "text", + [ + "dg mcp --transport sse --port 8000", + "dg mcp --transport=sse", + "dg mcp -t sse", + "dg mcp --transport http", + "dg mcp --transport streamable-http", + "dg mcp --transport 'sse'", + '"args": ["mcp", "--transport", "sse"]', + '"args": ["mcp", "-t", "http"]', + '"args": ["mcp", "--transport=sse"]', + '"args": ["deepctl", "--api-key", "YOUR_KEY", "mcp"]', + '"args": [\n "deepctl",\n "--api-key",\n "abc",\n "mcp"\n]', + '"args": ["deepctl", "--api-key=abc", "mcp"]', + "dg --api-key=KEY mcp", + "dg --api-key YOUR_KEY mcp", + 'dg --api-key "$DEEPGRAM_API_KEY" mcp', + "Add to your AI editor: http://127.0.0.1:8000/sse", + "dg mcp --stdout", + "MCP server mode: `dg mcp` (stdio/SSE/HTTP)", + "dg login --api-key KEY && dg mcp --transport sse", + "dg login; dg --api-key=KEY mcp", + "ws://127.0.0.1:8000/sse", + "wss://example.com/mcp/sse", + "dg --api-key abc123 mcp", + "deepctl --profile x --api-key abc123 mcp", + "Run `dg login`, then `dg --api-key abc123 mcp`", + "Run dg login. Then run dg --api-key abc123 mcp", + ], +) +def test_catches_the_old_text(text: str) -> None: + assert check_text(text), text + + +@pytest.mark.parametrize( + "text", + [ + "dg mcp", + "dg mcp --transport stdio", + '"args": ["mcp", "--transport", "stdio"]', + '"args": ["deepctl", "mcp"]', + "a `--transport` other than `stdio` exits with an error", + "Use stdio: remove --transport from your MCP config", + "dg login --api-key YOUR_API_KEY", + "dg login --profile staging --api-key YOUR_KEY", + '_SECRET_OPTIONS = ("--api-key", "-k")', + 'if token.startswith("--api-key="):', + "https://developers.deepgram.com/docs/sse-handling", + "from mcp.client.streamable_http import streamablehttp_client", + "dg login --api-key abc123", + "Pass the key with dg's --api-key flag, or run dg login.", + "ws://127.0.0.1:8000/v1/listen", + ], +) +def test_leaves_true_text_alone(text: str) -> None: + assert check_text(text) == [], text diff --git a/web/public/llms-full.txt b/web/public/llms-full.txt index 4f599a3a..77eec0f7 100644 --- a/web/public/llms-full.txt +++ b/web/public/llms-full.txt @@ -239,10 +239,21 @@ dg api /v1/projects/PROJECT_ID/usage/requests | jq '.requests[].path' ## MCP Server (AI Editor Integration) ```bash -dg mcp # Start MCP server (stdio transport) +dg mcp # Start MCP server (stdio only) ``` Used with Claude Code, Cursor, VS Code Copilot, and other MCP-compatible editors. +The editor starts `dg mcp` (command `dg`, args `["mcp"]`) and talks to it over +stdin and stdout; SSE and HTTP transports aren't supported. The server has one +tool, `search_deepgram_knowledge_sources`, which searches Deepgram's +documentation and returns passages with their source URLs. It doesn't call +other Deepgram APIs. + +The API key comes from `dg login` or `DEEPGRAM_API_KEY`. Don't put the API key +in the MCP config's `args`. Without a key the server still starts, and each +search returns a tool error (`isError: true`) that says to run `dg login` or set +`DEEPGRAM_API_KEY`; if the error says a person must fix the config file, an AI +agent should ask the user rather than act on it. --- diff --git a/web/public/llms.txt b/web/public/llms.txt index 414b3b7a..0fdd430c 100644 --- a/web/public/llms.txt +++ b/web/public/llms.txt @@ -37,4 +37,4 @@ - Plugin system: `dg plugin install ` installs community extensions in isolated venv (refused in an AI agent session: a person runs it) - `--dry-run` on all destructive operations (key deletion, member removal) - Shell completion for bash, zsh, and fish -- MCP server mode: `dg mcp` (stdio) gives AI editors Deepgram documentation search +- MCP server mode: `dg mcp` (stdio only) lets AI editors search Deepgram's documentation diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 2c5d420e..a295cd67 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -168,7 +168,7 @@ const commandCards: CommandCard[] = [ // ── MCP & agents ──────────────────────────────────────────────────────── { cmd: 'mcp', icon: 'bolt', color: 'brand', title: 'Run as MCP server', - desc: 'Give Claude, Cursor and other MCP clients Deepgram documentation search.', + desc: 'Let Claude, Cursor, and other MCP clients search Deepgram’s docs.', lines: ['dg mcp'] }, { cmd: 'skills', icon: 'robot', color: 'brand', title: 'Regenerate AI skill files', @@ -1064,8 +1064,7 @@ const commandCards: CommandCard[] = [ // ── MCP server for AI editors ───────────────── [ { p: '❯ ', t: 'claude mcp add deepgram -- dg mcp', c: '' }, - { p: '', t: 'Added stdio MCP server deepgram with command: dg mcp', c: '#13ef95' }, - { p: '', t: ' Your editor starts dg mcp over stdio', c: '#949498' }, + { p: '', t: '# stdio server: Claude Code can now search Deepgram’s docs', c: '#949498' }, { p: '', t: '', c: '' }, ], ]; From 4e39fadc8f48747d87c3ea479a5d4fd80eaed709 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 1 Nov 2026 13:24:55 -0500 Subject: [PATCH 04/25] feat(core): detect AI agents from one documented table deepctl_core.agents is now the only place dg decides whether an AI agent, automation or a person is running it. Each hard signal names the public source that documents or implements it, and the table grows from 9 variables to 21 rows: AI_AGENT first, then AGENT (amp, goose), GOOSE_TERMINAL, the Claude Code, Codex, Gemini, Copilot CLI, OpenCode, Antigravity, Augment, Cursor and Aider variables, the /opt/.devin file and a .pi/agent entry in PATH. A variable counts when it is set and not empty; whitespace still counts, as before. DG_AGENT=1 turns agent mode on and DG_AGENT=0 turns it off for output, prompts, hints and login. Trust decisions keep reading the raw markers, so DG_AGENT never loosens or tightens which config file and key dg uses. Every new marker therefore also applies the agent-session config and base URL rules. NO_COLOR no longer counts toward the no-terminal score. Under NO_COLOR, and in agent mode, the consoles write plain text with no colour, bold or dim; otherwise they never pass no_color=False, so FORCE_COLOR and TERM work as rich reads them. CI matches case-insensitively, and the command-line flags count only before "--". output.is_agentic(), output.is_ai_agent() and AI_AGENT_ENV_MARKERS stay importable and keep their meaning. output gains invoked_prog() and stderr_color() for messages that name the program or colour raw stderr lines. --- conftest.py | 8 +- docs/contracts/help-cache-key.schema.json | 8 +- docs/contracts/help-cache.md | 17 +- .../src/deepctl_cmd_listen/command.py | 16 +- .../tests/unit/test_listen_body_checks.py | 2 +- .../tests/unit/test_listen_output.py | 4 +- .../tests/unit/test_listen_passthrough.py | 8 +- .../src/deepctl_cmd_login/command.py | 12 +- .../tests/unit/test_login_device_agent.py | 20 + .../src/deepctl_cmd_speak/command.py | 6 +- .../tests/unit/test_speak_agent.py | 8 +- .../tests/unit/test_speak_output.py | 14 +- .../deepctl-core/src/deepctl_core/__init__.py | 3 + .../deepctl-core/src/deepctl_core/agents.py | 371 ++++++++++++++++++ .../deepctl-core/src/deepctl_core/output.py | 153 ++++---- .../src/deepctl_core/transport.py | 4 +- .../agent_env/claude-code-in-vscode.json | 45 +++ .../tests/unit/fixtures/agent_env/cursor.json | 34 ++ .../fixtures/agent_env/github-actions.json | 31 ++ .../unit/fixtures/agent_env/jetbrains.json | 20 + .../tests/unit/fixtures/agent_env/vscode.json | 35 ++ .../tests/unit/fixtures/agent_env/warp.json | 22 ++ .../tests/unit/fixtures/agent_env/zed.json | 20 + .../tests/unit/test_agent_trust.py | 176 +++++++++ .../deepctl-core/tests/unit/test_agents.py | 340 ++++++++++++++++ .../deepctl-core/tests/unit/test_output.py | 70 +++- .../src/deepctl_shared_utils/diagnostics.py | 42 +- src/deepctl/_cache.py | 75 ++-- src/deepctl/_cli.py | 2 +- tests/unit/test_cache.py | 120 +++++- 30 files changed, 1477 insertions(+), 209 deletions(-) create mode 100644 packages/deepctl-core/src/deepctl_core/agents.py create mode 100644 packages/deepctl-core/tests/unit/fixtures/agent_env/claude-code-in-vscode.json create mode 100644 packages/deepctl-core/tests/unit/fixtures/agent_env/cursor.json create mode 100644 packages/deepctl-core/tests/unit/fixtures/agent_env/github-actions.json create mode 100644 packages/deepctl-core/tests/unit/fixtures/agent_env/jetbrains.json create mode 100644 packages/deepctl-core/tests/unit/fixtures/agent_env/vscode.json create mode 100644 packages/deepctl-core/tests/unit/fixtures/agent_env/warp.json create mode 100644 packages/deepctl-core/tests/unit/fixtures/agent_env/zed.json create mode 100644 packages/deepctl-core/tests/unit/test_agent_trust.py create mode 100644 packages/deepctl-core/tests/unit/test_agents.py diff --git a/conftest.py b/conftest.py index 3a8e7bf5..02c51581 100644 --- a/conftest.py +++ b/conftest.py @@ -49,22 +49,24 @@ 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) + monkeypatch.delenv("DG_AGENT", 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.""" + Tests that need agent mode patch ``agent_mode`` 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"): + for name in (*AI_AGENT_ENV_MARKERS, "OR_APP_NAME", "OR_SITE_URL", "DG_AGENT"): monkeypatch.delenv(name, raising=False) diff --git a/docs/contracts/help-cache-key.schema.json b/docs/contracts/help-cache-key.schema.json index bbab63bb..2171a6dc 100644 --- a/docs/contracts/help-cache-key.schema.json +++ b/docs/contracts/help-cache-key.schema.json @@ -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} } }, { @@ -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} } } ] diff --git a/docs/contracts/help-cache.md b/docs/contracts/help-cache.md index c2f67611..3c967bf0 100644 --- a/docs/contracts/help-cache.md +++ b/docs/contracts/help-cache.md @@ -26,7 +26,7 @@ An entry is named `---.txt`. - **``:** the deepctl version, for example `0.5.0`. - **``:** 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`). @@ -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`). @@ -107,4 +110,4 @@ Writes are atomic. The data goes to a `mkstemp` file (`..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. diff --git a/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py b/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py index 9f683041..af2f5576 100644 --- a/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py +++ b/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py @@ -34,9 +34,9 @@ from deepctl_core.hosts import CredentialHostError from deepctl_core.output import ( _agentic, + agent_mode, format_source, get_output_format, - is_ai_agent, print_error, print_hint, print_info, @@ -547,7 +547,7 @@ def pre_auth_check(self, config: Config, **kwargs: Any) -> Any | None: """ if kwargs.get("source") is not None or kwargs.get("mic"): return None - if not is_ai_agent(): + if not agent_mode(): return None prog = _prog() text = msg.E_LISTEN_NO_SOURCE.format(prog=prog) @@ -1606,7 +1606,7 @@ def _coded_failure( next_step: str | None = None, ) -> ListenCodedError: """Report a live-stream failure on stderr and return its envelope.""" - hint = hint_agent if (_agentic or is_ai_agent()) else hint_human + hint = hint_agent if (_agentic or agent_mode()) else hint_human print_error(escape(text)) print_hint(escape(hint)) return ListenCodedError( @@ -2038,16 +2038,16 @@ def _default(obj: Any) -> str: self._print_prerecorded_result(result) def _may_compact(self) -> bool: - """Compaction's conditions other than size: a hard AI - agent signal, a format chosen by detection rather than asked for, - no --full-output, and stdout neither a regular file nor the null - device.""" + """Compaction's conditions other than size: agent mode (a hard AI + agent signal, or DG_AGENT=1), a format chosen by detection rather + than asked for, no --full-output, and stdout neither a regular file + nor the null device.""" from deepctl_core.result_store import stdout_is_file_or_null return ( get_output_format() == "json" and format_source() not in ("explicit", "default") - and is_ai_agent() + and agent_mode() and not self._full_output and not stdout_is_file_or_null() ) diff --git a/packages/deepctl-cmd-listen/tests/unit/test_listen_body_checks.py b/packages/deepctl-cmd-listen/tests/unit/test_listen_body_checks.py index 9cb67e1e..e302132b 100644 --- a/packages/deepctl-cmd-listen/tests/unit/test_listen_body_checks.py +++ b/packages/deepctl-cmd-listen/tests/unit/test_listen_body_checks.py @@ -42,7 +42,7 @@ def _env(monkeypatch, tmp_path): monkeypatch.setitem(output._output_config, "quiet", False) monkeypatch.setitem(output._output_config, "format", "json") monkeypatch.setitem(output._output_config, "format_source", "explicit") - monkeypatch.setattr(listen_cmd, "is_ai_agent", lambda: False) + monkeypatch.setattr(listen_cmd, "agent_mode", lambda: False) monkeypatch.setattr(transport, "prog", lambda: "dg") monkeypatch.setattr(listen_cmd, "_prog", lambda: "dg") monkeypatch.setattr(listen_cmd, "_listen_json_warned", False) diff --git a/packages/deepctl-cmd-listen/tests/unit/test_listen_output.py b/packages/deepctl-cmd-listen/tests/unit/test_listen_output.py index 727a8af6..c2756f79 100644 --- a/packages/deepctl-cmd-listen/tests/unit/test_listen_output.py +++ b/packages/deepctl-cmd-listen/tests/unit/test_listen_output.py @@ -17,7 +17,7 @@ @pytest.fixture def command(monkeypatch): - monkeypatch.setattr(listen_cmd, "is_ai_agent", lambda: True) + monkeypatch.setattr(listen_cmd, "agent_mode", lambda: True) return ListenCommand() @@ -67,7 +67,7 @@ def test_output_outcome_is_output_unwritable_with_the_kept_finals(command, capsy def test_person_gets_the_human_hint(command, monkeypatch): - monkeypatch.setattr(listen_cmd, "is_ai_agent", lambda: False) + monkeypatch.setattr(listen_cmd, "agent_mode", lambda: False) monkeypatch.setattr(listen_cmd, "_agentic", False) outcome = live.LiveOutcome( status="error", kind="output", os_error="Input/output error" diff --git a/packages/deepctl-cmd-listen/tests/unit/test_listen_passthrough.py b/packages/deepctl-cmd-listen/tests/unit/test_listen_passthrough.py index d407d735..39ce1837 100644 --- a/packages/deepctl-cmd-listen/tests/unit/test_listen_passthrough.py +++ b/packages/deepctl-cmd-listen/tests/unit/test_listen_passthrough.py @@ -48,7 +48,7 @@ def _env(monkeypatch, tmp_path): monkeypatch.setitem(output._output_config, "quiet", False) monkeypatch.setitem(output._output_config, "format", "json") monkeypatch.setitem(output._output_config, "format_source", "explicit") - monkeypatch.setattr(listen_cmd, "is_ai_agent", lambda: False) + monkeypatch.setattr(listen_cmd, "agent_mode", lambda: False) monkeypatch.setattr(transport, "prog", lambda: "dg") monkeypatch.setattr(listen_cmd, "_prog", lambda: "dg") monkeypatch.setattr(listen_cmd, "_listen_json_warned", False) @@ -225,7 +225,7 @@ def test_a_body_that_is_not_an_object_is_unreadable(monkeypatch): def agent(monkeypatch) -> None: fmt(monkeypatch, "json", "hard_agent") - monkeypatch.setattr(listen_cmd, "is_ai_agent", lambda: True) + monkeypatch.setattr(listen_cmd, "agent_mode", lambda: True) def test_large_result_under_an_agent_is_compacted(monkeypatch, capsysbinary, tmp_path): @@ -294,7 +294,7 @@ def test_compaction_boundaries(monkeypatch, capsysbinary, case): fmt(monkeypatch, "json", "explicit") elif case == "soft": fmt(monkeypatch, "json", "soft") - monkeypatch.setattr(listen_cmd, "is_ai_agent", lambda: False) + monkeypatch.setattr(listen_cmd, "agent_mode", lambda: False) elif case == "full_output": full_output = True elif case == "regular_file": @@ -302,7 +302,7 @@ def test_compaction_boundaries(monkeypatch, capsysbinary, case): "deepctl_core.result_store.stdout_is_file_or_null", lambda: True ) elif case == "no_agent": - monkeypatch.setattr(listen_cmd, "is_ai_agent", lambda: False) + monkeypatch.setattr(listen_cmd, "agent_mode", lambda: False) elif case == "text": fmt(monkeypatch, "text", "explicit") run(rest_client(body), full_output=full_output) diff --git a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py index 24d26abd..c05eead3 100644 --- a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py +++ b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py @@ -160,8 +160,14 @@ def _rerun_with_yes(prog: str | None) -> str | None: def _hard_signal() -> bool: - """An AI agent marked the environment, or ``DG_AGENT=1`` says one runs dg.""" - return is_ai_agent() or os.environ.get("DG_AGENT") == "1" + """Hard agent mode: an AI agent marked the environment, or ``DG_AGENT=1`` + says one runs dg (``DG_AGENT=0`` turns it off).""" + from deepctl_core.agents import override + + forced = override() + if forced is not None: + return forced == "1" + return is_ai_agent() def _hint_for( @@ -1044,7 +1050,7 @@ def _start_pending( self, config: Config, auth_manager: AuthManager, identity_url: str ) -> Any: """Get a device code, save it for --complete, and return the link and - code at once (exit 0; OQ-16).""" + code at once (exit 0).""" profile_name = config.profile or "default" try: device = auth_manager._request_device_code(identity_url) diff --git a/packages/deepctl-cmd-login/tests/unit/test_login_device_agent.py b/packages/deepctl-cmd-login/tests/unit/test_login_device_agent.py index df0f3341..b5d1eca4 100644 --- a/packages/deepctl-cmd-login/tests/unit/test_login_device_agent.py +++ b/packages/deepctl-cmd-login/tests/unit/test_login_device_agent.py @@ -209,6 +209,26 @@ def test_pending_without_an_agent_marker(env, agent, monkeypatch, why): assert isinstance(result, LoginPendingResult) +def test_dg_agent_0_waits_like_a_person(env, agent, monkeypatch): + """DG_AGENT=0 restores the interactive login for a person whose shell + carries an agent marker; trust still reads the marker.""" + agent(True) + monkeypatch.setenv("DG_AGENT", "0") + output._output_config["agentic"] = False # as the process computes it + output._output_config["format"] = "default" + monkeypatch.setattr(sys.stdin, "isatty", lambda: True, raising=False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True, raising=False) + calls = [] + monkeypatch.setattr( + AuthManager, + "login_with_device_flow", + lambda self, open_browser=True: calls.append(open_browser), + ) + run(env) + assert calls == [True] + assert not pending_path(env).exists() + + def test_person_at_a_terminal_still_waits(env, agent, monkeypatch): agent(False) output._output_config["format"] = "default" diff --git a/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py b/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py index cc547559..caeb1692 100644 --- a/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py +++ b/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py @@ -39,8 +39,8 @@ ) from deepctl_core.output import ( _agentic, + agent_mode, format_source, - is_ai_agent, print_error, print_hint, print_warning, @@ -334,7 +334,7 @@ def _coded( details: dict[str, Any] | None = None, ) -> SpeakCodedError: """Report a coded failure on stderr (two lines) and return its envelope.""" - hint = hint_agent if (_agentic or is_ai_agent()) else hint_human + hint = hint_agent if (_agentic or agent_mode()) else hint_human print_error(escape(text)) print_hint(escape(hint)) return SpeakCodedError( @@ -1042,7 +1042,7 @@ def _preflight(self, kwargs: dict[str, Any]) -> _Plan | SpeakCodedError: hint_agent=msg.H_SPEAK_O_FORMAT.format(prog=prog, value=legacy), next_step=self._o_format_next_step(prog, legacy, kwargs), ) - agent = is_ai_agent() + agent = agent_mode() text = kwargs.get("text") if agent and not text and not kwargs.get("file"): # Never block on a stdin that may never close; "-" opts in. diff --git a/packages/deepctl-cmd-speak/tests/unit/test_speak_agent.py b/packages/deepctl-cmd-speak/tests/unit/test_speak_agent.py index ea1e7eb8..07c41ae7 100644 --- a/packages/deepctl-cmd-speak/tests/unit/test_speak_agent.py +++ b/packages/deepctl-cmd-speak/tests/unit/test_speak_agent.py @@ -64,7 +64,7 @@ def _env(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) monkeypatch.setattr(speak, "_deprecation_shown", False) monkeypatch.setattr(speak, "_agentic", True) - monkeypatch.setattr(speak, "is_ai_agent", lambda: True) + monkeypatch.setattr(speak, "agent_mode", lambda: True) monkeypatch.setattr(sinks, "stdout_stat", lambda: None) # a pipe monkeypatch.setattr(sinks, "stdout_takes_audio", lambda: False) @@ -110,7 +110,7 @@ def test_dash_reads_stdin_in_agent_mode(self, monkeypatch): assert client.speak_text.call_args.kwargs["text"] == "from stdin" def test_dash_reads_stdin_for_a_person_too(self, monkeypatch): - monkeypatch.setattr(speak, "is_ai_agent", lambda: False) + monkeypatch.setattr(speak, "agent_mode", lambda: False) cmd = SpeakCommand() client = Mock(spec=DeepgramClient) client.speak_text.return_value = iter([b"a"]) @@ -124,7 +124,7 @@ def test_dash_reads_stdin_for_a_person_too(self, monkeypatch): assert client.speak_text.call_args.kwargs["text"] == "typed" def test_ci_only_keeps_reading_piped_stdin(self, monkeypatch): - monkeypatch.setattr(speak, "is_ai_agent", lambda: False) + monkeypatch.setattr(speak, "agent_mode", lambda: False) assert _pre(["speak"], monkeypatch, text=None) is None @@ -226,7 +226,7 @@ def test_regular_file_tty_or_dev_null_is_allowed(self, monkeypatch): assert _pre(["speak", "hi"], monkeypatch) is None def test_people_and_ci_may_pipe_audio(self, monkeypatch): - monkeypatch.setattr(speak, "is_ai_agent", lambda: False) + monkeypatch.setattr(speak, "agent_mode", lambda: False) assert _pre(["speak", "hi"], monkeypatch) is None diff --git a/packages/deepctl-cmd-speak/tests/unit/test_speak_output.py b/packages/deepctl-cmd-speak/tests/unit/test_speak_output.py index 53e85044..d2af3e66 100644 --- a/packages/deepctl-cmd-speak/tests/unit/test_speak_output.py +++ b/packages/deepctl-cmd-speak/tests/unit/test_speak_output.py @@ -66,7 +66,7 @@ def _env(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) monkeypatch.setattr(speak, "_deprecation_shown", False) monkeypatch.setattr(speak, "_agentic", False) - monkeypatch.setattr(speak, "is_ai_agent", lambda: False) + monkeypatch.setattr(speak, "agent_mode", lambda: False) # stdout is "a pipe" unless a test says otherwise. monkeypatch.setattr(sinks, "stdout_stat", lambda: None) # The signal guard re-delivers a signal after _CLOSE_TIMEOUT_S if the @@ -749,7 +749,7 @@ def test_a_stalled_unwind_is_cut_short_by_the_second_signal( assert died == [signal.SIGTERM] def test_second_ctrl_c_cleans_up_and_cancels_like_the_first(self, monkeypatch): - """N-3: the second Ctrl-C (or the timer) unwinds by KeyboardInterrupt + """The second Ctrl-C (or the timer) unwinds by KeyboardInterrupt again, so the usual cancel path, exit 2 and exit handlers follow; the hard-exit backstop is cancelled with the guard.""" import signal @@ -859,7 +859,7 @@ def test_replace_drops_setuid_setgid_and_sticky(self, tmp_path): def test_a_signal_inside_the_rename_leaves_no_temp_file( self, tmp_path, monkeypatch ): - """N-1 (the reviewer's repro): os.replace interrupted; abort still + """os.replace interrupted; abort still removes the temp file.""" path = tmp_path / "a.wav" path.write_bytes(b"old") @@ -1070,7 +1070,7 @@ def h(signum, frame): signal.signal(signal.SIGHUP, before) def test_most_severe_deferred_signal_wins(self): - """N-1: TERM then INT during a section ends by TERM.""" + """TERM then INT during a section ends by TERM.""" import signal seen: list[int] = [] @@ -1095,7 +1095,7 @@ def h(signum, frame): def test_a_signal_during_the_restore_reaches_the_restored_handler( self, monkeypatch, signame ): - """S-2: closed guard, original handlers back, no live timer, no + """Closed guard, original handlers back, no live timer, no cleanup; the signal goes to the handler that was there before.""" import signal @@ -1145,7 +1145,7 @@ def settled() -> bool: not hasattr(__import__("signal"), "pthread_sigmask"), reason="POSIX" ) def test_mask_is_restored_under_a_signal_flood(self): - """S-3: the mask is always restored, even when a pending handler + """The mask is always restored, even when a pending handler raises inside the section's own setup. Run in a child process: the flood can land on any bytecode.""" import subprocess @@ -1197,7 +1197,7 @@ def flood(): assert "raises=0" not in p.stdout def test_abort_outside_the_guard_still_removes_the_temp_file(self, tmp_path): - """N-2: a Ctrl-C taken by another thread while abort() runs with no + """A Ctrl-C taken by another thread while abort() runs with no guard installed is held until the removal is done.""" import signal diff --git a/packages/deepctl-core/src/deepctl_core/__init__.py b/packages/deepctl-core/src/deepctl_core/__init__.py index 7ac04113..d8b66b9f 100644 --- a/packages/deepctl-core/src/deepctl_core/__init__.py +++ b/packages/deepctl-core/src/deepctl_core/__init__.py @@ -31,6 +31,7 @@ ) from .output import ( OutputFormatter, + agent_mode, get_console, get_output_format, get_status_console, @@ -71,6 +72,7 @@ "ProfileInfo": "models", "ProfilesResult": "models", "TimingContext": "timing", + "agent_mode": "output", "enable_timing": "timing", "get_console": "output", "get_output_format": "output", @@ -130,6 +132,7 @@ "ProfileInfo", "ProfilesResult", "TimingContext", + "agent_mode", "enable_timing", "get_console", "get_output_format", diff --git a/packages/deepctl-core/src/deepctl_core/agents.py b/packages/deepctl-core/src/deepctl_core/agents.py new file mode 100644 index 00000000..570b106b --- /dev/null +++ b/packages/deepctl-core/src/deepctl_core/agents.py @@ -0,0 +1,371 @@ +"""Agent detection: whether an AI agent, automation or a person runs dg. + +One table decides it, and ``dg help agent-mode`` prints the same table. +Every hard signal names the public source that documents or implements it; +a variable without one is never added. + +Evaluation order: + +1. ``DG_AGENT=1`` turns agent mode on and ``DG_AGENT=0`` turns it off, for + output, prompts, notices and login. Other values are ignored. +2. The hard signals in ``HARD_SIGNALS``, top to bottom. The first match + names the agent. +3. The soft signals (``CI``, ``--non-interactive``/``--agent-friendly``, or + no terminal): automation is likely. They change presentation and prompts + only. + +Trust decisions (which config file and API key to use, where the key may be +sent, whether to save) read the raw hard signals through +:func:`is_ai_agent`. ``DG_AGENT`` never changes them, in either direction. + +Stdlib only, and nothing is cached: tests and callers that change the +environment see the change on the next call. +""" + +from __future__ import annotations + +import os +import re +import sys +from typing import TYPE_CHECKING, NamedTuple + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + +OVERRIDE_VAR = "DG_AGENT" + +# Public sources for the table, read 2026-10-31 and again 2026-11-01. +SOURCES: dict[str, str] = { + "gh": "https://github.com/cli/cli/blob/trunk/internal/agents/detect.go", + "vercel": ( + "https://github.com/vercel/vercel/blob/main/packages/detect-agent/src/index.ts" + ), + "vscode": ( + "https://github.com/microsoft/vscode/blob/main/" + "src/vs/platform/chat/common/aiAgentEnv.ts" + ), + "goose": ( + "https://github.com/block/goose/blob/main/" + "documentation/docs/guides/environment-variables.md" + ), + "codex": "https://github.com/openai/codex/blob/main/codex-rs/core/src/spawn.rs", + "aider": "https://github.com/Aider-AI/aider/blob/main/aider/llm.py", + "claude-agent-sdk": ( + "https://github.com/anthropics/claude-agent-sdk-python/blob/main/" + "src/claude_agent_sdk/_internal/transport/subprocess_cli.py" + ), +} + +_AGENT_ID = re.compile(r"[A-Za-z0-9_-]{1,64}") +_AGENT_VALUES = ("amp", "goose") + +# A file some agent sandboxes create (POSIX only). A module attribute so +# tests can point it elsewhere. +DEVIN_MARKER = "/opt/.devin" +# The variable the pi row reads. +HARD_PATH_VAR = "PATH" + + +def _set(env: Mapping[str, str], name: str) -> bool: + """Present and not empty. Whitespace counts: a marker set to " " is + still a marker, so trust never loosens on an odd value.""" + return bool(env.get(name, "")) + + +def _ai_agent_id(env: Mapping[str, str]) -> str: + value = env.get("AI_AGENT", "") + return value if _AGENT_ID.fullmatch(value) else "unknown" + + +class Match(NamedTuple): + """A hard signal that matched.""" + + agent_id: str + # The variable that matched, or a description of the check (rows that + # are a file or a PATH entry). + reason: str + # True for the checks that aren't a variable someone set on purpose. + session: bool = False + + +class HardSignal(NamedTuple): + """One row of the detection table.""" + + name: str + condition: str + sources: tuple[str, ...] + match: Callable[[Mapping[str, str]], Match | None] + + +def _when_set(name: str, agent_id: str) -> Callable[[Mapping[str, str]], Match | None]: + def match(env: Mapping[str, str]) -> Match | None: + return Match(agent_id, name) if _set(env, name) else None + + return match + + +def _ai_agent(env: Mapping[str, str]) -> Match | None: + return Match(_ai_agent_id(env), "AI_AGENT") if _set(env, "AI_AGENT") else None + + +def _agent_value(env: Mapping[str, str]) -> Match | None: + value = env.get("AGENT", "") + return Match(value, "AGENT") if value in _AGENT_VALUES else None + + +def _cursor_role(env: Mapping[str, str]) -> Match | None: + if env.get("CURSOR_EXTENSION_HOST_ROLE", "") == "agent-exec": + return Match("cursor-cli", "CURSOR_EXTENSION_HOST_ROLE") + return None + + +def _aider(env: Mapping[str, str]) -> Match | None: + if env.get("OR_APP_NAME", "") == "Aider": + return Match("aider", "OR_APP_NAME") + if "aider" in env.get("OR_SITE_URL", ""): + return Match("aider", "OR_SITE_URL") + return None + + +def _devin(env: Mapping[str, str]) -> Match | None: + del env + if os.name != "posix": + return None + try: + found = os.path.lexists(DEVIN_MARKER) + except (OSError, ValueError): + return None + return Match("devin", DEVIN_MARKER, session=True) if found else None + + +def _pi(env: Mapping[str, str]) -> Match | None: + path = env.get(HARD_PATH_VAR, "") + if "/.pi/agent" in path or "\\.pi\\agent" in path: + return Match("pi", "a .pi/agent entry in PATH", session=True) + return None + + +# Order matters: Amp also sets CLAUDECODE, so AGENT comes first. +HARD_SIGNALS: tuple[HardSignal, ...] = ( + HardSignal("AI_AGENT", "set", ("gh", "vercel", "vscode"), _ai_agent), + HardSignal("AGENT", "amp or goose", ("gh", "goose"), _agent_value), + HardSignal( + "GOOSE_TERMINAL", "set", ("goose",), _when_set("GOOSE_TERMINAL", "goose") + ), + HardSignal( + "CLAUDECODE", "set", ("gh", "vercel"), _when_set("CLAUDECODE", "claude-code") + ), + HardSignal( + "CLAUDE_CODE", "set", ("gh", "vercel"), _when_set("CLAUDE_CODE", "claude-code") + ), + HardSignal( + "CLAUDE_CODE_IS_COWORK", + "set", + ("gh",), + _when_set("CLAUDE_CODE_IS_COWORK", "cowork"), + ), + HardSignal( + "CLAUDE_CODE_ENTRYPOINT", + "set", + ("claude-agent-sdk",), + _when_set("CLAUDE_CODE_ENTRYPOINT", "claude-code"), + ), + HardSignal( + "CODEX_SANDBOX", "set", ("gh", "vercel"), _when_set("CODEX_SANDBOX", "codex") + ), + HardSignal("CODEX_CI", "set", ("gh", "vercel"), _when_set("CODEX_CI", "codex")), + HardSignal( + "CODEX_THREAD_ID", + "set", + ("gh", "vercel"), + _when_set("CODEX_THREAD_ID", "codex"), + ), + HardSignal( + "CODEX_SANDBOX_NETWORK_DISABLED", + "set", + ("codex",), + _when_set("CODEX_SANDBOX_NETWORK_DISABLED", "codex"), + ), + HardSignal( + "GEMINI_CLI", "set", ("gh", "vercel"), _when_set("GEMINI_CLI", "gemini-cli") + ), + HardSignal("COPILOT_CLI", "set", ("gh",), _when_set("COPILOT_CLI", "copilot-cli")), + HardSignal("OPENCODE", "set", ("gh",), _when_set("OPENCODE", "opencode")), + HardSignal( + "ANTIGRAVITY_AGENT", + "set", + ("gh", "vercel"), + _when_set("ANTIGRAVITY_AGENT", "antigravity"), + ), + HardSignal( + "AUGMENT_AGENT", + "set", + ("gh", "vercel"), + _when_set("AUGMENT_AGENT", "augment-cli"), + ), + HardSignal( + "CURSOR_AGENT", "set", ("gh", "vercel"), _when_set("CURSOR_AGENT", "cursor-cli") + ), + HardSignal( + "CURSOR_EXTENSION_HOST_ROLE", "agent-exec", ("gh", "vercel"), _cursor_role + ), + HardSignal( + "OR_APP_NAME / OR_SITE_URL", + "Aider / aider", + ("aider",), + _aider, + ), + HardSignal(DEVIN_MARKER, "exists", ("vercel",), _devin), + HardSignal("PATH", "has .pi/agent", ("gh",), _pi), +) + +# Every environment variable a hard signal reads, except PATH. +HARD_ENV_VARS: tuple[str, ...] = ( + "AI_AGENT", + "AGENT", + "GOOSE_TERMINAL", + "CLAUDE_CODE_IS_COWORK", + "CLAUDECODE", + "CLAUDE_CODE", + "CLAUDE_CODE_ENTRYPOINT", + "CODEX_SANDBOX", + "CODEX_CI", + "CODEX_THREAD_ID", + "CODEX_SANDBOX_NETWORK_DISABLED", + "GEMINI_CLI", + "COPILOT_CLI", + "OPENCODE", + "ANTIGRAVITY_AGENT", + "AUGMENT_AGENT", + "CURSOR_AGENT", + "CURSOR_EXTENSION_HOST_ROLE", + "OR_APP_NAME", + "OR_SITE_URL", +) + + +def hard_inputs() -> tuple[tuple[str, ...], tuple[str, ...]]: + """Everything a hard signal reads, for a cache that must never be + served under a different agent state: every variable name (``PATH`` + included) and every file (the Devin marker, POSIX only).""" + files = (DEVIN_MARKER,) if os.name == "posix" else () + return (*HARD_ENV_VARS, HARD_PATH_VAR), files + + +def _environ(env: Mapping[str, str] | None) -> Mapping[str, str]: + return os.environ if env is None else env + + +def hard_match(env: Mapping[str, str] | None = None) -> Match | None: + """The first hard signal that matches, ignoring ``DG_AGENT``.""" + env = _environ(env) + for signal in HARD_SIGNALS: + found = signal.match(env) + if found is not None: + return found + return None + + +def is_ai_agent(env: Mapping[str, str] | None = None) -> bool: + """True when an AI agent marked the environment (a raw hard signal). + + For trust decisions. Never ``CI``, terminal state, command-line flags or + ``DG_AGENT``. + """ + return hard_match(env) is not None + + +def override(env: Mapping[str, str] | None = None) -> str | None: + """``"0"`` or ``"1"`` from ``DG_AGENT``; anything else is ``None``.""" + value = _environ(env).get(OVERRIDE_VAR, "").strip() + return value if value in ("0", "1") else None + + +def agent_mode(env: Mapping[str, str] | None = None) -> bool: + """Hard agent mode for output, prompts, notices and login. + + The raw hard signals, unless ``DG_AGENT`` says otherwise. + """ + forced = override(env) + if forced is not None: + return forced == "1" + return is_ai_agent(env) + + +def no_color(env: Mapping[str, str] | None = None) -> bool: + """``NO_COLOR`` is set to a non-empty value (no-color.org).""" + return bool(_environ(env).get("NO_COLOR", "")) + + +def _isatty(stream: object) -> bool: + try: + return bool(stream.isatty()) # type: ignore[attr-defined] + except (AttributeError, OSError, ValueError): + return False + + +def _flags_before_dashes(argv: list[str]) -> list[str]: + return argv[: argv.index("--")] if "--" in argv else argv + + +def soft_reason( + env: Mapping[str, str] | None = None, argv: list[str] | None = None +) -> str | None: + """Why automation is likely (a soft signal), or ``None``.""" + env = _environ(env) + words = _flags_before_dashes(sys.argv[1:] if argv is None else argv) + for flag in ("--non-interactive", "--agent-friendly"): + if flag in words: + return flag + if env.get("CI", "").strip().lower() in ("true", "1"): + return "CI" + score = 0 + if not _isatty(sys.stdin): + score += 1 + if not _isatty(sys.stdout): + score += 1 + if env.get("TERM", "") in ("", "dumb"): + score += 1 + return "no terminal" if score >= 3 else None + + +def is_agentic( + env: Mapping[str, str] | None = None, argv: list[str] | None = None +) -> bool: + """Agent or automation: machine output by default, no prompts or notices.""" + forced = override(env) + if forced is not None: + return forced == "1" + return is_ai_agent(env) or soft_reason(env, argv) is not None + + +class Detection(NamedTuple): + """Everything detection found, for ``dg help agent-mode`` and debugging.""" + + hard: bool + soft: bool + agent_id: str | None + reason: str | None + marker: Match | None + soft_reason: str | None + override: str | None + + +def detect( + env: Mapping[str, str] | None = None, argv: list[str] | None = None +) -> Detection: + """Run every check and report the result.""" + env = _environ(env) + forced = override(env) + marker = hard_match(env) + soft = soft_reason(env, argv) + if forced is not None: + on = forced == "1" + return Detection( + on, False, "dg-agent-1" if on else None, OVERRIDE_VAR, marker, soft, forced + ) + if marker is not None: + return Detection( + True, False, marker.agent_id, marker.reason, marker, soft, None + ) + return Detection(False, soft is not None, None, soft, None, soft, None) diff --git a/packages/deepctl-core/src/deepctl_core/output.py b/packages/deepctl-core/src/deepctl_core/output.py index aa5e5d3b..c7644c45 100644 --- a/packages/deepctl-core/src/deepctl_core/output.py +++ b/packages/deepctl-core/src/deepctl_core/output.py @@ -3,90 +3,31 @@ from __future__ import annotations import json -import os -import sys from io import StringIO from typing import TYPE_CHECKING, Any, cast +from . import agents + if TYPE_CHECKING: from rich.console import Console from rich.progress import Progress # ── Agentic / non-TTY detection ────────────────────────────────── +# deepctl_core.agents holds the table; these names stay importable from here. +# is_agentic(): agent or automation, for output, prompts and notices (honors +# DG_AGENT). is_ai_agent(): a raw AI-agent marker, for trust decisions. +is_agentic = agents.is_agentic +is_ai_agent = agents.is_ai_agent +AI_AGENT_ENV_MARKERS: tuple[str, ...] = agents.HARD_ENV_VARS -def is_agentic() -> bool: - """Detect if running inside an AI coding tool or non-interactive context. - - Uses a layered signal approach: - - Hard signals: known AI-tool environment variables (high confidence) - - Soft signals: score-based TTY/CI heuristics (3+ = probable agent) - - Returns True when the context is likely non-interactive or AI-driven. - """ - env = os.environ - - # Hard signals: explicit non-interactive intent or known AI tool env vars - if "--non-interactive" in sys.argv: - return True - if "--agent-friendly" in sys.argv: - return True - if env.get("CI") in ("true", "1"): - return True - if env.get("CLAUDECODE"): - return True - if env.get("CLAUDE_CODE_ENTRYPOINT"): - return True - if env.get("CODEX_SANDBOX"): - return True - if env.get("CODEX_SANDBOX_NETWORK_DISABLED"): - return True - if env.get("OR_APP_NAME") == "Aider": - return True - if "aider" in env.get("OR_SITE_URL", ""): - return True - - # Soft signals — score-based; none is conclusive alone - score = 0 - if not sys.stdin.isatty(): - score += 1 - if not sys.stdout.isatty(): - score += 1 - if not env.get("TERM") or env.get("TERM") == "dumb": - score += 1 - if "NO_COLOR" in env: - score += 1 - - return score >= 3 - - -# Environment markers set by AI coding agents themselves. Used, instead of -# is_agentic(), wherever the CLI decides whom to trust with the API key: a -# hard signal only. CI, a missing TTY or a missing TERM are never on this -# list, so CI jobs, cron and containers keep the environment they were given. -# An exact list; is_agentic() above is for output formatting and is broader. -AI_AGENT_ENV_MARKERS: tuple[str, ...] = ( - "AI_AGENT", - "CLAUDECODE", - "CLAUDE_CODE_ENTRYPOINT", - "CODEX_SANDBOX", - "CODEX_SANDBOX_NETWORK_DISABLED", - "CURSOR_AGENT", - "GEMINI_CLI", -) - - -def is_ai_agent() -> bool: - """True only when an AI coding agent marked the environment. - - Hard signals: any non-empty variable in ``AI_AGENT_ENV_MARKERS``, or - Aider (``OR_APP_NAME=Aider`` or "aider" in ``OR_SITE_URL``). Never - ``CI``, TTY state, ``TERM`` or command-line flags. - """ - env = os.environ - if any(env.get(name) for name in AI_AGENT_ENV_MARKERS): - return True - return env.get("OR_APP_NAME") == "Aider" or "aider" in env.get("OR_SITE_URL", "") +def agent_mode() -> bool: + """Hard agent mode for presentation, prompts and login: ``DG_AGENT`` + when it is 0 or 1, else :func:`is_ai_agent`. Never for trust.""" + forced = agents.override() + if forced is not None: + return forced == "1" + return is_ai_agent() _agentic = is_agentic() @@ -150,18 +91,23 @@ def __repr__(self) -> str: # Global console instance — plain when agentic, rich when interactive -console = cast("Console", _LazyConsole(no_color=_agentic, highlight=not _agentic)) -stderr_console = cast( - "Console", - _LazyConsole(stderr=True, no_color=_agentic, highlight=not _agentic), -) +# Plain text (no colour and no bold or dim either) in agent mode and when +# NO_COLOR is set: rich's own NO_COLOR handling still writes styles. Never +# no_color=False, which would override NO_COLOR; otherwise rich reads +# FORCE_COLOR and TERM itself. +_plain = _agentic or agents.no_color() +_console_kwargs: dict[str, Any] = {"highlight": not _agentic} +if _plain: + _console_kwargs.update(no_color=True, color_system=None) +console = cast("Console", _LazyConsole(**_console_kwargs)) +stderr_console = cast("Console", _LazyConsole(stderr=True, **_console_kwargs)) # Global output configuration _output_config: dict[str, Any] = { "format": "default", "quiet": False, "verbose": False, - "color": not _agentic, + "color": not _plain, "agentic": _agentic, "format_source": "default", } @@ -173,7 +119,7 @@ def _format_source(requested: str) -> str: return "explicit" if not _output_config["agentic"]: return "default" - return "hard_agent" if is_ai_agent() else "soft" + return "hard_agent" if agent_mode() else "soft" def setup_output( @@ -711,7 +657,7 @@ def format_source() -> str: """Why the output format is what it is. ``"explicit"``: asked for with ``-o``. ``"hard_agent"``: JSON chosen - implicitly while an AI-agent marker (``is_ai_agent()``) is set. + implicitly in hard agent mode (``agents.agent_mode()``). ``"soft"``: JSON chosen implicitly by the TTY/CI heuristics alone. ``"default"``: no format was chosen. """ @@ -741,3 +687,46 @@ def get_status_console() -> Console: Console instance writing to stderr """ return stderr_console + + +def invoked_prog() -> str: + """The program name to show in notices and messages. + + ``DEEPCTL_PROG_NAME`` when it is a plain program name (see + ``deepctl.main.prog_name_from_env``); else the running executable as a + command (``dg``, ``deepctl``, ``deepgram`` when that name runs this + same file, else its path); else ``dg``. + """ + try: + from deepctl.main import prog_name_from_env + except ImportError: # deepctl-core without the CLI: no launcher name + name = None + else: + name = prog_name_from_env() + if name: + return name + from .transport import prog + + return prog() or "dg" + + +def stderr_color() -> bool: + """Whether a raw notice written to stderr may use ANSI colour. + + Only for a person's session (never agent or automation output), with + NO_COLOR unset or empty and TERM not ``dumb``; and only on a terminal, + unless FORCE_COLOR is set. + """ + import os + import sys + + if _output_config["agentic"] or os.environ.get("NO_COLOR"): + return False + if os.environ.get("TERM") == "dumb": + return False + if os.environ.get("FORCE_COLOR"): + return True + try: + return bool(sys.stderr.isatty()) + except (AttributeError, OSError, ValueError): + return False diff --git a/packages/deepctl-core/src/deepctl_core/transport.py b/packages/deepctl-core/src/deepctl_core/transport.py index 9cfe70cb..198ae2d6 100644 --- a/packages/deepctl-core/src/deepctl_core/transport.py +++ b/packages/deepctl-core/src/deepctl_core/transport.py @@ -242,7 +242,7 @@ class RestBackend: """The default backend: the Deepgram SDK over the shared ``httpx.Client``. A backend turns a checked base URL and a key into the SDK client the - facade uses. #142's SageMaker backend plugs in here (R-16). + facade uses. #142's SageMaker backend plugs in here. """ name = "rest" @@ -1084,7 +1084,7 @@ def render( from . import output agentic = bool(output._output_config["agentic"]) - hard = output.is_ai_agent() + hard = output.agent_mode() soft_only = agentic and not hard _, human, agent, automation = err.messages program = prog() diff --git a/packages/deepctl-core/tests/unit/fixtures/agent_env/claude-code-in-vscode.json b/packages/deepctl-core/tests/unit/fixtures/agent_env/claude-code-in-vscode.json new file mode 100644 index 00000000..c7b1521b --- /dev/null +++ b/packages/deepctl-core/tests/unit/fixtures/agent_env/claude-code-in-vscode.json @@ -0,0 +1,45 @@ +{ + "_meta": { + "agent": "claude-code_2-1-312_agent", + "captured": true, + "sanitising": "Captured with `env` in the terminal named, then sanitised: variables whose name contains KEY, TOKEN, SECRET or NONCE, DEEPGRAM_* and the user's own tool variables dropped; per-user and OS housekeeping names (HOME, USER, PWD, TMPDIR, XPC_*, __CF* and similar) dropped; every value replaced with \"x\" except TERM, TERM_PROGRAM, COLORTERM, LANG, SHELL, SHLVL, VSCODE_INJECTION, CLAUDECODE, CLAUDE_CODE_ENTRYPOINT and AI_AGENT; PATH replaced with a generic one.", + "source": "capture 2026-11-01", + "terminal": "Claude Code Bash tool, run from a VS Code terminal (macOS)" + }, + "env": { + "AI_AGENT": "claude-code_2-1-312_agent", + "CLAUDECODE": "1", + "CLAUDE_CODE_CHILD_SESSION": "x", + "CLAUDE_CODE_ENTRYPOINT": "cli", + "CLAUDE_CODE_EXECPATH": "x", + "CLAUDE_CODE_MESSAGING_SOCKET": "x", + "CLAUDE_CODE_SESSION_ATTENDED": "x", + "CLAUDE_CODE_SESSION_ID": "x", + "CLAUDE_CODE_SSE_PORT": "x", + "CLAUDE_EFFORT": "x", + "COLORTERM": "truecolor", + "COMMAND_MODE": "unix2003", + "COREPACK_ENABLE_AUTO_PIN": "x", + "GEMINI_CLI_IDE_SERVER_PORT": "x", + "GEMINI_CLI_IDE_WORKSPACE_PATH": "x", + "GIT_ASKPASS": "x", + "GIT_EDITOR": "x", + "LANG": "C.UTF-8", + "LIBRARY_PATH": "x", + "NoDefaultCurrentDirectoryInExePath": "x", + "PATH": "/usr/local/bin:/usr/bin:/bin", + "SHELL": "/bin/zsh", + "SHLVL": "2", + "SSH_AUTH_SOCK": "x", + "TERM": "xterm-256color", + "TERM_PROGRAM": "vscode", + "TERM_PROGRAM_VERSION": "x", + "VSCODE_GIT_ASKPASS_EXTRA_ARGS": "x", + "VSCODE_GIT_ASKPASS_MAIN": "x", + "VSCODE_GIT_ASKPASS_NODE": "x", + "VSCODE_GIT_IPC_HANDLE": "x", + "VSCODE_INJECTION": "1", + "VSCODE_PROFILE_INITIALIZED": "x", + "VSCODE_PYTHON_AUTOACTIVATE_GUARD": "x" + } +} diff --git a/packages/deepctl-core/tests/unit/fixtures/agent_env/cursor.json b/packages/deepctl-core/tests/unit/fixtures/agent_env/cursor.json new file mode 100644 index 00000000..34af0de7 --- /dev/null +++ b/packages/deepctl-core/tests/unit/fixtures/agent_env/cursor.json @@ -0,0 +1,34 @@ +{ + "_meta": { + "agent": null, + "captured": false, + "sanitising": "Not captured: written from the variables this terminal is documented or known to set (source below), values \"x\" unless the value matters. Replace with a sanitised capture when one is available.", + "source": "cli/cli internal/agents/detect.go (CURSOR_TRACE_ID is the Cursor IDE); Cursor is a VS Code fork", + "terminal": "Cursor integrated terminal" + }, + "env": { + "COLORTERM": "truecolor", + "COMMAND_MODE": "unix2003", + "COREPACK_ENABLE_AUTO_PIN": "x", + "CURSOR_TRACE_ID": "x", + "GIT_ASKPASS": "x", + "GIT_EDITOR": "x", + "LANG": "C.UTF-8", + "LIBRARY_PATH": "x", + "NoDefaultCurrentDirectoryInExePath": "x", + "PATH": "/usr/local/bin:/usr/bin:/bin", + "SHELL": "/bin/zsh", + "SHLVL": "2", + "SSH_AUTH_SOCK": "x", + "TERM": "xterm-256color", + "TERM_PROGRAM": "vscode", + "TERM_PROGRAM_VERSION": "x", + "VSCODE_GIT_ASKPASS_EXTRA_ARGS": "x", + "VSCODE_GIT_ASKPASS_MAIN": "x", + "VSCODE_GIT_ASKPASS_NODE": "x", + "VSCODE_GIT_IPC_HANDLE": "x", + "VSCODE_INJECTION": "1", + "VSCODE_PROFILE_INITIALIZED": "x", + "VSCODE_PYTHON_AUTOACTIVATE_GUARD": "x" + } +} diff --git a/packages/deepctl-core/tests/unit/fixtures/agent_env/github-actions.json b/packages/deepctl-core/tests/unit/fixtures/agent_env/github-actions.json new file mode 100644 index 00000000..301633e6 --- /dev/null +++ b/packages/deepctl-core/tests/unit/fixtures/agent_env/github-actions.json @@ -0,0 +1,31 @@ +{ + "_meta": { + "agent": null, + "captured": false, + "sanitising": "Not captured: written from the variables this terminal is documented or known to set (source below), values \"x\" unless the value matters. Replace with a sanitised capture when one is available.", + "source": "docs.github.com, Variables reference: default environment variables", + "terminal": "GitHub Actions job step (ubuntu runner)" + }, + "env": { + "CI": "true", + "GITHUB_ACTION": "x", + "GITHUB_ACTIONS": "true", + "GITHUB_ACTOR": "x", + "GITHUB_EVENT_NAME": "push", + "GITHUB_JOB": "x", + "GITHUB_REF": "x", + "GITHUB_REPOSITORY": "x", + "GITHUB_RUN_ID": "x", + "GITHUB_SHA": "x", + "GITHUB_WORKFLOW": "x", + "GITHUB_WORKSPACE": "x", + "HOME": "/home/runner", + "ImageOS": "ubuntu24", + "LANG": "C.UTF-8", + "PATH": "/usr/local/bin:/usr/bin:/bin", + "RUNNER_ARCH": "X64", + "RUNNER_OS": "Linux", + "RUNNER_TEMP": "x", + "SHELL": "/bin/bash" + } +} diff --git a/packages/deepctl-core/tests/unit/fixtures/agent_env/jetbrains.json b/packages/deepctl-core/tests/unit/fixtures/agent_env/jetbrains.json new file mode 100644 index 00000000..1558ca7b --- /dev/null +++ b/packages/deepctl-core/tests/unit/fixtures/agent_env/jetbrains.json @@ -0,0 +1,20 @@ +{ + "_meta": { + "agent": null, + "captured": false, + "sanitising": "Not captured: written from the variables this terminal is documented or known to set (source below), values \"x\" unless the value matters. Replace with a sanitised capture when one is available.", + "source": "JetBrains terminal sets TERMINAL_EMULATOR=JetBrains-JediTerm", + "terminal": "JetBrains IDE terminal" + }, + "env": { + "COLORTERM": "truecolor", + "LANG": "en_US.UTF-8", + "LOGIN_SHELL": "1", + "PATH": "/usr/local/bin:/usr/bin:/bin", + "SHELL": "/bin/zsh", + "SHLVL": "1", + "TERM": "xterm-256color", + "TERMINAL_EMULATOR": "JetBrains-JediTerm", + "__INTELLIJ_COMMAND_HISTFILE__": "x" + } +} diff --git a/packages/deepctl-core/tests/unit/fixtures/agent_env/vscode.json b/packages/deepctl-core/tests/unit/fixtures/agent_env/vscode.json new file mode 100644 index 00000000..e479bb20 --- /dev/null +++ b/packages/deepctl-core/tests/unit/fixtures/agent_env/vscode.json @@ -0,0 +1,35 @@ +{ + "_meta": { + "agent": null, + "captured": true, + "sanitising": "Captured with `env` in the terminal named, then sanitised: variables whose name contains KEY, TOKEN, SECRET or NONCE, DEEPGRAM_* and the user's own tool variables dropped; per-user and OS housekeeping names (HOME, USER, PWD, TMPDIR, XPC_*, __CF* and similar) dropped; every value replaced with \"x\" except TERM, TERM_PROGRAM, COLORTERM, LANG, SHELL, SHLVL, VSCODE_INJECTION, CLAUDECODE, CLAUDE_CODE_ENTRYPOINT and AI_AGENT; PATH replaced with a generic one.", + "source": "capture 2026-11-01; Claude Code's own variables removed (CLAUDE*, AI_AGENT)", + "terminal": "VS Code integrated terminal (macOS)" + }, + "env": { + "COLORTERM": "truecolor", + "COMMAND_MODE": "unix2003", + "COREPACK_ENABLE_AUTO_PIN": "x", + "GEMINI_CLI_IDE_SERVER_PORT": "x", + "GEMINI_CLI_IDE_WORKSPACE_PATH": "x", + "GIT_ASKPASS": "x", + "GIT_EDITOR": "x", + "LANG": "C.UTF-8", + "LIBRARY_PATH": "x", + "NoDefaultCurrentDirectoryInExePath": "x", + "PATH": "/usr/local/bin:/usr/bin:/bin", + "SHELL": "/bin/zsh", + "SHLVL": "2", + "SSH_AUTH_SOCK": "x", + "TERM": "xterm-256color", + "TERM_PROGRAM": "vscode", + "TERM_PROGRAM_VERSION": "x", + "VSCODE_GIT_ASKPASS_EXTRA_ARGS": "x", + "VSCODE_GIT_ASKPASS_MAIN": "x", + "VSCODE_GIT_ASKPASS_NODE": "x", + "VSCODE_GIT_IPC_HANDLE": "x", + "VSCODE_INJECTION": "1", + "VSCODE_PROFILE_INITIALIZED": "x", + "VSCODE_PYTHON_AUTOACTIVATE_GUARD": "x" + } +} diff --git a/packages/deepctl-core/tests/unit/fixtures/agent_env/warp.json b/packages/deepctl-core/tests/unit/fixtures/agent_env/warp.json new file mode 100644 index 00000000..46b6e0e9 --- /dev/null +++ b/packages/deepctl-core/tests/unit/fixtures/agent_env/warp.json @@ -0,0 +1,22 @@ +{ + "_meta": { + "agent": null, + "captured": false, + "sanitising": "Not captured: written from the variables this terminal is documented or known to set (source below), values \"x\" unless the value matters. Replace with a sanitised capture when one is available.", + "source": "Warp sets TERM_PROGRAM=WarpTerminal and WARP_* variables", + "terminal": "Warp" + }, + "env": { + "COLORTERM": "truecolor", + "LANG": "en_US.UTF-8", + "PATH": "/usr/local/bin:/usr/bin:/bin", + "SHELL": "/bin/zsh", + "SHLVL": "1", + "TERM": "xterm-256color", + "TERM_PROGRAM": "WarpTerminal", + "TERM_PROGRAM_VERSION": "x", + "WARP_HONOR_PS1": "0", + "WARP_IS_LOCAL_SHELL_SESSION": "1", + "WARP_USE_SSH_WRAPPER": "1" + } +} diff --git a/packages/deepctl-core/tests/unit/fixtures/agent_env/zed.json b/packages/deepctl-core/tests/unit/fixtures/agent_env/zed.json new file mode 100644 index 00000000..6a97d54c --- /dev/null +++ b/packages/deepctl-core/tests/unit/fixtures/agent_env/zed.json @@ -0,0 +1,20 @@ +{ + "_meta": { + "agent": null, + "captured": false, + "sanitising": "Not captured: written from the variables this terminal is documented or known to set (source below), values \"x\" unless the value matters. Replace with a sanitised capture when one is available.", + "source": "Zed sets TERM_PROGRAM=zed and ZED_TERM=true", + "terminal": "Zed integrated terminal" + }, + "env": { + "COLORTERM": "truecolor", + "LANG": "en_US.UTF-8", + "PATH": "/usr/local/bin:/usr/bin:/bin", + "SHELL": "/bin/zsh", + "SHLVL": "1", + "TERM": "xterm-256color", + "TERM_PROGRAM": "zed", + "TERM_PROGRAM_VERSION": "x", + "ZED_TERM": "true" + } +} diff --git a/packages/deepctl-core/tests/unit/test_agent_trust.py b/packages/deepctl-core/tests/unit/test_agent_trust.py new file mode 100644 index 00000000..b659dc95 --- /dev/null +++ b/packages/deepctl-core/tests/unit/test_agent_trust.py @@ -0,0 +1,176 @@ +"""Trust follows the raw AI-agent markers, never DG_AGENT. + +Every hard signal in the detection table turns on the agent-session config +rules: a user config others can change, or another user owns, is ignored, +its API key unused and never saved to, and the base URL rules for agents +apply. DG_AGENT=0 doesn't turn them off, and DG_AGENT=1 alone doesn't turn +them on. These tests set real environment variables; nothing patches the +detection functions. +""" + +from __future__ import annotations + +import os +import sys + +import pytest +import yaml +from deepctl_core import agents, hosts +from deepctl_core import config as config_module +from deepctl_core.config import Config + +pytestmark = pytest.mark.skipif( + not sys.platform.startswith("linux"), reason="XDG layout (Linux)" +) + +ALLOWED = "127.0.0.1:8000" + + +@pytest.fixture(autouse=True) +def home(tmp_path, monkeypatch): + for name in (*agents.HARD_ENV_VARS, agents.OVERRIDE_VAR): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("PATH", "/usr/local/bin:/usr/bin:/bin") + monkeypatch.setattr(agents, "DEVIN_MARKER", str(tmp_path / "no-devin")) + monkeypatch.setattr(config_module, "_pending_notices", []) + monkeypatch.setattr(config_module, "_seen_notices", set()) + monkeypatch.setattr(hosts, "_reported", set()) + root = tmp_path / "home" + root.mkdir() + monkeypatch.setattr(config_module, "_real_home", lambda: str(root)) + monkeypatch.setenv("HOME", str(root)) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + return root + + +def plant(home, mode: int): + """A user config holding a key, that other users can change.""" + cfg = home / ".config" / "deepctl" / "config.yaml" + cfg.parent.mkdir(parents=True) + cfg.write_text( + yaml.safe_dump( + { + "api": {"allowed_hosts": [ALLOWED]}, + "profiles": {"default": {"api_key": "k-planted"}}, + } + ) + ) + cfg.chmod(mode) + return cfg + + +def assert_agent_rules(home, monkeypatch) -> None: + """0.4.1's agent-session rules: key unused, file untouched and unsaved, + an env base URL off the allowlist refused.""" + cfg = plant(home, 0o666) + before = cfg.read_bytes() + config = Config() + assert config.get_profile().api_key is None + assert config.api_key_untrusted is True + assert config.trusted_file is False + with pytest.raises(config_module.ConfigSaveRefused): + config.update_profile("default", project_id="p-agent") + assert cfg.read_bytes() == before + assert (cfg.stat().st_mode & 0o777) == 0o666 + monkeypatch.setenv("DEEPGRAM_BASE_URL", "https://evil.example") + with pytest.raises(hosts.CredentialHostError): + hosts.check_credential_host(Config()) + + +def assert_person_rules(home, monkeypatch) -> None: + """A person's session: the file is tightened to 0600 and trusted, the + key used, and an env base URL used with a warning.""" + cfg = plant(home, 0o666) + config = Config() + assert config.get_profile().api_key == "k-planted" + assert config.trusted_file is True + assert (cfg.stat().st_mode & 0o777) == 0o600 + config.update_profile("default", project_id="p-person") + assert yaml.safe_load(cfg.read_text())["profiles"]["default"]["project_id"] == ( + "p-person" + ) + monkeypatch.setenv("DEEPGRAM_BASE_URL", "https://evil.example") + assert hosts.check_credential_host(Config()) == "https://evil.example" + + +def test_no_marker_is_a_person(home, monkeypatch): + assert_person_rules(home, monkeypatch) + + +def test_dg_agent_0_does_not_loosen_trust(home, monkeypatch): + monkeypatch.setenv("CLAUDECODE", "1") + monkeypatch.setenv("DG_AGENT", "0") + assert agents.agent_mode() is False # presentation: a person + assert_agent_rules(home, monkeypatch) # trust: still an agent session + + +def test_dg_agent_0_with_ai_agent_does_not_loosen_trust(home, monkeypatch): + monkeypatch.setenv("AI_AGENT", "some-agent") + monkeypatch.setenv("DG_AGENT", "0") + assert_agent_rules(home, monkeypatch) + + +@pytest.mark.parametrize("name", ["CLAUDECODE", "AI_AGENT"]) +@pytest.mark.parametrize("value", [" ", "\t"]) +def test_blank_marker_keeps_trust_strict(home, monkeypatch, name, value): + """A marker set to whitespace is still set: as before this release, and + as cli/cli reads it.""" + monkeypatch.setenv(name, value) + assert agents.is_ai_agent() is True + assert_agent_rules(home, monkeypatch) + + +def test_dg_agent_1_alone_keeps_person_trust(home, monkeypatch): + monkeypatch.setenv("DG_AGENT", "1") + assert agents.agent_mode() is True + assert_person_rules(home, monkeypatch) + + +# The hard signals this release adds to the trust rules (0.4.1 had AI_AGENT, +# CLAUDECODE, CLAUDE_CODE_ENTRYPOINT, CODEX_SANDBOX, +# CODEX_SANDBOX_NETWORK_DISABLED, CURSOR_AGENT, GEMINI_CLI and Aider's two). +NEW_SIGNALS = [ + {"AGENT": "amp"}, + {"AGENT": "goose"}, + {"GOOSE_TERMINAL": "1"}, + {"CLAUDE_CODE": "1"}, + {"CLAUDE_CODE_IS_COWORK": "1"}, + {"CODEX_CI": "1"}, + {"CODEX_THREAD_ID": "t-1"}, + {"COPILOT_CLI": "1"}, + {"OPENCODE": "1"}, + {"ANTIGRAVITY_AGENT": "1"}, + {"AUGMENT_AGENT": "1"}, + {"CURSOR_EXTENSION_HOST_ROLE": "agent-exec"}, + {"PATH": "/home/u/.pi/agent/bin:/usr/bin:/bin"}, +] + + +@pytest.mark.parametrize("env", NEW_SIGNALS, ids=lambda e: "-".join(e)) +def test_each_new_signal_applies_the_agent_rules(home, monkeypatch, env): + for name, value in env.items(): + monkeypatch.setenv(name, value) + assert_agent_rules(home, monkeypatch) + + +@pytest.mark.parametrize("env", NEW_SIGNALS, ids=lambda e: "-".join(e)) +def test_each_new_signal_with_dg_agent_0_keeps_the_agent_rules(home, monkeypatch, env): + for name, value in env.items(): + monkeypatch.setenv(name, value) + monkeypatch.setenv("DG_AGENT", "0") + assert_agent_rules(home, monkeypatch) + + +def test_devin_marker_applies_the_agent_rules(home, monkeypatch, tmp_path): + marker = tmp_path / ".devin" + marker.write_text("") + monkeypatch.setattr(agents, "DEVIN_MARKER", str(marker)) + assert_agent_rules(home, monkeypatch) + + +def test_trust_reads_the_detection_table(): + """Every trust check goes through the same function the table feeds.""" + from deepctl_core import output + + assert output.is_ai_agent is agents.is_ai_agent + assert os.environ.get("DG_AGENT") is None diff --git a/packages/deepctl-core/tests/unit/test_agents.py b/packages/deepctl-core/tests/unit/test_agents.py new file mode 100644 index 00000000..2a54231c --- /dev/null +++ b/packages/deepctl-core/tests/unit/test_agents.py @@ -0,0 +1,340 @@ +"""Agent detection: the table, its value rules, DG_AGENT, and the snapshots +of human terminals that must never count as an AI agent.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest +from deepctl_core import agents, output + +FIXTURES = Path(__file__).parent / "fixtures" / "agent_env" + +# Each variable alone, with a value that sets it, and the agent it names. +ROWS = [ + ("AI_AGENT", "github_copilot_vscode_agent", "github_copilot_vscode_agent"), + ("AGENT", "amp", "amp"), + ("AGENT", "goose", "goose"), + ("GOOSE_TERMINAL", "1", "goose"), + ("CLAUDECODE", "1", "claude-code"), + ("CLAUDE_CODE", "1", "claude-code"), + ("CLAUDE_CODE_IS_COWORK", "1", "cowork"), + ("CLAUDE_CODE_ENTRYPOINT", "sdk-py", "claude-code"), + ("CODEX_SANDBOX", "seatbelt", "codex"), + ("CODEX_CI", "1", "codex"), + ("CODEX_THREAD_ID", "t-1", "codex"), + ("CODEX_SANDBOX_NETWORK_DISABLED", "1", "codex"), + ("GEMINI_CLI", "1", "gemini-cli"), + ("COPILOT_CLI", "1", "copilot-cli"), + ("OPENCODE", "1", "opencode"), + ("ANTIGRAVITY_AGENT", "1", "antigravity"), + ("AUGMENT_AGENT", "1", "augment-cli"), + ("CURSOR_AGENT", "1", "cursor-cli"), + ("CURSOR_EXTENSION_HOST_ROLE", "agent-exec", "cursor-cli"), + ("OR_APP_NAME", "Aider", "aider"), + ("OR_SITE_URL", "https://aider.chat", "aider"), +] + +# Set by people, IDEs or other tools; never a hard signal alone. +NOT_HARD = [ + {"OPENCODE_CLIENT": "vscode"}, + {"CURSOR_TRACE_ID": "abc"}, + {"TERM_PROGRAM": "kiro"}, + {"REPL_ID": "r"}, + {"COPILOT_MODEL": "gpt"}, + {"COPILOT_ALLOW_ALL": "1"}, + {"COPILOT_GITHUB_TOKEN": "t"}, + {"GOOSE_PROVIDER": "openai"}, + {"TERM_PROGRAM": "vscode", "VSCODE_INJECTION": "1"}, + {"ZED_TERM": "true"}, + {"TERMINAL_EMULATOR": "JetBrains-JediTerm"}, + {"WARP_IS_LOCAL_SHELL_SESSION": "1"}, + {"CI": "true", "GITHUB_ACTIONS": "true"}, + {"NO_COLOR": "1"}, + {"GEMINI_CLI_IDE_SERVER_PORT": "1"}, + {"AGENT": "claude"}, + {"CURSOR_EXTENSION_HOST_ROLE": "user"}, + {"OR_APP_NAME": "other"}, +] + + +@pytest.fixture(autouse=True) +def _no_devin(monkeypatch, tmp_path): + monkeypatch.setattr(agents, "DEVIN_MARKER", str(tmp_path / "no-devin")) + + +def _env(**values: str) -> dict[str, str]: + return {"PATH": "/usr/local/bin:/usr/bin:/bin", **values} + + +class TestTable: + @pytest.mark.parametrize(("name", "value", "agent_id"), ROWS) + def test_each_variable_alone_is_hard(self, name, value, agent_id): + env = _env(**{name: value}) + found = agents.hard_match(env) + assert found is not None + assert (found.agent_id, found.reason) == (agent_id, name) + assert agents.is_ai_agent(env) is True + assert agents.agent_mode(env) is True + + @pytest.mark.parametrize("name", sorted({row[0] for row in ROWS})) + def test_empty_is_not_set(self, name): + assert agents.is_ai_agent(_env(**{name: ""})) is False + + @pytest.mark.parametrize( + "name", + sorted( + {row[0] for row in ROWS} + - {"AGENT", "CURSOR_EXTENSION_HOST_ROLE", "OR_APP_NAME", "OR_SITE_URL"} + ), + ) + @pytest.mark.parametrize("value", [" ", " ", "\t"]) + def test_whitespace_is_set(self, name, value): + # Present and not empty, like cli/cli: a blank marker stays a marker, + # so trust never loosens on an odd value. + assert agents.is_ai_agent(_env(**{name: value})) is True + + def test_blank_ai_agent_is_unknown(self): + found = agents.hard_match(_env(AI_AGENT=" ")) + assert found is not None and found.agent_id == "unknown" + + def test_every_env_row_is_listed(self): + assert {row[0] for row in ROWS} == set(agents.HARD_ENV_VARS) + + def test_every_row_names_a_known_source(self): + for signal in agents.HARD_SIGNALS: + assert signal.sources, signal.name + assert set(signal.sources) <= set(agents.SOURCES), signal.name + for url in agents.SOURCES.values(): + assert url.startswith("https://github.com/") + + @pytest.mark.parametrize("env", NOT_HARD) + def test_not_hard(self, env): + assert agents.is_ai_agent(_env(**env)) is False + + @pytest.mark.parametrize( + ("value", "agent_id"), + [ + ("claude-code_2-1-312_agent", "claude-code_2-1-312_agent"), + ("has space", "unknown"), + ("x" * 65, "unknown"), + ("semi;colon", "unknown"), + ], + ) + def test_ai_agent_value_rule(self, value, agent_id): + found = agents.hard_match(_env(AI_AGENT=value)) + assert found is not None and found.agent_id == agent_id + + def test_amp_wins_over_claudecode(self): + found = agents.hard_match(_env(AGENT="amp", CLAUDECODE="1")) + assert found is not None and found.agent_id == "amp" + + def test_ai_agent_comes_first(self): + found = agents.hard_match(_env(AI_AGENT="x", CLAUDECODE="1")) + assert found is not None and found.reason == "AI_AGENT" + + @pytest.mark.parametrize( + "path", + [ + "/home/u/.pi/agent/bin:/usr/bin", + "C:\\Users\\u\\.pi\\agent\\bin;C:\\Windows", + ], + ) + def test_pi_path_entry(self, path): + found = agents.hard_match({"PATH": path}) + assert found is not None + assert (found.agent_id, found.session) == ("pi", True) + assert found.reason == "a .pi/agent entry in PATH" + + def test_pi_needs_a_path_segment(self): + assert agents.is_ai_agent({"PATH": "/opt/xpi/agent:/usr/bin"}) is False + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX marker file") + def test_devin_marker_file(self, monkeypatch, tmp_path): + marker = tmp_path / ".devin" + marker.write_text("") + monkeypatch.setattr(agents, "DEVIN_MARKER", str(marker)) + found = agents.hard_match(_env()) + assert found is not None + assert (found.agent_id, found.reason, found.session) == ( + "devin", + str(marker), + True, + ) + + +class TestSnapshots: + """Environments captured from (or documented for) real terminals.""" + + @staticmethod + def _load(name: str) -> tuple[dict, dict[str, str]]: + data = json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8")) + return data["_meta"], data["env"] + + @pytest.mark.parametrize( + "name", ["vscode", "cursor", "jetbrains", "warp", "zed", "github-actions"] + ) + def test_human_terminals_and_ci_are_not_ai_agents(self, name): + meta, env = self._load(name) + assert meta["agent"] is None + assert meta["sanitising"] + assert agents.is_ai_agent(env) is False + assert agents.agent_mode(env) is False + assert agents.detect(env, []).agent_id is None + + def test_github_actions_is_soft(self): + _, env = self._load("github-actions") + detection = agents.detect(env, []) + assert (detection.hard, detection.soft, detection.reason) == ( + False, + True, + "CI", + ) + + def test_claude_code_capture_is_hard(self): + meta, env = self._load("claude-code-in-vscode") + found = agents.hard_match(env) + assert found is not None + assert found.agent_id == meta["agent"] + assert found.reason == "AI_AGENT" + + def test_fixtures_hold_no_secrets(self): + for path in FIXTURES.glob("*.json"): + env = json.loads(path.read_text(encoding="utf-8"))["env"] + for name in env: + assert not any( + word in name for word in ("KEY", "TOKEN", "SECRET", "DEEPGRAM_") + ), (path.name, name) + + +class TestOverride: + def test_dg_agent_1_turns_agent_mode_on(self): + env = _env(DG_AGENT="1") + assert agents.agent_mode(env) is True + assert agents.is_agentic(env, []) is True + detection = agents.detect(env, []) + assert (detection.hard, detection.agent_id, detection.reason) == ( + True, + "dg-agent-1", + "DG_AGENT", + ) + + def test_dg_agent_1_does_not_mark_an_ai_agent_for_trust(self): + assert agents.is_ai_agent(_env(DG_AGENT="1")) is False + + def test_dg_agent_0_turns_agent_mode_off(self): + env = _env(DG_AGENT="0", CLAUDECODE="1", CI="true") + assert agents.agent_mode(env) is False + assert agents.is_agentic(env, ["--non-interactive"]) is False + assert agents.detect(env, []).hard is False + + def test_dg_agent_0_keeps_trust_strict(self): + # Trust reads the raw markers: DG_AGENT=0 never loosens it. + env = _env(DG_AGENT="0", CLAUDECODE="1") + assert agents.is_ai_agent(env) is True + assert agents.detect(env, []).marker is not None + + @pytest.mark.parametrize("value", ["", "2", "yes", "true", "on"]) + def test_other_values_are_ignored(self, value): + assert agents.override(_env(DG_AGENT=value)) is None + assert agents.agent_mode(_env(DG_AGENT=value)) is False + assert agents.agent_mode(_env(DG_AGENT=value, CLAUDECODE="1")) is True + + def test_output_agent_mode_follows_override(self, monkeypatch): + monkeypatch.setattr(output, "is_ai_agent", lambda: True) + monkeypatch.setenv("DG_AGENT", "0") + assert output.agent_mode() is False + monkeypatch.setattr(output, "is_ai_agent", lambda: False) + monkeypatch.setenv("DG_AGENT", "1") + assert output.agent_mode() is True + monkeypatch.delenv("DG_AGENT") + assert output.agent_mode() is False + + +class TestSoft: + @pytest.fixture + def tty(self, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + + def test_flags_only_before_double_dash(self, tty): + env = _env(TERM="xterm") + assert agents.soft_reason(env, ["listen", "--non-interactive"]) == ( + "--non-interactive" + ) + assert agents.soft_reason(env, ["api", "--", "--agent-friendly"]) is None + + def test_no_color_never_counts(self, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + assert agents.soft_reason(_env(TERM="xterm", NO_COLOR="1"), []) is None + assert agents.soft_reason(_env(TERM="dumb"), []) == "no terminal" + + +def test_stdlib_only(): + """Detection runs before anything heavy loads; it imports only stdlib.""" + code = ( + "import sys; before = set(sys.modules); import deepctl_core.agents; " + "new = set(sys.modules) - before; " + "bad = sorted(m for m in new if m.split('.')[0] in " + "('rich','click','pydantic','httpx','yaml','deepgram','keyring')); " + "print(','.join(bad))" + ) + out = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, check=True + ) + assert out.stdout.strip() == "" + + +@pytest.mark.skipif(sys.platform == "win32", reason="needs a pty") +@pytest.mark.parametrize( + ("env", "ansi"), + [ + ({}, True), + ({"NO_COLOR": "1"}, False), + ({"NO_COLOR": "1", "FORCE_COLOR": "1"}, False), + ({"CLAUDECODE": "1"}, False), + ({"CLAUDECODE": "1", "DG_AGENT": "0"}, True), + ], +) +def test_console_on_a_terminal_honors_no_color(env, ansi): + """On a terminal, colour and styles only for a person without NO_COLOR.""" + import os + import pty + + code = ( + "from deepctl_core import output\n" + "output.console.print('[red]x[/red] [dim]y[/dim] [bold]z[/bold]')\n" + "output.stderr_console.print('[yellow]w[/yellow] [dim]v[/dim]')\n" + ) + clean = { + k: v + for k, v in os.environ.items() + if k not in (*agents.HARD_ENV_VARS, "DG_AGENT", "NO_COLOR", "FORCE_COLOR", "CI") + } + master, slave = pty.openpty() + proc = subprocess.run( + [sys.executable, "-c", code], + stdin=slave, + stdout=slave, + stderr=slave, + env={**clean, "TERM": "xterm-256color", "PATH": "/usr/bin:/bin", **env}, + timeout=60, + check=True, + ) + os.close(slave) + out = b"" + while True: + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + out += chunk + os.close(master) + assert proc.returncode == 0 + assert (b"\x1b[" in out) is ansi, out diff --git a/packages/deepctl-core/tests/unit/test_output.py b/packages/deepctl-core/tests/unit/test_output.py index 79a5b98d..7aacafde 100644 --- a/packages/deepctl-core/tests/unit/test_output.py +++ b/packages/deepctl-core/tests/unit/test_output.py @@ -6,6 +6,7 @@ import pytest import yaml from deepctl_core.output import ( + AI_AGENT_ENV_MARKERS, OutputFormatter, get_console, is_agentic, @@ -27,16 +28,7 @@ def baseline(self, monkeypatch): monkeypatch.setattr("sys.argv", ["dg", "listen", "foo.wav"]) monkeypatch.setattr("sys.stdin.isatty", lambda: True) monkeypatch.setattr("sys.stdout.isatty", lambda: True) - for var in ( - "CI", - "CLAUDECODE", - "CLAUDE_CODE_ENTRYPOINT", - "CODEX_SANDBOX", - "CODEX_SANDBOX_NETWORK_DISABLED", - "OR_APP_NAME", - "OR_SITE_URL", - "NO_COLOR", - ): + for var in (*AI_AGENT_ENV_MARKERS, "CI", "NO_COLOR", "DG_AGENT"): monkeypatch.delenv(var, raising=False) monkeypatch.setenv("TERM", "xterm-256color") @@ -66,14 +58,14 @@ def test_agent_friendly_flag_anywhere_in_argv(self, baseline, monkeypatch, argv) monkeypatch.setattr("sys.argv", argv) assert is_agentic() is True - @pytest.mark.parametrize("ci_value", ["1", "true"]) + @pytest.mark.parametrize("ci_value", ["1", "true", "TRUE", " True "]) def test_ci_env_var_is_hard_signal(self, baseline, monkeypatch, ci_value): monkeypatch.setenv("CI", ci_value) assert is_agentic() is True @pytest.mark.parametrize( "ci_value", - ["false", "0", "", "yes", "TRUE"], + ["false", "0", "", "yes"], ) def test_other_ci_values_do_not_count(self, baseline, monkeypatch, ci_value): monkeypatch.setenv("CI", ci_value) @@ -109,11 +101,13 @@ def test_two_soft_signals_not_enough(self, baseline, monkeypatch): monkeypatch.setattr("sys.stdout.isatty", lambda: False) assert is_agentic() is False - def test_three_soft_signals_trip_threshold(self, baseline, monkeypatch): + def test_no_color_is_not_a_soft_signal(self, baseline, monkeypatch): + # NO_COLOR asks for no colour, nothing more: it never adds to the + # score that switches output to JSON. monkeypatch.setattr("sys.stdin.isatty", lambda: False) monkeypatch.setattr("sys.stdout.isatty", lambda: False) monkeypatch.setenv("NO_COLOR", "1") - assert is_agentic() is True + assert is_agentic() is False def test_dumb_term_counts_as_soft_signal(self, baseline, monkeypatch): monkeypatch.setattr("sys.stdin.isatty", lambda: False) @@ -491,3 +485,51 @@ def test_setup_output_imports_no_rich(self, tmp_path): ) assert proc.returncode == 0, proc.stderr assert proc.stdout == "" + + +class TestProgAndColour: + def test_invoked_prog_prefers_prog_name_env(self, monkeypatch): + from deepctl_core.output import invoked_prog + + monkeypatch.setenv("DEEPCTL_PROG_NAME", "deepgram") + assert invoked_prog() == "deepgram" + + def test_invoked_prog_falls_back_to_dg(self, monkeypatch): + from deepctl_core.output import invoked_prog + + monkeypatch.delenv("DEEPCTL_PROG_NAME", raising=False) + monkeypatch.setattr("sys.argv", ["/opt/python3", "-c"]) + assert invoked_prog() == "dg" + + @pytest.mark.parametrize( + "value", ["/tmp/x;rm -rf ~", "dg\x1b[2J", "a/b", "dg name", "$(id)", "-h"] + ) + def test_invoked_prog_ignores_a_name_that_isnt_plain(self, monkeypatch, value): + from deepctl_core.output import invoked_prog + + monkeypatch.setenv("DEEPCTL_PROG_NAME", value) + monkeypatch.setattr("sys.argv", ["/opt/python3", "-c"]) + assert invoked_prog() == "dg" + + @pytest.mark.parametrize( + ("agentic", "tty", "env", "expected"), + [ + (False, True, {}, True), + (False, True, {"NO_COLOR": "1"}, False), + (False, True, {"TERM": "dumb"}, False), + (False, False, {}, False), + (False, False, {"FORCE_COLOR": "1"}, True), + (True, True, {}, False), + ], + ) + def test_stderr_color(self, monkeypatch, agentic, tty, env, expected): + from deepctl_core import output + + monkeypatch.setitem(output._output_config, "agentic", agentic) + for name in ("NO_COLOR", "FORCE_COLOR"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("TERM", "xterm") + for name, value in env.items(): + monkeypatch.setenv(name, value) + monkeypatch.setattr("sys.stderr.isatty", lambda: tty, raising=False) + assert output.stderr_color() is expected diff --git a/packages/deepctl-shared-utils/src/deepctl_shared_utils/diagnostics.py b/packages/deepctl-shared-utils/src/deepctl_shared_utils/diagnostics.py index e01979fe..8f7edeb1 100644 --- a/packages/deepctl-shared-utils/src/deepctl_shared_utils/diagnostics.py +++ b/packages/deepctl-shared-utils/src/deepctl_shared_utils/diagnostics.py @@ -1,6 +1,5 @@ """Plain-text diagnostic output shared by utilities outside deepctl-core.""" -import os import sys from typing import TextIO @@ -8,45 +7,30 @@ def is_non_interactive() -> bool: - """Mirror deepctl-core's agent and non-interactive output policy. + """Agent or automation, as ``deepctl_core.output.is_agentic`` decides. - Shared utilities intentionally do not depend on deepctl-core, so keep this - lightweight detection aligned with ``deepctl_core.output.is_agentic``. + ``deepctl_core.agents`` is stdlib only, so importing it here costs + nothing that core's console doesn't already pay. """ - env = os.environ - - if "--non-interactive" in sys.argv or "--agent-friendly" in sys.argv: - return True - if env.get("CI") in ("true", "1"): - return True - if env.get("CLAUDECODE") or env.get("CLAUDE_CODE_ENTRYPOINT"): - return True - if env.get("CODEX_SANDBOX") or env.get("CODEX_SANDBOX_NETWORK_DISABLED"): - return True - if env.get("OR_APP_NAME") == "Aider" or "aider" in env.get("OR_SITE_URL", ""): - return True - - score = 0 - if not sys.stdin.isatty(): - score += 1 - if not sys.stdout.isatty(): - score += 1 - if not env.get("TERM") or env.get("TERM") == "dumb": - score += 1 - if "NO_COLOR" in env: - score += 1 - - return score >= 3 + from deepctl_core.agents import is_agentic + + return is_agentic() def create_diagnostic_console( *, file: TextIO | None = None, force_terminal: bool | None = None ) -> Console: """Create a stderr diagnostic console with the shared plain-text policy.""" + from deepctl_core.agents import no_color + non_interactive = is_non_interactive() + # Plain text for agents and under NO_COLOR (rich alone keeps bold and + # dim); never no_color=False, so rich honors FORCE_COLOR and TERM. + plain = non_interactive or no_color() return Console( file=file or sys.stderr, force_terminal=force_terminal, - no_color=non_interactive, + no_color=True if plain else None, + color_system=None if plain else "auto", highlight=not non_interactive, ) diff --git a/src/deepctl/_cache.py b/src/deepctl/_cache.py index 5c287477..e42900b8 100644 --- a/src/deepctl/_cache.py +++ b/src/deepctl/_cache.py @@ -44,6 +44,9 @@ FORMAT = 1 +# The project file, read from the working directory. +PROJECT_FILE = "deepgram.yaml" + # Environment variables root --help output, or the plugin set, depends on. HELP_ENV_VARS = ( "COLUMNS", @@ -264,16 +267,27 @@ def _read_private(path: str) -> bytes | None: def file_state(path: str) -> dict[str, Any]: - """What the help-cache key records about one file or directory.""" + """What the help-cache key records about one file or directory. + + ``mode`` and ``uid`` are there because the trust checks read them and + chmod or chown changes neither mtime nor size. A path ``stat`` can't + follow but ``lstat`` finds (a dangling symlink) is recorded from + ``lstat``, so a reader's ``stat`` fails on it and never matches; + ``absent`` means not even ``lstat`` finds it.""" try: info = os.stat(path) except OSError: - return {"path": path, "absent": True} + try: + info = os.lstat(path) + except OSError: + return {"path": path, "absent": True} return { "path": path, "mtime_ns": info.st_mtime_ns, "size": info.st_size, "inode": info.st_ino, + "mode": info.st_mode, + "uid": info.st_uid, } @@ -318,26 +332,33 @@ def plugin_state_paths() -> list[str]: return list(dict.fromkeys(paths)) -def _plugin_dir_trust() -> list[Any]: - """Owner and mode of dg's own plugin directories (``.deepctl``, - ``.deepctl/plugins``, the venv) under each home: what the plugin - loader's trust check reads. chmod and chown change neither mtime nor - size, so the stamp records them itself.""" +def _plugin_dirs() -> list[str]: + """dg's own plugin directories (``.deepctl``, ``.deepctl/plugins``, + the venv) under each home: the ones the plugin loader's trust check + reads the owner and mode of.""" homes = {os.path.expanduser("~")} real = _real_home() if real: homes.add(real) - trust: list[Any] = [] + paths: list[str] = [] for home in sorted(homes): dot = os.path.join(home, ".deepctl") plugins = os.path.join(dot, "plugins") - for path in (dot, plugins, os.path.join(plugins, "venv")): - try: - info = os.stat(path) - except OSError: - trust.append([path, None]) - continue - trust.append([path, info.st_mode, info.st_uid]) + paths += [dot, plugins, os.path.join(plugins, "venv")] + return paths + + +def _plugin_dir_trust() -> list[Any]: + """Owner and mode of ``_plugin_dirs``. chmod and chown change neither + mtime nor size, so the stamp records them itself.""" + trust: list[Any] = [] + for path in _plugin_dirs(): + try: + info = os.stat(path) + except OSError: + trust.append([path, None]) + continue + trust.append([path, info.st_mode, info.st_uid]) return trust @@ -600,15 +621,23 @@ def help_width() -> int: def key_inputs() -> dict[str, Any]: """The interpreter, files and environment variables root ``--help`` output depends on, as the key records them. ``run()`` takes this before - rendering, and ``write_help`` writes nothing unless it still holds.""" - files = [file_state(p) for p in plugin_state_paths()] - files.append(file_state(_default_config_file())) + rendering, and ``write_help`` writes nothing unless it still holds. + + Besides the plugin set and the user config, the key records the + project file in the working directory (``cwd_relative``, so a reader + looks in its own) and every input of the agent detection table + (``deepctl_core.agents.hard_inputs``): a key written by a person is + never served to an agent session, nor under other trust state.""" + env_names, marker_files = _agent_markers() + paths = [*plugin_state_paths(), *_plugin_dirs(), _default_config_file()] + files = [file_state(p) for p in dict.fromkeys([*paths, *marker_files])] + files.append({**file_state(PROJECT_FILE), "cwd_relative": True}) return { "executable": sys.executable, "files": files, "env": { name: os.environ.get(name, None) - for name in sorted(set(HELP_ENV_VARS) | set(_agent_markers())) + for name in sorted(set(HELP_ENV_VARS) | set(env_names)) }, } @@ -639,10 +668,12 @@ def help_key( } -def _agent_markers() -> tuple[str, ...]: - from deepctl_core.output import AI_AGENT_ENV_MARKERS +def _agent_markers() -> tuple[tuple[str, ...], tuple[str, ...]]: + """The variables (``DG_AGENT`` too) and files agent detection reads.""" + from deepctl_core.agents import OVERRIDE_VAR, hard_inputs - return AI_AGENT_ENV_MARKERS + env_names, files = hard_inputs() + return (*env_names, OVERRIDE_VAR), files def _default_config_file() -> str: diff --git a/src/deepctl/_cli.py b/src/deepctl/_cli.py index a5a899a6..5faaef33 100644 --- a/src/deepctl/_cli.py +++ b/src/deepctl/_cli.py @@ -100,7 +100,7 @@ def show(self, file: Any = None) -> None: # noqa: ARG002 (stderr/stdout) from deepctl_core import output install_hint = H_DEPENDENCY_REINSTALL.format(prog=self.prog) - if output.is_agentic() or output.is_ai_agent(): + if output.is_agentic() or output.agent_mode(): hint = A_DEPENDENCY.format( prog=self.prog, dependency=self.dependency, diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py index bf5a3daa..dfe97877 100644 --- a/tests/unit/test_cache.py +++ b/tests/unit/test_cache.py @@ -15,6 +15,8 @@ import jsonschema import pytest +from deepctl_core.agents import HARD_ENV_VARS + from deepctl import _cache, _cli, _manifest if TYPE_CHECKING: @@ -22,17 +24,7 @@ REPO = Path(__file__).resolve().parents[2] KEY_SCHEMA = REPO / "docs" / "contracts" / "help-cache-key.schema.json" -AGENT_VARS = ( - "AI_AGENT", - "CLAUDECODE", - "CLAUDE_CODE_ENTRYPOINT", - "CODEX_SANDBOX", - "CODEX_SANDBOX_NETWORK_DISABLED", - "CURSOR_AGENT", - "GEMINI_CLI", - "OR_APP_NAME", - "OR_SITE_URL", -) +AGENT_VARS = (*HARD_ENV_VARS, "DG_AGENT") class Homes: @@ -132,8 +124,8 @@ def test_clean_run_writes_a_private_entry_under_the_real_home( del schema["properties"]["files"]["maxItems"] jsonschema.validate(recorded, schema) assert recorded["prog"] == "dg" and recorded["isatty"] is False - assert not any("deepgram.yaml" in f["path"] for f in recorded["files"]) - assert not any("cwd_relative" in f for f in recorded["files"]) + project = [f for f in recorded["files"] if f.get("cwd_relative")] + assert project == [{"path": "deepgram.yaml", "absent": True, "cwd_relative": True}] for name in ( "DEEPCTL_TELEMETRY_DISABLED", "DEEPCTL_TELEMETRY", @@ -279,7 +271,10 @@ def test_key_size_in_a_bundle_configuration( key = _cache.help_key("dg", 80, False, b"") assert len(key["files"]) <= _cache.MAX_KEY_FILES == 32 assert _cache.MAX_KEY_ENV == 64 - assert len(key["env"]) == 21 + assert len(key["env"]) == len( + set(_cache.HELP_ENV_VARS) | set(AGENT_VARS) | {"PATH"} + ) + assert len(key["env"]) <= _cache.MAX_KEY_ENV def _poison(stamp: str, commands: list[dict[str, Any]]) -> None: @@ -606,6 +601,103 @@ def test_entry_names_are_ascii(self) -> None: ) +def _key_files() -> dict[str, dict[str, Any]]: + return {f["path"]: f for f in _cache.key_inputs()["files"]} + + +class TestKeyTrustAndAgentState: + """The key changes with every input that changes the help output or + the trust and agent decisions behind it.""" + + def test_plugin_dir_mode_and_owner_are_keyed( + self, homes: Homes, monkeypatch: pytest.MonkeyPatch + ) -> None: + plugins = homes.home / ".deepctl" / "plugins" + plugins.mkdir(parents=True, mode=0o700) + before = _cache.key_inputs() + for path in (homes.home / ".deepctl", plugins): + item = _key_files()[str(path)] + assert item["mode"] == path.stat().st_mode + assert item["uid"] == os.getuid() + mtime = plugins.stat().st_mtime_ns + os.chmod(plugins, 0o777) + os.utime(plugins, ns=(mtime, mtime)) + assert _cache.key_inputs() != before + assert stat.S_IMODE(_key_files()[str(plugins)]["mode"]) == 0o777 + + def test_config_mode_is_keyed( + self, homes: Homes, monkeypatch: pytest.MonkeyPatch + ) -> None: + config = Path(_cache._default_config_file()) + config.parent.mkdir(parents=True) + config.write_text("output: json\n") + os.chmod(config, 0o600) + before = _cache.key_inputs() + mtime = config.stat().st_mtime_ns + os.chmod(config, 0o666) + os.utime(config, ns=(mtime, mtime)) + assert _cache.key_inputs() != before + assert stat.S_IMODE(_key_files()[str(config)]["mode"]) == 0o666 + + def test_project_file_in_the_working_directory_is_keyed( + self, homes: Homes, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + work = tmp_path / "proj" + work.mkdir() + monkeypatch.chdir(work) + before = _key_files()["deepgram.yaml"] + assert before == {"path": "deepgram.yaml", "absent": True, "cwd_relative": True} + (work / "deepgram.yaml").write_text("api:\n base_url: x\n") + after = _key_files()["deepgram.yaml"] + assert after["cwd_relative"] is True and "absent" not in after + assert after["size"] == (work / "deepgram.yaml").stat().st_size + + def test_dangling_symlink_is_not_recorded_as_absent( + self, homes: Homes, tmp_path: Path + ) -> None: + link = tmp_path / "link" + link.symlink_to(tmp_path / "missing") + item = _cache.file_state(str(link)) + assert "absent" not in item + assert stat.S_ISLNK(item["mode"]) + + def test_every_agent_signal_input_is_keyed( + self, homes: Homes, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + from deepctl_core import agents + + marker = tmp_path / "devin-marker" + monkeypatch.setattr(agents, "DEVIN_MARKER", str(marker)) + names, files = agents.hard_inputs() + assert "PATH" in names and set(HARD_ENV_VARS) <= set(names) + assert files == ((str(marker),) if os.name == "posix" else ()) + inputs = _cache.key_inputs() + assert set(names) | {"DG_AGENT"} <= set(inputs["env"]) + assert inputs["env"]["PATH"] == os.environ.get("PATH") + if os.name == "posix": + assert _key_files()[str(marker)] == {"path": str(marker), "absent": True} + marker.write_text("") + assert _cache.key_inputs() != inputs + + def test_pi_path_changes_the_key( + self, homes: Homes, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("PATH", "/usr/bin:/bin") + before = _cache.key_inputs() + monkeypatch.setenv("PATH", "/home/x/.pi/agent:/usr/bin:/bin") + assert _cache.key_inputs()["env"] != before["env"] + + def test_key_validates_against_the_schema( + self, homes: Homes, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "deepgram.yaml").write_text("x: 1\n") + key = _cache.help_key("dg", 80, False, b"") + schema = json.loads(KEY_SCHEMA.read_text()) + del schema["properties"]["files"]["maxItems"] + jsonschema.validate(key, schema) + + class TestRoundFour: def test_prog_name_is_not_in_the_key_env( self, From 891f16ecb9208f74ad4b83ccc95e35974771cb3d Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 1 Nov 2026 15:23:38 -0500 Subject: [PATCH 05/25] feat(help): explain agent mode with dg help agent-mode "dg help agent-mode" prints, as plain text and offline: - the detection table, each row with the sources that document it; - which row this process matched, and why agent mode is on or off; - how a person removes a stray marker in sh/bash/zsh, fish, PowerShell and cmd, and DG_AGENT=0 for the markers that can't be unset; - the trust rules in agent sessions: a user config that others can change or another user owns is ignored, its API key unused and never saved to, DG_AGENT=0 doesn't change that, and the person runs chmod 600 on it. "dg help" is a new command group from the root distribution, added as manifest fragments with root entry points. The README points to the topic instead of listing a few variables. --- README.md | 10 +- .../tests/unit/test_plugin_manager.py | 5 +- pyproject.toml | 12 +- src/deepctl/_cli.py | 11 +- src/deepctl/_manifest.d/help-agent-mode.json | 137 ++++++++++ src/deepctl/_manifest.d/help.json | 23 ++ src/deepctl/help_topics/__init__.py | 29 +++ src/deepctl/help_topics/agent_mode.py | 223 ++++++++++++++++ .../fixtures/help_agent_mode/claudecode.txt | 79 ++++++ .../unit/fixtures/help_agent_mode/person.txt | 79 ++++++ tests/unit/test_help_agent_mode.py | 244 ++++++++++++++++++ tests/unit/test_lazy_group.py | 2 +- tests/unit/test_manifest.py | 11 +- 13 files changed, 848 insertions(+), 17 deletions(-) create mode 100644 src/deepctl/_manifest.d/help-agent-mode.json create mode 100644 src/deepctl/_manifest.d/help.json create mode 100644 src/deepctl/help_topics/__init__.py create mode 100644 src/deepctl/help_topics/agent_mode.py create mode 100644 tests/unit/fixtures/help_agent_mode/claudecode.txt create mode 100644 tests/unit/fixtures/help_agent_mode/person.txt create mode 100644 tests/unit/test_help_agent_mode.py diff --git a/README.md b/README.md index 55019992..919b99e9 100644 --- a/README.md +++ b/README.md @@ -409,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 diff --git a/packages/deepctl-core/tests/unit/test_plugin_manager.py b/packages/deepctl-core/tests/unit/test_plugin_manager.py index d18b3511..f2ed8809 100644 --- a/packages/deepctl-core/tests/unit/test_plugin_manager.py +++ b/packages/deepctl-core/tests/unit/test_plugin_manager.py @@ -2012,7 +2012,8 @@ def test_saved_allow_override_is_read_from_the_real_home_under_an_agent( class TestBuiltinMapMatchesInstalledPackages: """The reserved-name table matches what the first-party packages in this - repository declare (read from packages/*/pyproject.toml).""" + repository declare (read from packages/*/pyproject.toml and the root + pyproject.toml).""" def _declared(self, group_prefix): try: @@ -2027,6 +2028,8 @@ def _declared(self, group_prefix): ) if not files: pytest.skip("not running from a source checkout") + # The root distribution ships the help topics. + files.insert(0, parents[2].parent.parent / "pyproject.toml") found: dict = {} for path in files: data = tomllib.loads(path.read_text(encoding="utf-8")) diff --git a/pyproject.toml b/pyproject.toml index 41774642..0fe15cec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,12 +114,12 @@ deepctl = "deepctl.main:main" deepgram = "deepctl.main:main" dg = "deepctl.main:main" -# Entry points are now provided by individual command packages -# [project.entry-points."deepctl.commands"] -# transcribe = "deepctl.commands.transcribe:TranscribeCommand" -# projects = "deepctl.commands.projects:ProjectsCommand" -# usage = "deepctl.commands.usage:UsageCommand" -# login = "deepctl.commands.login:LoginCommand" +# Most commands come from the command packages; the help topics ship here. +[project.entry-points."deepctl.commands"] +help = "deepctl.help_topics:HelpCommand" + +[project.entry-points."deepctl.subcommands.help"] +agent-mode = "deepctl.help_topics.agent_mode:AgentModeCommand" [tool.setuptools] package-dir = { "" = "src" } diff --git a/src/deepctl/_cli.py b/src/deepctl/_cli.py index 5faaef33..1a5f823c 100644 --- a/src/deepctl/_cli.py +++ b/src/deepctl/_cli.py @@ -1121,9 +1121,16 @@ def __init__(self, config_path: str | None) -> None: def _is_help_run(args: list[str]) -> bool: - """Whether ``--help`` appears among the options (before any ``--``).""" + """Whether this run only prints help: ``--help`` among the options + (before any ``--``), or the ``help`` command (``dg help ``).""" words = args[: args.index("--")] if "--" in args else args - return "--help" in words + if "--help" in words: + return True + flag_opts = _root_flag_opts() + i = 0 + while i < len(args) and args[i].startswith("-") and args[i] != "--": + i += 2 if _takes_next_token(args[i], flag_opts) else 1 + return i < len(args) and args[i] == "help" def prog_name(default: str | None = None) -> str | None: diff --git a/src/deepctl/_manifest.d/help-agent-mode.json b/src/deepctl/_manifest.d/help-agent-mode.json new file mode 100644 index 00000000..24166e2a --- /dev/null +++ b/src/deepctl/_manifest.d/help-agent-mode.json @@ -0,0 +1,137 @@ +{ + "name": "agent-mode", + "parent": "help", + "module": "deepctl.help_topics.agent_mode", + "attr": "AgentModeCommand", + "dist": "deepctl", + "summary": "Explain agent mode", + "hidden": false, + "requires_auth": false, + "help": { + "name": "agent-mode", + "help": "Explain agent mode: how dg detects AI agents, what this shell matched, how to turn it off, and the config trust rules\n\nExamples:\n dg help agent-mode\n\n\nFor agents, use --agent-friendly.", + "short_help": "Explain agent mode", + "epilog": null, + "hidden": false, + "deprecated": false, + "no_args_is_help": false, + "options_metavar": "[OPTIONS]", + "params": [ + { + "class": "option", + "name": "agent_friendly", + "opts": [ + "--agent-friendly" + ], + "secondary_opts": [], + "type": { + "name": "boolean" + }, + "required": false, + "nargs": 1, + "metavar": null, + "eager": true, + "expose": false, + "multiple": false, + "is_flag": true, + "count": false, + "hidden": false, + "help": "Output machine-readable JSON metadata for this command and exit." + }, + { + "class": "option", + "name": "non_interactive", + "opts": [ + "--non-interactive" + ], + "secondary_opts": [], + "type": { + "name": "boolean" + }, + "required": false, + "nargs": 1, + "metavar": null, + "eager": false, + "expose": false, + "multiple": false, + "is_flag": true, + "count": false, + "hidden": false, + "help": "Skip interactive prompts; use defaults for any optional features." + }, + { + "class": "option", + "name": "output", + "opts": [ + "--output", + "-o" + ], + "secondary_opts": [], + "type": { + "name": "choice", + "choices": [ + "json", + "yaml", + "table", + "csv" + ], + "case_sensitive": false + }, + "required": false, + "nargs": 1, + "metavar": null, + "eager": false, + "expose": false, + "multiple": false, + "is_flag": false, + "count": false, + "hidden": false, + "help": "Output format (same as the global -o before the command)." + }, + { + "class": "option", + "name": "quiet", + "opts": [ + "--quiet", + "-q" + ], + "secondary_opts": [], + "type": { + "name": "boolean" + }, + "required": false, + "nargs": 1, + "metavar": null, + "eager": false, + "expose": false, + "multiple": false, + "is_flag": true, + "count": false, + "hidden": false, + "help": "Suppress non-essential output." + }, + { + "class": "option", + "name": "verbose", + "opts": [ + "--verbose", + "-v" + ], + "secondary_opts": [], + "type": { + "name": "boolean" + }, + "required": false, + "nargs": 1, + "metavar": null, + "eager": false, + "expose": false, + "multiple": false, + "is_flag": true, + "count": false, + "hidden": false, + "help": "Enable verbose output." + } + ] + } +} diff --git a/src/deepctl/_manifest.d/help.json b/src/deepctl/_manifest.d/help.json new file mode 100644 index 00000000..63a4b392 --- /dev/null +++ b/src/deepctl/_manifest.d/help.json @@ -0,0 +1,23 @@ +{ + "name": "help", + "module": "deepctl.help_topics", + "attr": "HelpCommand", + "dist": "deepctl", + "summary": "Help topics", + "hidden": false, + "group": true, + "requires_auth": false, + "help": { + "name": "help", + "help": "Explain how dg behaves, one topic at a time", + "short_help": "Help topics", + "epilog": null, + "hidden": false, + "deprecated": false, + "no_args_is_help": true, + "options_metavar": "[OPTIONS]", + "params": [], + "group": true, + "subcommand_metavar": "COMMAND [ARGS]..." + } +} diff --git a/src/deepctl/help_topics/__init__.py b/src/deepctl/help_topics/__init__.py new file mode 100644 index 00000000..3a030b43 --- /dev/null +++ b/src/deepctl/help_topics/__init__.py @@ -0,0 +1,29 @@ +"""``dg help ``: longer explanations that don't fit a command's +``--help``. Each topic is a subcommand that prints text to stdout, works +offline and reads no credentials.""" + +from __future__ import annotations + +from typing import ClassVar + +from deepctl_core.base_group_command import BaseGroupCommand + + +class HelpCommand(BaseGroupCommand): + """Help topics.""" + + name = "help" + help = "Explain how dg behaves, one topic at a time" + short_help = "Help topics" + + requires_auth = False + requires_project = False + ci_friendly = True + + examples: ClassVar[list[str]] = ["dg help agent-mode"] + agent_help = ( + "Longer help topics. 'agent-mode' explains how dg detects AI agents, " + "what agent mode changes, and the config trust rules in agent sessions." + ) + + invoke_without_command = False diff --git a/src/deepctl/help_topics/agent_mode.py b/src/deepctl/help_topics/agent_mode.py new file mode 100644 index 00000000..e0c95474 --- /dev/null +++ b/src/deepctl/help_topics/agent_mode.py @@ -0,0 +1,223 @@ +"""``dg help agent-mode``: how dg detects AI agents, what this process +matched, how a person turns it off, and the trust rules that stay on. + +Plain text on stdout, whatever the output format: it's documentation. +Offline, and it reads no credentials. +""" + +from __future__ import annotations + +import os +import sys +import textwrap +from typing import TYPE_CHECKING, Any, ClassVar + +from deepctl_core import agents +from deepctl_core.base_command import BaseCommand + +if TYPE_CHECKING: + from collections.abc import Mapping + + from deepctl_core import AuthManager, Config, DeepgramClient + +_SIGNAL_WIDTH = 31 +_CONDITION_WIDTH = 15 +_HERE_WIDTH = 5 + +_REMEDIES = ( + ("sh, bash, zsh", "unset {name}"), + ("fish", "set -e {name}"), + ("PowerShell", "Remove-Item Env:{name}"), + ("cmd", "set {name}="), +) + + +def _para(text: str, indent: str = " ") -> list[str]: + return textwrap.wrap( + text, + width=76, + initial_indent=indent, + subsequent_indent=indent, + break_long_words=False, + break_on_hyphens=False, + ) + + +def _row(signal: str, condition: str, here: str, sources: str) -> str: + return ( + f" {signal:<{_SIGNAL_WIDTH}} {condition:<{_CONDITION_WIDTH}} " + f"{here:<{_HERE_WIDTH}} {sources}" + ).rstrip() + + +def _status(detection: agents.Detection) -> list[str]: + marker = detection.marker + if detection.override == "1": + return [" Agent mode: on, because DG_AGENT=1."] + if detection.override == "0": + lines = [" Agent mode: off, because DG_AGENT=0."] + if marker is not None: + lines.append( + f" An AI-agent signal is still set ({marker.reason}), so the " + "config file rules below apply." + ) + return lines + if marker is not None: + verb = "matched" if marker.session else "is set" + return [f" Agent mode: on, because {marker.reason} {verb}."] + if detection.soft_reason is not None: + why = { + "CI": "CI is set", + "no terminal": "there is no terminal", + }.get(detection.soft_reason, f"of {detection.soft_reason}") + return [ + " Agent mode: off.", + f" JSON output and no questions, because {why}.", + ] + return [" Agent mode: off. No signal matched."] + + +def _owner_fix(config_path: str) -> list[str]: + """The command that fixes the config file's permissions. Windows has + none: dg doesn't check the owner or permissions there.""" + if sys.platform == "win32": + return _para( + "On Windows, dg doesn't check the file's owner or permissions.", + indent=" ", + ) + return [f" chmod 600 {config_path}"] + + +def render( + prog: str, + config_path: str, + env: Mapping[str, str] | None = None, + argv: list[str] | None = None, +) -> str: + """The topic's text for this process (or the given environment).""" + detection = agents.detect(env, argv) + env_map: Mapping[str, str] = os.environ if env is None else env + marker = detection.marker + name = marker.reason if marker is not None and not marker.session else "NAME" + + lines = [ + "AGENT MODE", + "", + *_para( + f"{prog} runs in agent mode when an AI coding agent runs it. In " + f"agent mode {prog} writes JSON unless you pick a format with -o, " + "never asks questions, shows no update or setup notices, and " + "starts each line it writes to stderr with ERROR:, WARN:, INFO:, " + "HINT:, OK: or DEPRECATED:." + ), + "", + "HOW IT DECIDES", + "", + " 1. DG_AGENT=1 turns agent mode on. DG_AGENT=0 turns it off.", + " 2. Otherwise the first signal below that matches turns it on.", + " 3. Otherwise CI=true, --non-interactive, --agent-friendly, or no", + " terminal at all (stdin and stdout not terminals, TERM unset or", + " dumb) switch to JSON output and turn questions off.", + " NO_COLOR only turns color off.", + "", + "SIGNALS", + "", + _row("Signal", "Condition", "Here", "Source"), + ] + for signal in agents.HARD_SIGNALS: + matched = signal.match(env_map) is not None + lines.append( + _row( + signal.name, + signal.condition, + "yes" if matched else "-", + ", ".join(signal.sources), + ) + ) + lines += [ + "", + ' "set" means present and not empty.', + "", + "THIS PROCESS", + "", + *_status(detection), + "", + "IF YOU ARE A PERSON", + "", + *_para( + "If a signal is set in your own shell by mistake, remove it from " + "your environment, and from the shell profile, alias or wrapper " + "that sets it:" + ), + "", + ] + lines += [ + f" {shell:<15} {command.format(name=name)}" for shell, command in _REMEDIES + ] + lines += [ + "", + *_para( + f"For {agents.DEVIN_MARKER} or a .pi/agent entry in PATH, run with " + "DG_AGENT=0 for human output. The config file rules below still apply." + ), + "", + "CONFIG FILES IN AGENT SESSIONS", + "", + *_para( + f"While any signal above is set, {prog} won't read the user config " + f"({config_path}), or the API key in it, if other users can " + "change the file or another user owns it. It won't save to that " + "file either. DG_AGENT=0 doesn't change this. To fix the file, " + "make sure you own it, then run this yourself, not through an " + "agent:" + ), + "", + *_owner_fix(config_path), + "", + "SOURCES", + "", + ] + width = max(len(key) for key in agents.SOURCES) + lines += [f" {key:<{width}} {url}" for key, url in agents.SOURCES.items()] + return "\n".join(lines) + "\n" + + +class AgentModeCommand(BaseCommand): + """Explain agent mode.""" + + name = "agent-mode" + help = ( + "Explain agent mode: how dg detects AI agents, what this shell " + "matched, how to turn it off, and the config trust rules" + ) + short_help = "Explain agent mode" + + requires_auth = False + requires_project = False + ci_friendly = True + + examples: ClassVar[list[str]] = ["dg help agent-mode"] + agent_help = ( + "Print, as text, the AI-agent detection table with sources, which " + "signals this process matched, per-shell remedies, and the config " + "trust rules in agent sessions. Offline." + ) + + def handle( + self, + config: Config, + auth_manager: AuthManager, + client: DeepgramClient, + **kwargs: Any, + ) -> Any: + del config, auth_manager, client, kwargs + from deepctl_core.hosts import user_config_display + from deepctl_core.output import invoked_prog + + text = render(invoked_prog(), user_config_display()) + try: + sys.stdout.write(text) + sys.stdout.flush() + except (BrokenPipeError, ValueError): + pass + return None diff --git a/tests/unit/fixtures/help_agent_mode/claudecode.txt b/tests/unit/fixtures/help_agent_mode/claudecode.txt new file mode 100644 index 00000000..9690b76d --- /dev/null +++ b/tests/unit/fixtures/help_agent_mode/claudecode.txt @@ -0,0 +1,79 @@ +AGENT MODE + + dg runs in agent mode when an AI coding agent runs it. In agent mode dg + writes JSON unless you pick a format with -o, never asks questions, shows + no update or setup notices, and starts each line it writes to stderr with + ERROR:, WARN:, INFO:, HINT:, OK: or DEPRECATED:. + +HOW IT DECIDES + + 1. DG_AGENT=1 turns agent mode on. DG_AGENT=0 turns it off. + 2. Otherwise the first signal below that matches turns it on. + 3. Otherwise CI=true, --non-interactive, --agent-friendly, or no + terminal at all (stdin and stdout not terminals, TERM unset or + dumb) switch to JSON output and turn questions off. + NO_COLOR only turns color off. + +SIGNALS + + Signal Condition Here Source + AI_AGENT set - gh, vercel, vscode + AGENT amp or goose - gh, goose + GOOSE_TERMINAL set - goose + CLAUDECODE set yes gh, vercel + CLAUDE_CODE set - gh, vercel + CLAUDE_CODE_IS_COWORK set - gh + CLAUDE_CODE_ENTRYPOINT set - claude-agent-sdk + CODEX_SANDBOX set - gh, vercel + CODEX_CI set - gh, vercel + CODEX_THREAD_ID set - gh, vercel + CODEX_SANDBOX_NETWORK_DISABLED set - codex + GEMINI_CLI set - gh, vercel + COPILOT_CLI set - gh + OPENCODE set - gh + ANTIGRAVITY_AGENT set - gh, vercel + AUGMENT_AGENT set - gh, vercel + CURSOR_AGENT set - gh, vercel + CURSOR_EXTENSION_HOST_ROLE agent-exec - gh, vercel + OR_APP_NAME / OR_SITE_URL Aider / aider - aider + /opt/.devin exists - vercel + PATH has .pi/agent - gh + + "set" means present and not empty. + +THIS PROCESS + + Agent mode: on, because CLAUDECODE is set. + +IF YOU ARE A PERSON + + If a signal is set in your own shell by mistake, remove it from your + environment, and from the shell profile, alias or wrapper that sets it: + + sh, bash, zsh unset CLAUDECODE + fish set -e CLAUDECODE + PowerShell Remove-Item Env:CLAUDECODE + cmd set CLAUDECODE= + + For /opt/.devin or a .pi/agent entry in PATH, run with DG_AGENT=0 for + human output. The config file rules below still apply. + +CONFIG FILES IN AGENT SESSIONS + + While any signal above is set, dg won't read the user config + (~/.config/deepctl/config.yaml), or the API key in it, if other users can + change the file or another user owns it. It won't save to that file + either. DG_AGENT=0 doesn't change this. To fix the file, make sure you own + it, then run this yourself, not through an agent: + + chmod 600 ~/.config/deepctl/config.yaml + +SOURCES + + gh https://github.com/cli/cli/blob/trunk/internal/agents/detect.go + vercel https://github.com/vercel/vercel/blob/main/packages/detect-agent/src/index.ts + vscode https://github.com/microsoft/vscode/blob/main/src/vs/platform/chat/common/aiAgentEnv.ts + goose https://github.com/block/goose/blob/main/documentation/docs/guides/environment-variables.md + codex https://github.com/openai/codex/blob/main/codex-rs/core/src/spawn.rs + aider https://github.com/Aider-AI/aider/blob/main/aider/llm.py + claude-agent-sdk https://github.com/anthropics/claude-agent-sdk-python/blob/main/src/claude_agent_sdk/_internal/transport/subprocess_cli.py diff --git a/tests/unit/fixtures/help_agent_mode/person.txt b/tests/unit/fixtures/help_agent_mode/person.txt new file mode 100644 index 00000000..517f029b --- /dev/null +++ b/tests/unit/fixtures/help_agent_mode/person.txt @@ -0,0 +1,79 @@ +AGENT MODE + + dg runs in agent mode when an AI coding agent runs it. In agent mode dg + writes JSON unless you pick a format with -o, never asks questions, shows + no update or setup notices, and starts each line it writes to stderr with + ERROR:, WARN:, INFO:, HINT:, OK: or DEPRECATED:. + +HOW IT DECIDES + + 1. DG_AGENT=1 turns agent mode on. DG_AGENT=0 turns it off. + 2. Otherwise the first signal below that matches turns it on. + 3. Otherwise CI=true, --non-interactive, --agent-friendly, or no + terminal at all (stdin and stdout not terminals, TERM unset or + dumb) switch to JSON output and turn questions off. + NO_COLOR only turns color off. + +SIGNALS + + Signal Condition Here Source + AI_AGENT set - gh, vercel, vscode + AGENT amp or goose - gh, goose + GOOSE_TERMINAL set - goose + CLAUDECODE set - gh, vercel + CLAUDE_CODE set - gh, vercel + CLAUDE_CODE_IS_COWORK set - gh + CLAUDE_CODE_ENTRYPOINT set - claude-agent-sdk + CODEX_SANDBOX set - gh, vercel + CODEX_CI set - gh, vercel + CODEX_THREAD_ID set - gh, vercel + CODEX_SANDBOX_NETWORK_DISABLED set - codex + GEMINI_CLI set - gh, vercel + COPILOT_CLI set - gh + OPENCODE set - gh + ANTIGRAVITY_AGENT set - gh, vercel + AUGMENT_AGENT set - gh, vercel + CURSOR_AGENT set - gh, vercel + CURSOR_EXTENSION_HOST_ROLE agent-exec - gh, vercel + OR_APP_NAME / OR_SITE_URL Aider / aider - aider + /opt/.devin exists - vercel + PATH has .pi/agent - gh + + "set" means present and not empty. + +THIS PROCESS + + Agent mode: off. No signal matched. + +IF YOU ARE A PERSON + + If a signal is set in your own shell by mistake, remove it from your + environment, and from the shell profile, alias or wrapper that sets it: + + sh, bash, zsh unset NAME + fish set -e NAME + PowerShell Remove-Item Env:NAME + cmd set NAME= + + For /opt/.devin or a .pi/agent entry in PATH, run with DG_AGENT=0 for + human output. The config file rules below still apply. + +CONFIG FILES IN AGENT SESSIONS + + While any signal above is set, dg won't read the user config + (~/.config/deepctl/config.yaml), or the API key in it, if other users can + change the file or another user owns it. It won't save to that file + either. DG_AGENT=0 doesn't change this. To fix the file, make sure you own + it, then run this yourself, not through an agent: + + chmod 600 ~/.config/deepctl/config.yaml + +SOURCES + + gh https://github.com/cli/cli/blob/trunk/internal/agents/detect.go + vercel https://github.com/vercel/vercel/blob/main/packages/detect-agent/src/index.ts + vscode https://github.com/microsoft/vscode/blob/main/src/vs/platform/chat/common/aiAgentEnv.ts + goose https://github.com/block/goose/blob/main/documentation/docs/guides/environment-variables.md + codex https://github.com/openai/codex/blob/main/codex-rs/core/src/spawn.rs + aider https://github.com/Aider-AI/aider/blob/main/aider/llm.py + claude-agent-sdk https://github.com/anthropics/claude-agent-sdk-python/blob/main/src/claude_agent_sdk/_internal/transport/subprocess_cli.py diff --git a/tests/unit/test_help_agent_mode.py b/tests/unit/test_help_agent_mode.py new file mode 100644 index 00000000..19bbfa81 --- /dev/null +++ b/tests/unit/test_help_agent_mode.py @@ -0,0 +1,244 @@ +"""``dg help agent-mode``: the detection table with sources, what this +process matched, per-shell remedies and the trust rules. Offline text.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from deepctl_core import agents + +from deepctl.help_topics import agent_mode + +GOLDEN = Path(__file__).parent / "fixtures" / "help_agent_mode" +CONFIG = "~/.config/deepctl/config.yaml" +BASE_ENV = {"PATH": "/usr/local/bin:/usr/bin:/bin", "TERM": "xterm"} + + +@pytest.fixture(autouse=True) +def _no_devin(monkeypatch, tmp_path): + monkeypatch.setattr(agents, "DEVIN_MARKER", "/opt/.devin") + monkeypatch.setattr(agents, "_devin", lambda env: None) + # The HardSignal rows hold the function itself; swap the row too. + rows = tuple( + signal._replace(match=lambda env: None) + if signal.name == "/opt/.devin" + else signal + for signal in agents.HARD_SIGNALS + ) + monkeypatch.setattr(agents, "HARD_SIGNALS", rows) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + + +def _render(**env: str) -> str: + return agent_mode.render("dg", CONFIG, {**BASE_ENV, **env}, []) + + +@pytest.mark.parametrize( + ("golden", "env"), + [ + ("claudecode.txt", {"CLAUDECODE": "1"}), + ("person.txt", {}), + ], +) +def test_golden(golden: str, env: dict[str, str]) -> None: + expected = (GOLDEN / golden).read_text(encoding="utf-8") + assert _render(**env) == expected + + +def test_every_row_and_source_is_listed() -> None: + text = _render() + for signal in agents.HARD_SIGNALS: + assert signal.name in text + for key, url in agents.SOURCES.items(): + assert f" {key}" in text and url in text + + +@pytest.mark.parametrize( + ("env", "row"), + [ + ({"CLAUDECODE": "1"}, "CLAUDECODE "), + ({"CLAUDECODE": " "}, "CLAUDECODE "), + ({"AGENT": "goose"}, "AGENT "), + ({"OR_SITE_URL": "https://aider.chat"}, "OR_APP_NAME / OR_SITE_URL"), + ({"PATH": "/h/.pi/agent:/usr/bin"}, "PATH "), + ], +) +def test_matched_row_says_yes(env: dict[str, str], row: str) -> None: + here = { + line[2:33].strip(): line[50:55].strip() + for line in _render(**env).splitlines() + if len(line) > 50 and line[2:33].strip() in _ROW_NAMES + } + assert here[row.strip()] == "yes" + assert [name for name, mark in here.items() if mark == "yes"] == [row.strip()] + + +_ROW_NAMES = {signal.name for signal in agents.HARD_SIGNALS} + + +def test_remedies_name_the_matched_variable() -> None: + text = _render(GEMINI_CLI="1") + for line in ( + "sh, bash, zsh unset GEMINI_CLI", + "fish set -e GEMINI_CLI", + "PowerShell Remove-Item Env:GEMINI_CLI", + "cmd set GEMINI_CLI=", + ): + assert f" {line}" in text + + +def test_dg_agent_0_still_reports_the_marker_and_trust() -> None: + text = " ".join(_render(CLAUDECODE="1", DG_AGENT="0").split()) + assert "Agent mode: off, because DG_AGENT=0." in text + assert "An AI-agent signal is still set (CLAUDECODE)" in text + assert "DG_AGENT=0 doesn't change this." in text + assert f"chmod 600 {CONFIG}" in text + + +def test_dg_agent_1() -> None: + assert "Agent mode: on, because DG_AGENT=1." in _render(DG_AGENT="1") + + +def test_session_rows() -> None: + text = _render(PATH="/h/.pi/agent:/usr/bin") + assert "Agent mode: on, because a .pi/agent entry in PATH matched." in text + assert "unset NAME" in text + + +def test_ci_is_reported_as_automation() -> None: + text = _render(CI="true") + assert "Agent mode: off." in text + assert "JSON output and no questions, because CI is set." in text + + +ENTRY = ( + "import sys; sys.argv[0] = '/opt/bin/dg'; " + "from deepctl.main import main; sys.exit(main())" +) + + +def _env(home: Path, **extra: str) -> dict[str, str]: + env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("DEEPGRAM_", "PYTHON", "DEEPCTL_")) + and k not in (*agents.HARD_ENV_VARS, "DG_AGENT") + } + env.update( + HOME=str(home), + XDG_CONFIG_HOME=str(home / ".config"), + DEEPCTL_TELEMETRY_DISABLED="1", + DEEPCTL_TELEMETRY_DSN="http://k@127.0.0.1:9/1", + DEEPCTL_NO_UPDATE_CHECK="1", + DEEPCTL_PROG_NAME="dg", + PATH="/usr/local/bin:/usr/bin:/bin", + ) + env.update(extra) + return env + + +# Modules that only a background check or a connection would load. +CHECK_MODULES = ( + "httpx", + "websockets", + "deepctl_cmd_update.startup_check", + "deepctl_cmd_update.plugin_update_check", + "deepctl_cmd_skills.startup_check", +) +NETMODS = ( + "import atexit, sys\n" + "atexit.register(lambda: sys.stderr.write('NETMODS=' + ','.join(" + f"sorted(m for m in {CHECK_MODULES!r} " + "if m in sys.modules)) + '\\n'))\n" + ENTRY.replace("; ", "\n") +) + + +@pytest.mark.parametrize("args", [[], ["-o", "json"]]) +def test_command_prints_text_offline(tmp_path: Path, args: list[str]) -> None: + proc = subprocess.run( + [sys.executable, "-c", NETMODS, *args, "help", "agent-mode"], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + env=_env(tmp_path, CLAUDECODE="1"), + timeout=60, + cwd=tmp_path, + ) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.startswith("AGENT MODE\n") + assert "CONFIG FILES IN AGENT SESSIONS" in proc.stdout + assert "Agent mode: on, because CLAUDECODE is set." in proc.stdout + # No background check and no network stack: nothing to connect with. + assert "NETMODS=\n" in proc.stderr, proc.stderr + + +@pytest.mark.skipif(os.name != "posix", reason="needs a pseudo-terminal") +def test_person_at_a_terminal_starts_no_checks(tmp_path: Path) -> None: + """A person's terminal, with no opt-out: the update, plugin-update + and skills checks would all run for another command, never for a + help topic.""" + import pty + + env = _env(tmp_path, TERM="xterm") + env.pop("DEEPCTL_NO_UPDATE_CHECK") + from deepctl_cmd_update.startup_check import _CI_ENV_VARS + + for name in _CI_ENV_VARS: + env.pop(name, None) + controller, terminal = pty.openpty() + try: + proc = subprocess.Popen( + [sys.executable, "-c", NETMODS, "help", "agent-mode"], + stdin=terminal, + stdout=terminal, + stderr=terminal, + env=env, + cwd=tmp_path, + ) + os.close(terminal) + terminal = -1 + chunks = [] + while True: + try: + chunk = os.read(controller, 65536) + except OSError: # EIO: the child closed its side + break + if not chunk: + break + chunks.append(chunk) + assert proc.wait(timeout=60) == 0 + finally: + os.close(controller) + if terminal >= 0: + os.close(terminal) + out = b"".join(chunks).decode("utf-8", "replace").replace("\r\n", "\n") + assert "Agent mode: off." in out, out + assert "NETMODS=\n" in out, out + + +def test_help_lists_the_topic(tmp_path: Path) -> None: + proc = subprocess.run( + [sys.executable, "-c", ENTRY, "help", "--help"], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + env=_env(tmp_path), + timeout=60, + cwd=tmp_path, + ) + assert proc.returncode == 0, proc.stderr + assert "agent-mode" in proc.stdout + + +def test_windows_gets_no_chmod(monkeypatch: pytest.MonkeyPatch) -> None: + import sys + + monkeypatch.setattr(sys, "platform", "win32") + text = " ".join(_render(CLAUDECODE="1").split()) + assert "chmod" not in text + assert "On Windows, dg doesn't check the file's owner or permissions." in text diff --git a/tests/unit/test_lazy_group.py b/tests/unit/test_lazy_group.py index 09c6f013..acd96272 100644 --- a/tests/unit/test_lazy_group.py +++ b/tests/unit/test_lazy_group.py @@ -412,7 +412,7 @@ def test_rewrite( assert debug_with_extra._expand_command_word(ctx, args) == expected def test_groups_come_from_the_manifest(self) -> None: - assert _manifest.groups() == {"debug", "plugin", "skills"} + assert _manifest.groups() == {"debug", "help", "plugin", "skills"} def test_hot_paths_never_scan_subcommand_entry_points( self, diff --git a/tests/unit/test_manifest.py b/tests/unit/test_manifest.py index d78e1963..0e3e6345 100644 --- a/tests/unit/test_manifest.py +++ b/tests/unit/test_manifest.py @@ -52,7 +52,7 @@ class TestRepoManifest: def test_assembles(self) -> None: manifest = assemble(str(FRAGMENTS)) assert manifest["manifest_version"] == 1 - assert len([p for p in manifest["commands"] if " " not in p]) == 24 + assert len([p for p in manifest["commands"] if " " not in p]) == 25 def test_runtime_reads_the_same_manifest(self) -> None: assert _manifest.load() == assemble(str(FRAGMENTS)) @@ -129,7 +129,9 @@ def test_every_first_party_entry_point_has_a_fragment(self) -> None: import tomli as tomllib declared: dict[str, str] = {} - for pyproject in sorted(REPO.glob("packages/deepctl-cmd-*/pyproject.toml")): + pyprojects = [REPO / "pyproject.toml"] + pyprojects += sorted(REPO.glob("packages/deepctl-cmd-*/pyproject.toml")) + for pyproject in pyprojects: data = tomllib.loads(pyproject.read_text(encoding="utf-8")) groups = data.get("project", {}).get("entry-points", {}) for group, entries in groups.items(): @@ -271,9 +273,10 @@ def test_every_fragment_has_a_help_block(self) -> None: assert all("help" in f for f in load_fragments(str(FRAGMENTS))) def test_help_stays_out_of_the_manifest(self) -> None: - text = _manifest_build.dumps(assemble(str(FRAGMENTS))) + manifest = assemble(str(FRAGMENTS)) + text = _manifest_build.dumps(manifest) assert '"params"' not in text - assert '"help"' not in text + assert all("help" not in entry for entry in manifest["commands"].values()) def _check(self, directory: Path) -> subprocess.CompletedProcess[str]: return subprocess.run( From 155263c4322ed901e55c81cc9e5e23571d8e3c9f Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Sun, 1 Nov 2026 15:24:10 -0500 Subject: [PATCH 06/25] fix(cli): keep warnings and notices off stdout Every notice now goes to stderr, prints once, and names the program the way it was invoked: - A person at a terminal whose environment carries an AI-agent marker gets one INFO line saying why dg is in agent mode and pointing to "dg help agent-mode". It names no shell command, and it never prints when DG_AGENT decides the mode or without a terminal. - The skills prompt, the update notice and the plugin update notice no longer show in agent or automation sessions, use colour only on a person's terminal (NO_COLOR and TERM=dumb turn it off, FORCE_COLOR turns it on), and name the invoked program. - Copying a legacy config is reported through the config notice queue, so it follows the INFO:/WARN: prefixes and --quiet. - dg ffprobe --path reports a bad path through the error console, with the ERROR: prefix in agent mode, once. - The pending-login profile error names the invoked program. - dg --version prints "dg, version ...", "deepgram, version ..." or "deepctl, version ..." as invoked (DEEPCTL_PROG_NAME first, as for every other message), on the fast path and the Click path alike. Changelog: "--version now prints the name you ran (dg, deepgram or deepctl)". The bug report template's example follows. New subprocess tests pin -o json stdout to one JSON document with a malformed or unreadable config, with the warning once on stderr. --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .../src/deepctl_cmd_ffprobe/command.py | 10 +- .../src/deepctl_cmd_login/command.py | 5 +- .../tests/unit/test_login_device_agent.py | 13 + .../src/deepctl_cmd_skills/startup_check.py | 12 +- .../tests/unit/test_skills_command.py | 29 ++ .../src/deepctl_cmd_update/check_cache.py | 116 +++++++- .../deepctl_cmd_update/plugin_update_check.py | 31 +- .../src/deepctl_cmd_update/startup_check.py | 26 +- .../tests/unit/test_notices.py | 102 +++++++ .../tests/unit/test_plugin_update_check.py | 10 +- .../tests/unit/test_startup_check.py | 138 +++++++++ .../deepctl-core/src/deepctl_core/agents.py | 27 ++ .../deepctl-core/src/deepctl_core/config.py | 25 +- .../deepctl-core/tests/unit/test_agents.py | 39 +++ .../tests/unit/test_config_login.py | 5 +- src/deepctl/_cli.py | 44 ++- src/deepctl/main.py | 25 +- tests/unit/test_lazy_group.py | 2 +- tests/unit/test_stream_discipline.py | 280 ++++++++++++++++++ 20 files changed, 876 insertions(+), 65 deletions(-) create mode 100644 packages/deepctl-cmd-update/tests/unit/test_notices.py create mode 100644 tests/unit/test_stream_discipline.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b0b920e0..60ba77b4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/packages/deepctl-cmd-ffprobe/src/deepctl_cmd_ffprobe/command.py b/packages/deepctl-cmd-ffprobe/src/deepctl_cmd_ffprobe/command.py index 4abe53f7..21941857 100644 --- a/packages/deepctl-cmd-ffprobe/src/deepctl_cmd_ffprobe/command.py +++ b/packages/deepctl-cmd-ffprobe/src/deepctl_cmd_ffprobe/command.py @@ -97,9 +97,7 @@ def handle( reset = kwargs.get("reset", False) if path and reset: - status_console.print( - "[red]Error:[/red] Cannot use --path and --reset together" - ) + print_error("Cannot use --path and --reset together") return BaseResult( status="error", message="Cannot use --path and --reset together", @@ -126,13 +124,15 @@ def handle( def _handle_set_path(self, config: Config, path: str) -> BaseResult: """Store a custom ffprobe path.""" + from rich.markup import escape + # Validate the path if not os.path.isfile(path): - status_console.print(f"[red]Error:[/red] File not found: {path}") + print_error(f"File not found: {escape(path)}") return BaseResult(status="error", message=f"File not found: {path}") if not os.access(path, os.X_OK): - status_console.print(f"[red]Error:[/red] File is not executable: {path}") + print_error(f"File is not executable: {escape(path)}") return BaseResult(status="error", message=f"File is not executable: {path}") # Store in config. Only absolute paths are used, so resolve it now. diff --git a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py index c05eead3..8869937b 100644 --- a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py +++ b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py @@ -1140,8 +1140,9 @@ def _complete( # The pending login was confirmed for its own profile. raise click.UsageError( f'The pending login is for profile "{msg.display(pending.profile)}". ' - "Run --complete without --profile, or start a new login for " - f'"{msg.display(explicit)}" with dg login --profile.' + f"Run {shown} login --complete without --profile, or start a new " + f'login for "{msg.display(explicit)}" with {shown} login --profile ' + f"{msg.display(shlex.quote(explicit))}." ) config.profile = pending.profile interval = pending.interval diff --git a/packages/deepctl-cmd-login/tests/unit/test_login_device_agent.py b/packages/deepctl-cmd-login/tests/unit/test_login_device_agent.py index b5d1eca4..527da2dd 100644 --- a/packages/deepctl-cmd-login/tests/unit/test_login_device_agent.py +++ b/packages/deepctl-cmd-login/tests/unit/test_login_device_agent.py @@ -747,6 +747,19 @@ def test_complete_with_another_profile_is_a_usage_error(env): assert result.code == "auth_pending" +def test_other_profile_error_suggests_commands_that_run(env): + import click + + run(env, profile="work") + with pytest.raises(click.UsageError) as err: + run(env, complete=True, profile="my team") + assert err.value.message == ( + 'The pending login is for profile "work". Run dg login --complete ' + 'without --profile, or start a new login for "my team" with dg login ' + "--profile 'my team'." + ) + + def test_complete_with_save_base_url_is_a_usage_error(env): import click diff --git a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/startup_check.py b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/startup_check.py index 35deb9c2..ac295fe3 100644 --- a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/startup_check.py +++ b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/startup_check.py @@ -153,12 +153,18 @@ def print_pending_notification() -> None: if thread is None or thread.is_alive(): return + from deepctl_core.output import invoked_prog, stderr_color + if _result.get("should_prompt"): try: - sys.stderr.write( - "\033[36mAI coding assistants detected. " - "Run 'deepctl skills install' to set up integration.\033[0m\n" + line = ( + "Found AI coding agents on this computer. " + f"Run {invoked_prog()} skills install to add Deepgram skills " + "to them." ) + if stderr_color(): + line = f"\033[36m{line}\033[0m" + sys.stderr.write(line + "\n") except (BrokenPipeError, OSError, ValueError): # The output stream closed (e.g. an MCP host disconnected stdio # after `dg mcp` finished). The prompt is purely advisory. diff --git a/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py b/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py index 4567d8e3..4abf3b5f 100644 --- a/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py +++ b/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py @@ -6,6 +6,7 @@ import os import shutil import sys +import threading import time from pathlib import Path from unittest.mock import MagicMock, patch @@ -959,6 +960,34 @@ def test_suppressed_in_ci(self, mock_ci): startup_check.check_and_notify(quiet=False) assert startup_check._thread is None + @pytest.mark.parametrize( + "env", [{"AI_AGENT": "claude-code"}, {"CI": "true"}, {"DG_AGENT": "1"}] + ) + def test_suppressed_in_agent_sessions(self, monkeypatch, env): + from deepctl_cmd_skills import startup_check + + monkeypatch.setattr(startup_check, "_is_ci", lambda: False) + monkeypatch.setattr(startup_check, "_is_oneshot", lambda: False) + monkeypatch.setattr(startup_check, "_already_prompted", lambda: False) + for name, value in env.items(): + monkeypatch.setenv(name, value) + startup_check.check_and_notify(quiet=False) + assert startup_check._thread is None + + def test_nag_names_the_program_without_forced_colour(self, monkeypatch, capsys): + from deepctl_cmd_skills import startup_check + + monkeypatch.setenv("DEEPCTL_PROG_NAME", "dg") + monkeypatch.setattr(startup_check, "_result", {"should_prompt": True}) + thread = threading.Thread(target=lambda: None) + thread.start() + monkeypatch.setattr(startup_check, "_thread", thread) + startup_check.print_pending_notification() + assert capsys.readouterr().err == ( + "Found AI coding agents on this computer. " + "Run dg skills install to add Deepgram skills to them.\n" + ) + def test_suppressed_when_quiet(self): from deepctl_cmd_skills import startup_check diff --git a/packages/deepctl-cmd-update/src/deepctl_cmd_update/check_cache.py b/packages/deepctl-cmd-update/src/deepctl_cmd_update/check_cache.py index ecf7ef8a..d9ab5e2d 100644 --- a/packages/deepctl-cmd-update/src/deepctl_cmd_update/check_cache.py +++ b/packages/deepctl-cmd-update/src/deepctl_cmd_update/check_cache.py @@ -2,8 +2,8 @@ Each check has one small JSON file there, ``update-check.json`` for dg itself and ``plugin-update-check.json`` for plugins, holding ``checked_at`` (Unix -time), ``ok``, ``latest`` and ``source``, plus ``notified_at`` once a notice -was shown. A check writes its file just before it sends its request and again +time), ``ok``, ``latest`` and ``source``, plus ``notified_at`` and +``notified_latest`` once a notice was shown (``claim_notice``). A check writes its file just before it sends its request and again when it gets an answer, so a check that fails or never finishes still waits a full interval before the next attempt, while a run that ends before the request goes out doesn't use up the interval. @@ -113,19 +113,107 @@ def _save(name: str, state: dict[str, Any]) -> None: def write(name: str, *, ok: bool, latest: str | None, source: str) -> None: - """Save the state of a check that ran now. Never raises.""" - _save( - name, - {"checked_at": time.time(), "ok": ok, "latest": latest, "source": source}, - ) - - -def mark_notified(name: str) -> None: - """Record that the notice for the saved state was shown now.""" - state = read(name) - if state is not None: - state["notified_at"] = time.time() + """Save the state of a check that ran now. Never raises. When the + last notice was shown (``notified_at``, ``notified_latest``) carries + over, so a check that finishes right after another process showed the + notice doesn't make it show again. The read and save run under the + ``claim_notice`` lock, so a claim can't land in between and be lost; + if the lock stays taken for ``WRITE_LOCK_WAIT_SECONDS`` (a process + that died holding it), the state is saved without it.""" + directory = _directory() + lock = None + if directory is not None: + path = os.path.join(directory, f".{name}.lock") + give_up = time.monotonic() + WRITE_LOCK_WAIT_SECONDS + while (taken := _take_lock(path)) is False and time.monotonic() < give_up: + time.sleep(0.01) + lock = path if taken else None + try: + state: dict[str, Any] = { + "checked_at": time.time(), + "ok": ok, + "latest": latest, + "source": source, + } + previous = read(name) or {} + for key in ("notified_at", "notified_latest"): + if key in previous: + state[key] = previous[key] _save(name, state) + finally: + if lock is not None: + _release_lock(lock) + + +def notice_due(state: dict[str, Any] | None, latest: str, interval: float) -> bool: + """Whether the notice for ``latest`` may show: it wasn't shown for + that version within ``interval``.""" + if (state or {}).get("notified_latest") != latest: + return True + return elapsed(notified_at(state), interval) + + +LOCK_STALE_SECONDS = 60.0 +WRITE_LOCK_WAIT_SECONDS = 0.5 + + +def _take_lock(lock: str) -> bool | None: + """Create ``lock`` (``O_EXCL``): True if this process now holds it, + False if another does, None if it can't be created at all.""" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + try: + os.close(os.open(lock, flags, 0o600)) + except FileExistsError: + return False + except OSError: + return None + return True + + +def _release_lock(lock: str) -> None: + try: + os.unlink(lock) + except OSError: + pass + + +def claim_notice(name: str, latest: str, interval: float) -> bool: + """Whether this process shows the notice for ``latest`` now; if so, + it is recorded as shown. Of several dg processes that finish together, + at most one gets True. + + The claim is a lock file next to the state file (``..lock``, + created with ``O_EXCL``), held while the state is read again and the + notice recorded. A process that finds the lock taken shows nothing. + A lock older than a minute was left by a process that died: it is + removed, and this run shows nothing. With no usable cache directory, + or a lock that can't be created there, there is nothing to coordinate + with, and the notice shows.""" + directory = _directory() + if directory is None: + return True + lock = os.path.join(directory, f".{name}.lock") + taken = _take_lock(lock) + if taken is None: + return True # no lock can be made here: showing twice beats never + if not taken: + try: + if time.time() - os.lstat(lock).st_mtime > LOCK_STALE_SECONDS: + os.unlink(lock) + except OSError: + pass + return False + try: + state = read(name) + if not notice_due(state, latest, interval): + return False + if state is not None: + state["notified_at"] = time.time() + state["notified_latest"] = latest + _save(name, state) + return True + finally: + _release_lock(lock) def _time(state: dict[str, Any] | None, key: str) -> float: diff --git a/packages/deepctl-cmd-update/src/deepctl_cmd_update/plugin_update_check.py b/packages/deepctl-cmd-update/src/deepctl_cmd_update/plugin_update_check.py index d4783964..5c6e638a 100644 --- a/packages/deepctl-cmd-update/src/deepctl_cmd_update/plugin_update_check.py +++ b/packages/deepctl-cmd-update/src/deepctl_cmd_update/plugin_update_check.py @@ -249,22 +249,23 @@ def print_pending_plugin_notifications() -> None: if not updates: return + from deepctl_core.output import invoked_prog, stderr_color + + prog = invoked_prog() + if len(updates) == 1: + u = updates[0] + lines = [ + f"Plugin update available: {u['name']} {u['current']} → {u['latest']}" + f" — run {prog} plugin update {u['name']} to upgrade" + ] + else: + lines = ["Plugin updates available:"] + lines += [f" {u['name']} {u['current']} → {u['latest']}" for u in updates] + lines.append(f"Run {prog} plugin update to upgrade") + if stderr_color(): + lines = [f"\033[33m{line}\033[0m" for line in lines] try: - if len(updates) == 1: - u = updates[0] - sys.stderr.write( - f"\033[33mPlugin update available: {u['name']} {u['current']} → {u['latest']}" - f" — run 'deepctl plugin update {u['name']}' to upgrade\033[0m\n" - ) - else: - sys.stderr.write("\033[33mPlugin updates available:\033[0m\n") - for u in updates: - sys.stderr.write( - f"\033[33m {u['name']} {u['current']} → {u['latest']}\033[0m\n" - ) - sys.stderr.write( - "\033[33mRun 'deepctl plugin update ' to upgrade\033[0m\n" - ) + sys.stderr.write("".join(f"{line}\n" for line in lines)) except (BrokenPipeError, OSError, ValueError): # The output stream closed (e.g. an MCP host disconnected stdio # after `dg mcp` finished). These notifications are purely advisory. diff --git a/packages/deepctl-cmd-update/src/deepctl_cmd_update/startup_check.py b/packages/deepctl-cmd-update/src/deepctl_cmd_update/startup_check.py index fbd8d148..d45f2cb8 100644 --- a/packages/deepctl-cmd-update/src/deepctl_cmd_update/startup_check.py +++ b/packages/deepctl-cmd-update/src/deepctl_cmd_update/startup_check.py @@ -187,7 +187,7 @@ def _newer_saved( latest = state.get("latest") if not isinstance(latest, str): return None - if not check_cache.elapsed(check_cache.notified_at(state), interval): + if not check_cache.notice_due(state, latest, interval): return None try: from packaging import version as pkg_version @@ -204,6 +204,8 @@ def _newer_saved( _result: dict[str, Any] = {} # A notice from the saved answer, when no check ran this time. _saved_notice: tuple[str, str] | None = None +# The check interval this run used: a notice shows at most once per interval. +_interval: float = 86400.0 def check_and_notify( @@ -221,7 +223,7 @@ def check_and_notify( detected automatically from package metadata. quiet: If True, suppress the check entirely. """ - global _thread, _result, _saved_notice + global _thread, _result, _saved_notice, _interval _thread = None _result = {} _saved_notice = None @@ -232,6 +234,7 @@ def check_and_notify( interval = _get_check_interval_seconds(quiet) if interval is None: return + _interval = interval # A cache directory that can't be made private: no check, no state. if not check_cache.usable(): @@ -280,14 +283,23 @@ def print_pending_notification() -> None: return current, latest = notice - try: - sys.stderr.write( - f"\n\033[33m Update available: {current} → {latest}" - f" — run \033[1mdg update\033[0m\033[33m to upgrade\033[0m\n\n" + # Several dg processes can finish together: only one shows it. + if not check_cache.claim_notice(check_cache.UPDATE_FILE, latest, _interval): + return + from deepctl_core.output import invoked_prog, stderr_color + + command = f"{invoked_prog()} update" + if stderr_color(): + line = ( + f"\033[33m Update available: {current} → {latest}" + f" — run \033[1m{command}\033[0m\033[33m to upgrade\033[0m" ) + else: + line = f" Update available: {current} → {latest} — run {command} to upgrade" + try: + sys.stderr.write(f"\n{line}\n\n") except (BrokenPipeError, OSError, ValueError): # The output stream closed (e.g. an MCP host disconnected stdio # after `dg mcp` finished). Nothing useful to do — the notification # is purely advisory. return - check_cache.mark_notified(check_cache.UPDATE_FILE) diff --git a/packages/deepctl-cmd-update/tests/unit/test_notices.py b/packages/deepctl-cmd-update/tests/unit/test_notices.py new file mode 100644 index 00000000..af8fd314 --- /dev/null +++ b/packages/deepctl-cmd-update/tests/unit/test_notices.py @@ -0,0 +1,102 @@ +"""Update notices: never in agent or automation sessions, plain text unless +a person's terminal allows colour, and the command named as invoked.""" + +from __future__ import annotations + +import io +import sys +import threading +from unittest.mock import patch + +import pytest +from deepctl_cmd_update import plugin_update_check, startup_check +from deepctl_core import output + + +@pytest.fixture(autouse=True) +def _person_session(monkeypatch): + """Run as a person's session: the test runner has no terminal, which + reads as automation and would skip every check.""" + from deepctl_cmd_update import plugin_update_check, startup_check + + monkeypatch.setattr(startup_check, "_is_agent", lambda: False) + monkeypatch.setenv("DEEPCTL_PROG_NAME", "dg") + + +def _pending(module, result): + module._result = result + module._thread = threading.Thread(target=lambda: None) + module._thread.start() + # The notice prints only for a finished check. + module._thread.join() + + +class _Tty(io.StringIO): + def isatty(self) -> bool: + return True + + +def _stderr(tty: bool) -> io.StringIO: + return _Tty() if tty else io.StringIO() + + +@pytest.fixture +def person(monkeypatch): + monkeypatch.setitem(output._output_config, "agentic", False) + for name in ("NO_COLOR", "FORCE_COLOR"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("TERM", "xterm-256color") + + +@pytest.mark.parametrize("module", [startup_check, plugin_update_check]) +def test_no_check_in_an_agent_session(monkeypatch, module): + monkeypatch.setattr(startup_check, "_is_agent", lambda: True) + for name in ("_is_ci", "_is_oneshot"): + monkeypatch.setattr(startup_check, name, lambda: False) + monkeypatch.setattr(startup_check, "_stderr_is_terminal", lambda: True) + module._thread = None + if module is startup_check: + module.check_and_notify(current_version="1.0.0") + else: + module.check_plugins_and_notify() + assert module._thread is None + + +@pytest.mark.parametrize( + ("tty", "env", "colour"), + [ + (True, {}, True), + (True, {"NO_COLOR": "1"}, False), + (True, {"TERM": "dumb"}, False), + (False, {}, False), + (False, {"FORCE_COLOR": "1"}, True), + (False, {"FORCE_COLOR": "1", "NO_COLOR": "1"}, False), + ], +) +def test_update_notice_colour(person, monkeypatch, tty, env, colour): + for name, value in env.items(): + monkeypatch.setenv(name, value) + monkeypatch.setenv("DEEPCTL_PROG_NAME", "dg") + _pending(startup_check, {"latest": "2.0.0", "current": "1.0.0"}) + err = _stderr(tty) + with patch.object(sys, "stderr", err): + startup_check.print_pending_notification() + text = err.getvalue() + assert ("\033[" in text) is colour + assert "run dg update to upgrade" in text.replace("\033[1m", "").replace( + "\033[0m\033[33m", "" + ) + + +def test_plugin_notice_plain_without_terminal(person, monkeypatch): + monkeypatch.setenv("DEEPCTL_PROG_NAME", "dg") + _pending( + plugin_update_check, + {"updates": [{"name": "p", "current": "1", "latest": "2"}]}, + ) + err = _stderr(False) + with patch.object(sys, "stderr", err): + plugin_update_check.print_pending_plugin_notifications() + assert err.getvalue() == ( + "Plugin update available: p 1 → 2 — run dg plugin update p to upgrade\n" + ) diff --git a/packages/deepctl-cmd-update/tests/unit/test_plugin_update_check.py b/packages/deepctl-cmd-update/tests/unit/test_plugin_update_check.py index 0c5cbc08..e754b7a3 100644 --- a/packages/deepctl-cmd-update/tests/unit/test_plugin_update_check.py +++ b/packages/deepctl-cmd-update/tests/unit/test_plugin_update_check.py @@ -41,6 +41,12 @@ def check_allowed(monkeypatch, cache_dir): return cache_dir + +@pytest.fixture(autouse=True) +def _invoked_as_dg(monkeypatch): + """Notices name the program as invoked; these tests expect dg.""" + monkeypatch.setenv("DEEPCTL_PROG_NAME", "dg") + # --------------------------------------------------------------------------- # _is_excluded # --------------------------------------------------------------------------- @@ -371,7 +377,7 @@ def test_single_plugin_format(self): assert "deepctl-plugin-whisper" in output assert "0.1.0" in output assert "0.2.0" in output - assert "deepctl plugin update deepctl-plugin-whisper" in output + assert "dg plugin update deepctl-plugin-whisper" in output def test_multiple_plugin_format(self): import deepctl_cmd_update.plugin_update_check as mod @@ -398,7 +404,7 @@ def test_multiple_plugin_format(self): assert "Plugin updates available:" in output assert "deepctl-plugin-whisper" in output assert "deepctl-plugin-asr" in output - assert "deepctl plugin update " in output + assert "dg plugin update " in output @pytest.mark.parametrize( "error", diff --git a/packages/deepctl-cmd-update/tests/unit/test_startup_check.py b/packages/deepctl-cmd-update/tests/unit/test_startup_check.py index 4d7b2684..28b75fcc 100644 --- a/packages/deepctl-cmd-update/tests/unit/test_startup_check.py +++ b/packages/deepctl-cmd-update/tests/unit/test_startup_check.py @@ -68,6 +68,12 @@ def _finished_thread() -> threading.Thread: return thread +@pytest.fixture(autouse=True) +def _invoked_as_dg(monkeypatch): + """Notices name the program as invoked; these tests expect dg.""" + monkeypatch.setenv("DEEPCTL_PROG_NAME", "dg") + + class TestCIDetection: """Test CI environment detection.""" @@ -565,3 +571,135 @@ def test_in_time_notice_is_not_repeated_from_the_cache(self, check_allowed): assert "2.0.0" in self._notice() check_and_notify(current_version="1.0.0") assert self._notice() == "" + + + +# One dg process finishing: claim the notice for 2.0.0 at a set time. +_CLAIM = """ +import sys, time +from deepctl import _cache +from deepctl_cmd_update import check_cache +_cache.cache_root = lambda: sys.argv[1] +start = float(sys.argv[2]) +while time.time() < start: + pass +print(check_cache.claim_notice(check_cache.UPDATE_FILE, "2.0.0", 86400.0)) +""" + +class TestNoticeOnce: + """Several dg processes finishing together show the notice once.""" + + @staticmethod + def _state(cache_dir, **extra): + assert check_cache.usable() + check_cache.write(check_cache.UPDATE_FILE, ok=True, latest="2.0.0", source="x") + state = check_cache.read(check_cache.UPDATE_FILE) + state.update(extra) + check_cache._save(check_cache.UPDATE_FILE, state) + return cache_dir + + def test_concurrent_processes_claim_once(self, check_allowed): + import subprocess + + self._state(check_allowed) + start = time.time() + 3.0 + procs = [ + subprocess.Popen( + [sys.executable, "-c", _CLAIM, str(check_allowed), str(start)], + stdout=subprocess.PIPE, + text=True, + ) + for _ in range(6) + ] + results = [proc.communicate(timeout=60)[0].strip() for proc in procs] + assert results.count("True") == 1, results + assert results.count("False") == 5, results + state = check_cache.read(check_cache.UPDATE_FILE) + assert state["notified_latest"] == "2.0.0" + assert not list(check_allowed.glob(".*.lock")) + + def test_lock_taken_shows_nothing(self, check_allowed): + self._state(check_allowed) + lock = check_allowed / f".{check_cache.UPDATE_FILE}.lock" + lock.write_text("") + assert check_cache.claim_notice(check_cache.UPDATE_FILE, "2.0.0", 86400.0) is False + assert lock.exists() # a fresh lock belongs to a live process + os.utime(lock, (1, 1)) + assert check_cache.claim_notice(check_cache.UPDATE_FILE, "2.0.0", 86400.0) is False + assert not lock.exists() # a stale one is cleared for the next run + assert check_cache.claim_notice(check_cache.UPDATE_FILE, "2.0.0", 86400.0) is True + + def test_a_check_finishing_later_keeps_the_record(self, check_allowed): + """A second process's fresh check that lands after the first showed + the notice doesn't make it show again.""" + self._state(check_allowed) + assert check_cache.claim_notice(check_cache.UPDATE_FILE, "2.0.0", 86400.0) + check_cache.write(check_cache.UPDATE_FILE, ok=False, latest=None, source="x") + check_cache.write(check_cache.UPDATE_FILE, ok=True, latest="2.0.0", source="x") + mod._result = {"latest": "2.0.0", "current": "1.0.0"} + mod._thread = _finished_thread() + err = io.StringIO() + with patch.object(sys, "stderr", err): + print_pending_notification() + assert err.getvalue() == "" + + def test_a_claim_during_a_check_write_is_kept(self, check_allowed, monkeypatch): + """A claim made while another process's check is between reading + the state and saving it isn't erased by that save.""" + self._state(check_allowed) + real_read, real_save = check_cache.read, check_cache._save + read_done, claim_done = threading.Event(), threading.Event() + + def writer() -> bool: + return threading.current_thread() is checker + + def read(name): + state = real_read(name) + if writer(): + read_done.set() + return state + + def save(name, state): + if writer(): + claim_done.wait(5.0) + real_save(name, state) + + monkeypatch.setattr(check_cache, "read", read) + monkeypatch.setattr(check_cache, "_save", save) + checker = threading.Thread( + target=check_cache.write, + args=(check_cache.UPDATE_FILE,), + kwargs={"ok": True, "latest": "2.0.0", "source": "x"}, + ) + checker.start() + assert read_done.wait(5.0) + first = check_cache.claim_notice(check_cache.UPDATE_FILE, "2.0.0", 86400.0) + claim_done.set() + checker.join(5.0) + second = check_cache.claim_notice(check_cache.UPDATE_FILE, "2.0.0", 86400.0) + assert [first, second].count(True) == 1, (first, second) + assert not list(check_allowed.glob(".*.lock")) + + def test_a_lock_that_cannot_be_made_still_shows(self, check_allowed, monkeypatch): + """No lock can be created (read-only mount, full disk): the notice + shows rather than never.""" + self._state(check_allowed) + real_open = os.open + + def no_lock(path, *args, **kwargs): + if str(path).endswith(".lock"): + raise PermissionError(13, "Permission denied") + return real_open(path, *args, **kwargs) + + monkeypatch.setattr(check_cache.os, "open", no_lock) + claimed = check_cache.claim_notice(check_cache.UPDATE_FILE, "2.0.0", 86400.0) + assert claimed is True + + def test_a_newer_version_shows_again(self, check_allowed): + self._state(check_allowed, notified_at=time.time(), notified_latest="1.5.0") + mod._result = {"latest": "2.0.0", "current": "1.0.0"} + mod._thread = _finished_thread() + err = io.StringIO() + with patch.object(sys, "stderr", err): + print_pending_notification() + assert err.getvalue().count("Update available") == 1 diff --git a/packages/deepctl-core/src/deepctl_core/agents.py b/packages/deepctl-core/src/deepctl_core/agents.py index 570b106b..64dcc5b7 100644 --- a/packages/deepctl-core/src/deepctl_core/agents.py +++ b/packages/deepctl-core/src/deepctl_core/agents.py @@ -369,3 +369,30 @@ def detect( True, False, marker.agent_id, marker.reason, marker, soft, None ) return Detection(False, soft is not None, None, soft, None, soft, None) + + +def mode_notice(prog: str, env: Mapping[str, str] | None = None) -> str | None: + """The line telling a person why dg is in agent mode, or ``None``. + + Only when a hard signal (not ``DG_AGENT``) turned agent mode on and + stdin and stderr are terminals: a person at a keyboard who may have the + variable set by accident. It names no shell command, so it reads the + same in every shell. + """ + env = _environ(env) + if override(env) is not None: + return None + marker = hard_match(env) + if marker is None or not (_isatty(sys.stdin) and _isatty(sys.stderr)): + return None + if marker.session: + return ( + "This shell looks like an AI agent session (detected via " + f"{marker.reason}). If you are a person, see {prog} help agent-mode. " + "AI agents: don't change this." + ) + return ( + f"Using agent mode because {marker.reason} is set. If you are a person, " + f"remove {marker.reason} from your environment and shell profile " + f"(see {prog} help agent-mode). AI agents: don't change this." + ) diff --git a/packages/deepctl-core/src/deepctl_core/config.py b/packages/deepctl-core/src/deepctl_core/config.py index b4d12e6f..5257aa9d 100644 --- a/packages/deepctl-core/src/deepctl_core/config.py +++ b/packages/deepctl-core/src/deepctl_core/config.py @@ -1337,18 +1337,31 @@ def _migrate_config_if_needed(self, new_config_dir: Path) -> None: return # If old config exists and new doesn't, migrate it if old_config_path.exists() and not new_config_path.exists(): + # Queued like every config notice: stderr, once, in the run's + # output mode. + old_shown = msg.display(str(old_config_path)) + new_shown = msg.display(str(new_config_path)) try: - print( - f"Migrating config from {old_config_path} to {new_config_path}", - file=sys.stderr, - ) text = old_config_path.read_text(encoding="utf-8") # Written like any save: 0600, atomic, never via the source's mode. with _directory_lock(new_config_dir): self._replace_or_rewrite(new_config_path, text) - print("✓ Config migrated successfully", file=sys.stderr) + _queue_notice( + "I-CFG-MIGRATED", + str(new_config_path), + f"Copied the config from {old_shown} to {new_shown}.", + None, + quiet_ok=True, + ) except Exception as e: - print(f"Warning: Could not migrate config: {e}", file=sys.stderr) + _queue_notice( + "W-CFG-MIGRATE", + str(new_config_path), + f"Could not migrate config from {old_shown} to {new_shown}: " + f"{msg.display(str(e))}", + None, + quiet_ok=False, + ) def _get_project_config_path(self) -> Path: """Get the project-specific configuration path.""" diff --git a/packages/deepctl-core/tests/unit/test_agents.py b/packages/deepctl-core/tests/unit/test_agents.py index 2a54231c..4dd065cc 100644 --- a/packages/deepctl-core/tests/unit/test_agents.py +++ b/packages/deepctl-core/tests/unit/test_agents.py @@ -274,6 +274,45 @@ def test_no_color_never_counts(self, monkeypatch): assert agents.soft_reason(_env(TERM="dumb"), []) == "no terminal" +class TestNotice: + SHELL_WORDS = ("unset", "export ", "set -e", "Remove-Item", "$env:", "DG_AGENT") + + @pytest.fixture + def person(self, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stderr.isatty", lambda: True) + + def test_variable_row(self, person): + line = agents.mode_notice("dg", _env(CLAUDECODE="1")) + assert line == ( + "Using agent mode because CLAUDECODE is set. If you are a person, " + "remove CLAUDECODE from your environment and shell profile " + "(see dg help agent-mode). AI agents: don't change this." + ) + assert not any(word in line for word in self.SHELL_WORDS) + + def test_session_row(self, person): + line = agents.mode_notice("deepctl", {"PATH": "/h/.pi/agent:/usr/bin"}) + assert line == ( + "This shell looks like an AI agent session (detected via a .pi/agent " + "entry in PATH). If you are a person, see deepctl help agent-mode. " + "AI agents: don't change this." + ) + assert not any(word in line for word in self.SHELL_WORDS) + + @pytest.mark.parametrize("value", ["0", "1"]) + def test_never_when_dg_agent_decides(self, person, value): + assert agents.mode_notice("dg", _env(DG_AGENT=value, CLAUDECODE="1")) is None + + def test_never_without_a_person_at_a_terminal(self, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("sys.stderr.isatty", lambda: True) + assert agents.mode_notice("dg", _env(CLAUDECODE="1")) is None + + def test_never_for_people(self, person): + assert agents.mode_notice("dg", _env()) is None + + def test_stdlib_only(): """Detection runs before anything heavy loads; it imports only stdlib.""" code = ( diff --git a/packages/deepctl-core/tests/unit/test_config_login.py b/packages/deepctl-core/tests/unit/test_config_login.py index 1528f122..6efaa091 100644 --- a/packages/deepctl-core/tests/unit/test_config_login.py +++ b/packages/deepctl-core/tests/unit/test_config_login.py @@ -29,8 +29,11 @@ def test_legacy_migration_prints_to_stderr_only(tmp_path, monkeypatch, capsys): monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) monkeypatch.setattr("deepctl_core.output.is_ai_agent", lambda: False) monkeypatch.chdir(tmp_path) + from deepctl_core import config as config_module + Config() + config_module.flush_config_notices() captured = capsys.readouterr() assert captured.out == "" - assert "Migrating config" in captured.err + assert captured.err.count("Copied the config from") == 1 assert os.path.exists(home / ".config" / "deepctl" / "config.yaml") diff --git a/src/deepctl/_cli.py b/src/deepctl/_cli.py index 1a5f823c..1bdd5555 100644 --- a/src/deepctl/_cli.py +++ b/src/deepctl/_cli.py @@ -32,7 +32,7 @@ ) from deepctl_core.output import stderr_console -from deepctl import __version__, _manifest +from deepctl import _manifest if TYPE_CHECKING: from collections.abc import Callable @@ -712,9 +712,27 @@ def _argv_config_path(args: list[str]) -> str | None: return found +def _show_version(ctx: click.Context, param: click.Parameter, value: bool) -> None: + """``--version`` past the argv peek: the line ``deepctl.main`` prints.""" + del param + if not value or ctx.resilient_parsing: + return + from deepctl.main import version_line + + click.echo(version_line(), color=ctx.color) + ctx.exit() + + # Create CLI group @click.group(name="deepctl", cls=LazyGroup) -@click.version_option(version=__version__, prog_name="deepctl") +@click.option( + "--version", + is_flag=True, + expose_value=False, + is_eager=True, + callback=_show_version, + help="Show the version and exit.", +) @click.option( "--config", "-c", @@ -1098,6 +1116,26 @@ def _start_checks(quiet: bool) -> list[Callable[[], object]]: return pending +def _agent_mode_notice(quiet: bool) -> None: + """Tell a person at a terminal, once per run, that an AI-agent marker in + their environment put dg in agent mode (``deepctl_core.agents``).""" + if quiet: + return + from deepctl_core import agents, output + + try: + line = agents.mode_notice("{prog}") + if line is None: + return + from rich.markup import escape + + output.print_info( + escape(line.replace("{prog}", output.invoked_prog())), stderr=True + ) + except (OSError, ValueError): + pass + + def _record_startup(started_at: float) -> None: """Add process startup (from ``main()``'s first line) to ``--timing``.""" from deepctl_core.timing import TimingEntry, get_timing_collector @@ -1188,6 +1226,8 @@ def run( # Background update and AI-CLI checks (non-blocking). Not for help, # which makes no network connection and prints only the help. pending = [] if _is_help_run(args) else _start_checks(quiet_requested) + if not _is_help_run(args): + _agent_mode_notice(quiet_requested) # Root `dg --help` is saved for the native launcher when it asks # (DEEPCTL_HELP_CACHE=1), outside an AI agent, unless the run writes diff --git a/src/deepctl/main.py b/src/deepctl/main.py index 0bb4ec83..92715086 100644 --- a/src/deepctl/main.py +++ b/src/deepctl/main.py @@ -3,8 +3,8 @@ Only ``sys`` and ``os`` are imported at module level. ``main()`` peeks at argv first, so two paths finish without Click or ``deepctl_core``: -- ``dg --version`` prints the version constant (the same bytes Click printed - in 0.4.x) and exits 0. +- ``dg --version`` prints the version line (the same bytes as the Click + path) and exits 0. - ``dg mcp`` (stdio, no other options) answers the MCP handshake locally in ``deepctl.mcp._fast`` and hands the session to the proxy on the first call. @@ -143,15 +143,28 @@ def _rich_excepthook( sys.excepthook(exc_type, exc, tb) -def _print_version() -> None: +def version_line() -> str: + """``, version ``: ``DEEPCTL_PROG_NAME`` when a launcher + sets it to a plain program name, else the name the user typed (``dg``, + ``deepgram`` or ``deepctl``), else ``deepctl`` (``python -m deepctl``, + a renamed script). The Click path prints the same line.""" from deepctl import __version__ + name = prog_name_from_env() + if not name: + name = os.path.basename(sys.argv[0]) if sys.argv and sys.argv[0] else "" + if os.name == "nt" and name.lower().endswith(".exe"): + name = name[:-4] + if name not in ("dg", "deepgram", "deepctl"): + name = "deepctl" + return f"{name}, version {__version__}" + + +def _print_version() -> None: if sys.stdout is None: # started with stdout closed (`dg --version >&-`) return try: - # Byte-identical to 0.4.x, where Click's version_option printed this - # with prog_name="deepctl" for every console script. - sys.stdout.write(f"deepctl, version {__version__}\n") + sys.stdout.write(version_line() + "\n") sys.stdout.flush() except BrokenPipeError: # The reader went away (`dg --version | true`): exit quietly, and diff --git a/tests/unit/test_lazy_group.py b/tests/unit/test_lazy_group.py index acd96272..7da1284c 100644 --- a/tests/unit/test_lazy_group.py +++ b/tests/unit/test_lazy_group.py @@ -324,7 +324,7 @@ def test_prog_name_env(self, tmp_path: Path) -> None: proc = _dg(["--help"], tmp_path, DEEPCTL_PROG_NAME="foo") assert proc.stdout.startswith("Usage: foo [OPTIONS] COMMAND [ARGS]...") version = _dg(["--version"], tmp_path, DEEPCTL_PROG_NAME="foo") - assert version.stdout.startswith("deepctl, version ") + assert version.stdout.startswith("foo, version ") def test_alias_help(self, tmp_path: Path) -> None: proc = _dg(["debug-audio", "--help"], tmp_path) diff --git a/tests/unit/test_stream_discipline.py b/tests/unit/test_stream_discipline.py new file mode 100644 index 00000000..037d0e30 --- /dev/null +++ b/tests/unit/test_stream_discipline.py @@ -0,0 +1,280 @@ +"""Warnings, errors and notices stay on stderr: a machine-format stdout holds +the payload and nothing else, and each warning prints once. + +Every case runs dg in a fresh interpreter against a closed loopback port, +so nothing leaves the machine. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ENTRY = "import sys; from deepctl.main import main; sys.exit(main())" +FAKE_KEY = "fa1ce0000000000000000000000000000000000d" +AGENT_VARS = ( + "AI_AGENT", + "AGENT", + "GOOSE_TERMINAL", + "CLAUDE_CODE_IS_COWORK", + "CLAUDECODE", + "CLAUDE_CODE", + "CLAUDE_CODE_ENTRYPOINT", + "CODEX_SANDBOX", + "CODEX_CI", + "CODEX_THREAD_ID", + "CODEX_SANDBOX_NETWORK_DISABLED", + "GEMINI_CLI", + "COPILOT_CLI", + "OPENCODE", + "ANTIGRAVITY_AGENT", + "AUGMENT_AGENT", + "CURSOR_AGENT", + "CURSOR_EXTENSION_HOST_ROLE", + "OR_APP_NAME", + "OR_SITE_URL", + "DG_AGENT", +) + + +def _env(home: Path, **extra: str) -> dict[str, str]: + env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("DEEPGRAM_", "PYTHON", "DEEPCTL_")) and k not in AGENT_VARS + } + env.update( + HOME=str(home), + XDG_CONFIG_HOME=str(home / ".config"), + DEEPCTL_TELEMETRY_DISABLED="1", + DEEPCTL_TELEMETRY_DSN="http://k@127.0.0.1:9/1", + DEEPCTL_NO_UPDATE_CHECK="1", + DEEPGRAM_API_KEY=FAKE_KEY, + DEEPGRAM_BASE_URL="http://127.0.0.1:9", + PATH="/usr/local/bin:/usr/bin:/bin", + ) + env.update(extra) + return env + + +def _dg(home: Path, *args: str, **env: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-c", ENTRY, *args], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + env=_env(home, **env), + timeout=60, + cwd=home, + ) + + +def _user_config(home: Path) -> Path: + path = home / ".config" / "deepctl" / "config.yaml" + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +@pytest.fixture +def home(tmp_path: Path) -> Path: + root = tmp_path / "home" + root.mkdir() + return root + + +class TestConfigWarnings: + def test_malformed_config_json_stdout_stays_clean(self, home: Path) -> None: + _user_config(home).write_text("profiles: [\n bad", encoding="utf-8") + proc = _dg(home, "-o", "json", "models") + json.loads(proc.stdout) # one JSON document, nothing before it + assert "Could not load config" not in proc.stdout + assert proc.stderr.count("Could not load config") == 1 + assert "WARN: Could not load config" in proc.stderr + + @pytest.mark.skipif( + sys.platform == "win32" or (hasattr(os, "geteuid") and os.geteuid() == 0), + reason="root reads any file", + ) + def test_unreadable_config_json_stdout_stays_clean(self, home: Path) -> None: + path = _user_config(home) + path.write_text("output:\n format: json\n", encoding="utf-8") + path.chmod(0) + try: + proc = _dg(home, "-o", "json", "models") + finally: + path.chmod(0o600) + json.loads(proc.stdout) + assert proc.stderr.count("Could not load config") == 1 + + def test_malformed_config_human_output_warns_once_on_stderr( + self, home: Path + ) -> None: + _user_config(home).write_text("profiles: [\n bad", encoding="utf-8") + proc = _dg(home, "models", DG_AGENT="0") + assert "Could not load config" not in proc.stdout + assert proc.stderr.count("Could not load config") == 1 + + +class TestCommandErrors: + def test_ffprobe_bad_path_errors_once(self, home: Path) -> None: + proc = _dg(home, "ffprobe", "--path", "/nope/ffprobe") + assert proc.returncode == 1 + assert proc.stderr.count("File not found") == 1 + assert "ERROR: File not found: /nope/ffprobe" in proc.stderr + assert json.loads(proc.stdout)["status"] == "error" + + def test_ffprobe_bad_path_human_errors_once(self, home: Path) -> None: + proc = _dg(home, "ffprobe", "--path", "/nope/ffprobe", DG_AGENT="0") + assert proc.returncode == 1 + assert proc.stdout == "" + assert proc.stderr.count("File not found") == 1 + + def test_login_errors_go_to_stderr(self, home: Path) -> None: + proc = _dg(home, "login", "--api-key", "bad", DG_AGENT="0") + assert proc.returncode != 0 + assert proc.stdout == "" + assert proc.stderr.strip() + + def test_login_errors_machine_format(self, home: Path) -> None: + proc = _dg(home, "-o", "json", "login", "--api-key", "bad") + assert proc.returncode != 0 + assert "Error:" not in proc.stdout + json.loads(proc.stdout) + + +class TestVersionName: + @pytest.mark.parametrize("name", ["dg", "deepctl", "deepgram"]) + def test_version_names_the_invoked_program(self, home: Path, name: str) -> None: + from deepctl import __version__ + + code = ( + f"import sys; sys.argv[0] = '/opt/bin/{name}'; " + "from deepctl.main import main; sys.exit(main())" + ) + fast = subprocess.run( + [sys.executable, "-c", code, "--version"], + capture_output=True, + text=True, + env=_env(home), + timeout=60, + ) + slow = subprocess.run( + [sys.executable, "-c", code, "--quiet", "--version"], + capture_output=True, + text=True, + env=_env(home), + timeout=60, + ) + assert fast.stdout == f"{name}, version {__version__}\n" + assert slow.stdout == fast.stdout + assert fast.stderr == slow.stderr == "" + + def test_version_uses_the_launcher_prog_name(self, home: Path) -> None: + from deepctl import __version__ + + code = ( + "import sys; sys.argv[0] = '/venv/bin/deepctl'; " + "from deepctl.main import main; sys.exit(main())" + ) + for args in (["--version"], ["--quiet", "--version"]): + proc = subprocess.run( + [sys.executable, "-c", code, *args], + capture_output=True, + text=True, + env=_env(home, DEEPCTL_PROG_NAME="dg"), + timeout=60, + ) + assert proc.stdout == f"dg, version {__version__}\n" + + @pytest.mark.parametrize("value", ["/tmp/evil", "dg\x1b[2J", "dg;id", "dg x"]) + def test_version_ignores_a_name_that_isnt_plain( + self, home: Path, value: str + ) -> None: + from deepctl import __version__ + + code = ( + "import sys; sys.argv[0] = '/venv/bin/deepgram'; " + "from deepctl.main import main; sys.exit(main())" + ) + for args in (["--version"], ["--quiet", "--version"]): + proc = subprocess.run( + [sys.executable, "-c", code, *args], + capture_output=True, + text=True, + env=_env(home, DEEPCTL_PROG_NAME=value), + timeout=60, + ) + assert proc.stdout == f"deepgram, version {__version__}\n" + + +class TestAgentModeNotice: + """The notice needs a person at a terminal, which a subprocess pipe + isn't; deepctl_core.agents' tests cover the wording. Here: never on + stdout, and never without a terminal.""" + + def test_no_notice_without_a_terminal(self, home: Path) -> None: + proc = _dg(home, "-o", "json", "ffprobe", CLAUDECODE="1") + assert "agent mode" not in proc.stderr + assert "agent mode" not in proc.stdout + + @pytest.mark.skipif(sys.platform == "win32", reason="needs a pty") + @pytest.mark.parametrize( + ("env", "expected"), + [ + ( + {"CLAUDECODE": "1"}, + "INFO: Using agent mode because CLAUDECODE is set. If you are a " + "person, remove CLAUDECODE from your environment and shell profile " + "(see dg help agent-mode). AI agents: don't change this.", + ), + ({"CLAUDECODE": "1", "DG_AGENT": "1"}, None), + ({"CLAUDECODE": "1", "DG_AGENT": "0"}, None), + ({}, None), + ], + ) + def test_notice_for_a_person_at_a_terminal( + self, home: Path, env: dict[str, str], expected: str | None + ) -> None: + import pty + import select + + master, slave = pty.openpty() + code = ( + "import sys; sys.argv[0] = '/opt/bin/dg'; " + "from deepctl.main import main; sys.exit(main())" + ) + proc = subprocess.Popen( + [sys.executable, "-c", code, "-o", "json", "ffprobe"], + stdin=slave, + stdout=subprocess.PIPE, + stderr=slave, + env=_env(home, TERM="xterm", DEEPCTL_PROG_NAME="dg", **env), + cwd=home, + ) + os.close(slave) + out, _ = proc.communicate(timeout=60) + err = b"" + while True: + ready, _, _ = select.select([master], [], [], 0.5) + if not ready: + break + try: + chunk = os.read(master, 65536) + except OSError: + break + if not chunk: + break + err += chunk + os.close(master) + text = err.decode("utf-8", "replace").replace("\r\n", "\n") + flat = " ".join(text.split()) + assert b"agent mode" not in out + if expected is None: + assert "agent mode" not in flat + else: + assert flat.count(expected) == 1, text From dfed13bf2c00cee77d3a1278b28a92de38e5cde3 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Mon, 2 Nov 2026 08:18:40 -0500 Subject: [PATCH 07/25] style: say what each check does in config and skills comments A few comments and test docstrings in the config loading, credential and skills code pointed at notes kept outside the repository. They now describe the behaviour under test instead. --- .../src/deepctl_cmd_skills/command.py | 2 +- .../deepctl-core/src/deepctl_core/skill_generator.py | 12 ++++++------ .../deepctl-core/tests/unit/test_skill_generator.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py index 4ecd825e..202a8265 100644 --- a/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py +++ b/packages/deepctl-cmd-skills/src/deepctl_cmd_skills/command.py @@ -235,7 +235,7 @@ def _install(self, plan: list[tuple[SkillGenerator, str]], **recheck: Any) -> No for gen, paths, leftover in sg.install_for(plan, **recheck): done = f"{gen.display_name}: installed {_n(len(paths), 'skill')} in {gen.skills_root()}." print_success(escape(done)) - if leftover: # Only this run's staging (SF3). + if leftover: # Only this run's staging. print_warning(escape(sg._msg("E12", staging=leftover))) count += len(paths) done_refs[gen.cli_name] = refs[gen.cli_name] diff --git a/packages/deepctl-core/src/deepctl_core/skill_generator.py b/packages/deepctl-core/src/deepctl_core/skill_generator.py index 412a951f..86ffc155 100644 --- a/packages/deepctl-core/src/deepctl_core/skill_generator.py +++ b/packages/deepctl-core/src/deepctl_core/skill_generator.py @@ -146,7 +146,7 @@ def _reason(exc: BaseException) -> str: class SkillInstallError(Exception): """An install, remove or skills.json failure; the message is one sentence per problem.""" - leftover: Path | None = None # This run's staging, if cleanup left it (SF3). + leftover: Path | None = None # This run's staging, if cleanup left it. kept: Path | None = None # E4 or E6: where what was at dest was kept. @@ -393,7 +393,7 @@ def _ownership(path: Path, cli: str, name: str, rec: dict[str, Any] | None) -> s return "unproven" fp = _fingerprint(path) except OSError: - return "unreadable" # Never "edited", which would drop a record (SF4). + return "unreadable" # Never "edited", which would drop a record. return "ok" if fp in want else "edited" @@ -914,7 +914,7 @@ def _swap(gen: SkillGenerator, name: str, staging: Path, rec: Any) -> None: pass # Vanished: install as absent. except OSError as exc: raise _err("E5", gen, reason=_reason(exc)) - try: # Starts right after the move, so the copy always goes back (SF1). + try: # Starts right after the move, so the copy always goes back. if moved and (kind := _ownership(aside, cli, name, rec)) != "ok": raise (refusal := _refusal(gen, dest, kind, rec)) # The real proof. _place(staging / "new" / name, dest) @@ -1039,7 +1039,7 @@ def settle(state: dict[str, Any]) -> None: # From proof on disk, not ``placed`` ok = _marker_ok(dest, cli, s.name) fp = _fingerprint(dest) if ok else None except OSError: - continue # Unreadable keeps its record exactly as it is (SF4). + continue # Unreadable keeps its record exactly as it is. if fp in want: folders[s.name] = {"state": "installed", "fingerprint": fp} elif rec and rec.get("run", run) != run: @@ -1064,7 +1064,7 @@ def settle(state: dict[str, Any]) -> None: # From proof on disk, not ``placed`` staging = Path(tempfile.mkdtemp(prefix=_STAGING_PREFIX, dir=root)) except OSError as exc: raise _err("E5", gen, reason=_reason(exc)) - try: # Right after mkdtemp, so cleanup runs after any failure or Ctrl-C (SF1). + try: # Right after mkdtemp, so cleanup runs after any failure or Ctrl-C. fps.update(_stage(gen, skills, staging)) _update_state(mark, "E9", gen) # Record before swap (B6): nothing moved. for s in skills: @@ -1236,7 +1236,7 @@ def settle(state: dict[str, Any]) -> None: # Keeps only what proves on disk. continue try: # Covers the move too, so a Ctrl-C right after it puts it back. os.rename(dest, aside) - kind = _ownership(aside, cli, name, rec) # The real proof (SF1). + kind = _ownership(aside, cli, name, rec) # The real proof. except FileNotFoundError: continue except OSError as exc: # Nothing moved: _ownership never raises it. diff --git a/packages/deepctl-core/tests/unit/test_skill_generator.py b/packages/deepctl-core/tests/unit/test_skill_generator.py index cb412106..66031ba2 100644 --- a/packages/deepctl-core/tests/unit/test_skill_generator.py +++ b/packages/deepctl-core/tests/unit/test_skill_generator.py @@ -857,7 +857,7 @@ def test_unrecorded_dest_kinds_refuse(self, tmp_path, kind): with pytest.raises(SkillOwnershipError) as exc: install(make_bundle(tmp_path)) assert str(exc.value) == _msg("E1", paths=str(api)) - assert state_bytes() is None # No "installing" write happened (SF7). + assert state_bytes() is None # No "installing" write happened. assert sha_tree(api) == before if target_before is not None: assert sha_tree(target) == target_before @@ -894,7 +894,7 @@ def test_recorded_name_now_symlink_is_never_written_through(self, tmp_path): symlink_or_skip(api, rel(mine, api), is_dir=True) assert ( _fingerprint(mine) == records()["api"]["fingerprint"] - ) # A real install (SF7). + ) # A real install. before, saved = sha_tree(mine), state_bytes() with pytest.raises(SkillOwnershipError) as exc: install(make_bundle(tmp_path, body="v2")) From a6c2847a59b4a347edfdf3ac34dcf8e39045a7ed Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Mon, 2 Nov 2026 09:58:53 -0500 Subject: [PATCH 08/25] fix(cli): escape characters the output encoding can't write On Windows a redirected stdout uses the ANSI code page (cp1252, say). The banner's block art and the check mark aren't in it, so `dg --help > file` and the `dg ffprobe --path` success line stopped with "'charmap' codec can't encode". A stdout or stderr that isn't UTF-8, and whose error handler raises on such characters (strict, or surrogateescape under a C locale), now writes them as backslash escapes. The encoding itself is kept, so a real console keeps the one Python chose, and a UTF-8 stream is untouched. --- src/deepctl/main.py | 32 +++++++++ tests/unit/test_output_encoding.py | 110 +++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 tests/unit/test_output_encoding.py diff --git a/src/deepctl/main.py b/src/deepctl/main.py index 92715086..91c152cc 100644 --- a/src/deepctl/main.py +++ b/src/deepctl/main.py @@ -160,6 +160,37 @@ def version_line() -> str: return f"{name}, version {__version__}" +# Error handlers that never raise on a character the encoding lacks. +# "strict" and "surrogateescape" (a C locale) both raise. +_SAFE_ERRORS = frozenset( + {"backslashreplace", "replace", "ignore", "namereplace", "xmlcharrefreplace"} +) + + +def safe_output_streams() -> None: + """Have stdout and stderr escape what their encoding can't write. + + A redirected stream on Windows uses the ANSI code page (cp1252, say), + which has no block art or check mark: writing one raised + UnicodeEncodeError and ended the command. Such a stream now writes + ``\\u2713`` instead. A UTF-8 stream is left as it is, and the encoding + never changes, so a real console keeps what Python chose for it. + """ + import codecs + import io + + for stream in (sys.stdout, sys.stderr): + if not isinstance(stream, io.TextIOWrapper): + continue # None, or a stream that can't be reconfigured + try: + encoding = codecs.lookup(stream.encoding or "").name + if encoding == "utf-8" or stream.errors in _SAFE_ERRORS: + continue + stream.reconfigure(errors="backslashreplace") + except (LookupError, OSError, ValueError): + continue # an unknown encoding, or a closed stream + + def _print_version() -> None: if sys.stdout is None: # started with stdout closed (`dg --version >&-`) return @@ -206,6 +237,7 @@ def main(prog: "str | None" = None) -> None: if args[0] == "mcp" and args[1:] in _FAST_MCP_ARGS: sys.exit(serve()) + safe_output_streams() sys.excepthook = _rich_excepthook try: from deepctl_core.signals import install_interrupt_handler diff --git a/tests/unit/test_output_encoding.py b/tests/unit/test_output_encoding.py new file mode 100644 index 00000000..4cf9fe6a --- /dev/null +++ b/tests/unit/test_output_encoding.py @@ -0,0 +1,110 @@ +"""Output never fails on characters the stream's encoding lacks. + +Windows gives a redirected stdout the ANSI code page (cp1252), which has no +block art or check mark. PYTHONIOENCODING=cp1252 reproduces that stream on +any platform. +""" + +from __future__ import annotations + +import io +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from deepctl import main as dg_main + + +def _stream(encoding: str, errors: str = "strict") -> io.TextIOWrapper: + return io.TextIOWrapper(io.BytesIO(), encoding=encoding, errors=errors) + + +def _written(stream: io.TextIOWrapper) -> bytes: + stream.flush() + buffer = stream.buffer + assert isinstance(buffer, io.BytesIO) + return buffer.getvalue() + + +@pytest.mark.parametrize("errors", ["strict", "surrogateescape"]) +def test_cp1252_stream_escapes_instead_of_raising( + monkeypatch: pytest.MonkeyPatch, errors: str +) -> None: + # surrogateescape is what Python picks under a C locale (Wine has one). + out, err = _stream("cp1252", errors), _stream("cp1252", errors) + monkeypatch.setattr(sys, "stdout", out) + monkeypatch.setattr(sys, "stderr", err) + dg_main.safe_output_streams() + out.write("ok ✓ café █") + err.write("✗") + assert _written(out) == b"ok \\u2713 caf\xe9 \\u2588" + assert _written(err) == b"\\u2717" + # The encoding itself is kept: only unencodable characters are escaped. + assert out.encoding == "cp1252" + + +def test_utf8_stream_is_untouched(monkeypatch: pytest.MonkeyPatch) -> None: + out = _stream("UTF-8") + monkeypatch.setattr(sys, "stdout", out) + dg_main.safe_output_streams() + assert out.errors == "strict" + assert out.encoding == "UTF-8" + + +def test_chosen_error_handler_is_kept(monkeypatch: pytest.MonkeyPatch) -> None: + out = _stream("cp1252", "replace") + monkeypatch.setattr(sys, "stdout", out) + dg_main.safe_output_streams() + assert out.errors == "replace" + + +@pytest.mark.parametrize("stream", [None, object(), io.StringIO()]) +def test_odd_streams_are_skipped( + monkeypatch: pytest.MonkeyPatch, stream: object +) -> None: + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + dg_main.safe_output_streams() # no error + + +def test_closed_stream_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None: + out = _stream("cp1252") + out.close() + monkeypatch.setattr(sys, "stdout", out) + dg_main.safe_output_streams() + + +@pytest.mark.parametrize( + "args", [["--help"], ["ffprobe", "--help"], ["help", "agent-mode"]] +) +def test_redirected_cp1252_output_does_not_crash( + tmp_path: Path, args: list[str] +) -> None: + env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("DEEPCTL_", "DEEPGRAM_", "PYTHONIO")) + } + env.update( + HOME=str(tmp_path), + XDG_CONFIG_HOME=str(tmp_path / "config"), + XDG_CACHE_HOME=str(tmp_path / "cache"), + XDG_STATE_HOME=str(tmp_path / "state"), + PYTHONIOENCODING="cp1252", + DEEPCTL_TELEMETRY_DISABLED="1", + NO_COLOR="1", + ) + proc = subprocess.run( + [sys.executable, "-m", "deepctl", *args], + capture_output=True, + env=env, + cwd=tmp_path, + timeout=60, + check=False, + ) + assert b"codec can't encode" not in proc.stderr, proc.stderr[-2000:] + assert proc.returncode == 0, proc.stderr[-2000:] + assert proc.stdout From 59c66e15c75e26e2a8814d89d3bb767135b4a42f Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Mon, 2 Nov 2026 10:00:26 -0500 Subject: [PATCH 09/25] fix(login): show a profile's config-file key in profiles --list profiles --list and --current read each profile's key from the keyring and used the config file's key only when the keyring raised. A backend that answers "no entry", as Windows Credential Manager does for a key saved only in config.yaml, showed "API Key: Not set" for a profile that has one. The config file's key is now shown whenever the keyring has none. --- .../deepctl-core/src/deepctl_core/auth.py | 12 ++-- .../tests/unit/test_profiles_list_keys.py | 59 +++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 packages/deepctl-core/tests/unit/test_profiles_list_keys.py diff --git a/packages/deepctl-core/src/deepctl_core/auth.py b/packages/deepctl-core/src/deepctl_core/auth.py index ac1506dc..4b14c1fc 100644 --- a/packages/deepctl-core/src/deepctl_core/auth.py +++ b/packages/deepctl-core/src/deepctl_core/auth.py @@ -1032,16 +1032,18 @@ def list_profiles(self) -> ProfilesResult: for profile_name in self.config.list_profiles(): profile = self.config.get_profile(profile_name) - # Try to get API key from keyring first - api_key = None + # The keyring's key when it has one, else the config file's. + # Project ID is only stored in config, not keyring. project_id = profile.project_id - try: # Bounded off a terminal; a timeout falls back like an error. api_key = credentials.keyring_read(f"api-key.{profile_name}") - # Project ID is only stored in config, not keyring except Exception: - # Fall back to config + api_key = None + if not api_key: + # No entry, as with a key saved only in the config file + # while a keyring backend (Windows Credential Manager, say) + # is present. api_key = profile.api_key masked_key = None diff --git a/packages/deepctl-core/tests/unit/test_profiles_list_keys.py b/packages/deepctl-core/tests/unit/test_profiles_list_keys.py new file mode 100644 index 00000000..9903fd81 --- /dev/null +++ b/packages/deepctl-core/tests/unit/test_profiles_list_keys.py @@ -0,0 +1,59 @@ +"""``list_profiles`` shows the key a profile has, wherever it is kept.""" + +from __future__ import annotations + +from unittest.mock import Mock, patch + +import pytest +from deepctl_core import credentials +from deepctl_core.auth import AuthManager +from deepctl_core.config import Config + +FILE_KEY = "f" * 36 + "FILE" +RING_KEY = "r" * 36 + "RING" + + +def _config(file_key: str | None) -> Mock: + config = Mock(spec=Config) + config.config_path = "/mock/path/config.yaml" + config.profile = "default" + config.list_profiles.return_value = ["default"] + profile = Mock() + profile.api_key = file_key + profile.project_id = "proj" + profile.base_url = "https://api.deepgram.com" + config.get_profile.return_value = profile + return config + + +def _listed(file_key: str | None, keyring_read: object) -> str | None: + with ( + patch("deepctl_core.auth.httpx.Client"), + patch.object(credentials, "keyring_read", keyring_read), + ): + profiles = AuthManager(_config(file_key)).list_profiles().profiles + return profiles["default"].api_key + + +def test_key_only_in_config_is_shown_when_keyring_has_no_entry() -> None: + # A keyring backend that answers "no entry" (Windows Credential + # Manager with nothing stored) used to make this "Not set". + assert _listed(FILE_KEY, lambda _name: None) == "****FILE" + + +def test_keyring_key_wins_over_config() -> None: + assert _listed(FILE_KEY, lambda _name: RING_KEY) == "****RING" + + +@pytest.mark.parametrize( + "error", [RuntimeError("no backend"), credentials.KeyringTimeout()] +) +def test_keyring_error_falls_back_to_config(error: BaseException) -> None: + def failing(_name: str) -> str | None: + raise error + + assert _listed(FILE_KEY, failing) == "****FILE" + + +def test_no_key_anywhere_is_none() -> None: + assert _listed(None, lambda _name: None) is None From a82791a2044687046bf555e13b0e4553d289052a Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Mon, 2 Nov 2026 10:08:28 -0500 Subject: [PATCH 10/25] fix(config): count ~/.config on macOS when XDG_CONFIG_HOME names it On macOS platformdirs puts the user config under $XDG_CONFIG_HOME when it is set, but only ~/Library/Application Support could approve hosts in an agent session. A person with XDG_CONFIG_HOME=~/.config, a common setup, had approved hosts that never counted there. When XDG_CONFIG_HOME is exactly ~/.config under the account's own home (taken from the OS, not $HOME), that file is the one that counts, as on Linux. Any other value still keeps Application Support, so a file elsewhere never counts in an agent session. --- .../deepctl-core/src/deepctl_core/config.py | 17 ++++- .../tests/unit/test_trust_root_macos.py | 69 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 packages/deepctl-core/tests/unit/test_trust_root_macos.py diff --git a/packages/deepctl-core/src/deepctl_core/config.py b/packages/deepctl-core/src/deepctl_core/config.py index 5257aa9d..c5108fce 100644 --- a/packages/deepctl-core/src/deepctl_core/config.py +++ b/packages/deepctl-core/src/deepctl_core/config.py @@ -287,7 +287,14 @@ def _windows_folder(csidl: int) -> str | None: def _real_default_config_file() -> Path | None: """Where platformdirs puts the user config with no ``HOME`` or - ``XDG_CONFIG_HOME`` override: derived from the account itself.""" + ``XDG_CONFIG_HOME`` override: derived from the account itself. + + On macOS, platformdirs moves the config to ``$XDG_CONFIG_HOME`` when + that is set. Only the XDG default, ``~/.config`` under the account's + own home, is followed there: it is a fixed path, like the Linux one. + Any other value keeps ``~/Library/Application Support``, so a file + somewhere else (a repository, say) never counts in an agent session. + """ if sys.platform == "win32": base = _windows_folder(0x1C) # CSIDL_LOCAL_APPDATA if base is None: @@ -296,11 +303,17 @@ def _real_default_config_file() -> Path | None: home = _real_home() if home is None: return None + xdg_default = Path(home) / ".config" / "deepctl" / "config.yaml" if sys.platform == "darwin": + xdg = os.environ.get("XDG_CONFIG_HOME", "") + if os.path.isabs(xdg) and os.path.normpath(xdg) == os.path.join( + os.path.normpath(home), ".config" + ): + return xdg_default return ( Path(home) / "Library" / "Application Support" / "deepctl" / "config.yaml" ) - return Path(home) / ".config" / "deepctl" / "config.yaml" + return xdg_default def _trust_root_location() -> tuple[Path, Path] | None: diff --git a/packages/deepctl-core/tests/unit/test_trust_root_macos.py b/packages/deepctl-core/tests/unit/test_trust_root_macos.py new file mode 100644 index 00000000..7d73690e --- /dev/null +++ b/packages/deepctl-core/tests/unit/test_trust_root_macos.py @@ -0,0 +1,69 @@ +"""Which file can approve hosts in an agent session on macOS. + +Runs on any platform: ``sys.platform`` and the account's home are +patched, so this proves the choice of path, not a real macOS layout. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest +from deepctl_core import config as config_module + +HOME = "/Users/someone" +LIBRARY = Path(HOME) / "Library" / "Application Support" / "deepctl" / "config.yaml" +DOT_CONFIG = Path(HOME) / ".config" / "deepctl" / "config.yaml" + + +@pytest.fixture +def macos(monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(config_module, "_real_home", lambda: HOME) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + +def test_without_xdg_it_is_application_support(macos): + assert config_module._real_default_config_file() == LIBRARY + assert config_module._trust_root_location() == (Path(HOME), LIBRARY) + + +@pytest.mark.parametrize( + "value", [f"{HOME}/.config", f"{HOME}/.config/", f"{HOME}//.config/."] +) +def test_xdg_default_follows_the_config(macos, monkeypatch, value): + monkeypatch.setenv("XDG_CONFIG_HOME", value) + assert config_module._real_default_config_file() == DOT_CONFIG + assert config_module._trust_root_location() == (Path(HOME), DOT_CONFIG) + + +@pytest.mark.parametrize( + "value", + [ + "", + ".config", # relative: platformdirs ignores it too + "/tmp/repo/.config", + f"{HOME}/repo/.config", + f"{HOME}/.config/../repo", + "/Users/other/.config", + ], +) +def test_any_other_xdg_keeps_application_support(macos, monkeypatch, value): + monkeypatch.setenv("XDG_CONFIG_HOME", value) + assert config_module._real_default_config_file() == LIBRARY + + +def test_home_env_never_counts(macos, monkeypatch): + monkeypatch.setenv("HOME", "/tmp/fakehome") + monkeypatch.setenv("XDG_CONFIG_HOME", "/tmp/fakehome/.config") + assert config_module._real_default_config_file() == LIBRARY + + +def test_linux_is_unchanged(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(config_module, "_real_home", lambda: "/home/someone") + monkeypatch.setenv("XDG_CONFIG_HOME", "/tmp/elsewhere") + assert config_module._real_default_config_file() == Path( + "/home/someone/.config/deepctl/config.yaml" + ) From 47aceb9e6d0090f79db46c482b96d6d7c0fad316 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Mon, 2 Nov 2026 10:13:55 -0500 Subject: [PATCH 11/25] fix(config): wait for a locked config file once per run A read waits up to 5 s for a writer's lock on the config file, then skips the file with a warning. A command reads the file more than once, so a writer stuck holding the lock made `dg profiles --list` wait two or three times as long (about 16 s on macOS). Once a wait for a file has timed out, later reads of that file in the same run try the lock once and skip it straight away. A run now waits at most one bound. --- .../deepctl-core/src/deepctl_core/config.py | 19 ++++- .../tests/unit/test_config_busy_once.py | 83 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 packages/deepctl-core/tests/unit/test_config_busy_once.py diff --git a/packages/deepctl-core/src/deepctl_core/config.py b/packages/deepctl-core/src/deepctl_core/config.py index c5108fce..2a4a26ba 100644 --- a/packages/deepctl-core/src/deepctl_core/config.py +++ b/packages/deepctl-core/src/deepctl_core/config.py @@ -814,6 +814,14 @@ def _is_regular_fd(fd: int, st: os.stat_result | None = None) -> bool: return False +# (st_dev, st_ino) of files whose shared lock stayed taken for a whole wait +# in this process. A command reads the user config several times (load, +# then auth and profile lookups); each later read of such a file tries the +# lock once and gives up at once instead of waiting again, so a stuck +# writer costs one bounded wait per run. +_busy_files: set[tuple[int, int]] = set() + + def _flock_shared_bounded(fd: int) -> bool: """Take a shared flock on ``fd``, waiting about ``_LOCK_TIMEOUT`` s. @@ -831,15 +839,24 @@ def _flock_shared_bounded(fd: int) -> bool: import fcntl except ImportError: return False + try: + st = os.fstat(fd) + key: tuple[int, int] | None = (st.st_dev, st.st_ino) + except OSError: + key = None deadline = time.monotonic() + _LOCK_TIMEOUT while True: try: fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB) + if key is not None: + _busy_files.discard(key) return True except OSError as exc: if exc.errno not in (errno.EWOULDBLOCK, errno.EAGAIN): return False - if time.monotonic() >= deadline: + if key in _busy_files or time.monotonic() >= deadline: + if key is not None: + _busy_files.add(key) raise _ConfigBusy(errno.EWOULDBLOCK, _E_READ_BUSY) time.sleep(_LOCK_STEP / 5) diff --git a/packages/deepctl-core/tests/unit/test_config_busy_once.py b/packages/deepctl-core/tests/unit/test_config_busy_once.py new file mode 100644 index 00000000..b8086903 --- /dev/null +++ b/packages/deepctl-core/tests/unit/test_config_busy_once.py @@ -0,0 +1,83 @@ +"""A config file another process keeps locked costs one bounded wait per +run, not one per read.""" + +from __future__ import annotations + +import os +import sys +import time + +import pytest +import yaml +from deepctl_core import config as config_module +from deepctl_core import output + +fcntl = pytest.importorskip("fcntl") + +pytestmark = pytest.mark.skipif( + not sys.platform.startswith("linux"), reason="XDG layout (Linux)" +) + +WAIT = 0.4 + + +@pytest.fixture +def held(tmp_path, monkeypatch): + monkeypatch.setattr(config_module, "_pending_notices", []) + monkeypatch.setattr(config_module, "_seen_notices", set()) + monkeypatch.setattr(config_module, "_busy_files", set()) + monkeypatch.setattr(config_module, "_LOCK_TIMEOUT", WAIT) + home = tmp_path / "home" + (home / ".config" / "deepctl").mkdir(parents=True) + cfg = home / ".config" / "deepctl" / "config.yaml" + cfg.write_text(yaml.safe_dump({"profiles": {"default": {"project_id": "p"}}})) + os.chmod(cfg, 0o600) + monkeypatch.setattr(config_module, "_real_home", lambda: str(home)) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setattr(output, "is_ai_agent", lambda: False) + # A writer stuck in the middle of a rewrite. + fd = os.open(cfg, os.O_RDWR) + fcntl.flock(fd, fcntl.LOCK_EX) + yield cfg + os.close(fd) + + +def test_second_read_of_a_busy_file_does_not_wait_again(held): + with config_module._open_config(held) as f: + started = time.monotonic() + with pytest.raises(config_module._ConfigBusy): + f.load() + first = time.monotonic() - started + with config_module._open_config(held) as f: + started = time.monotonic() + with pytest.raises(config_module._ConfigBusy): + f.load() + second = time.monotonic() - started + assert first >= WAIT + assert second < WAIT / 2 + + +def test_freed_lock_is_read_again(held, tmp_path): + with config_module._open_config(held) as f, pytest.raises( + config_module._ConfigBusy + ): + f.load() + # A different file is not affected by the first one's wait. + other = tmp_path / "other.yaml" + other.write_text("a: 1\n") + with config_module._open_config(other) as f: + assert f.load() == {"a": 1} + + +def test_a_run_that_loads_the_config_twice_waits_once(held): + from deepctl_core.auth import AuthManager + from deepctl_core.config import Config + + started = time.monotonic() + AuthManager(Config()).list_profiles() + AuthManager(Config()).list_profiles() + took = time.monotonic() - started + # Before, every read waited the full bound: `dg profiles --list` took + # two or three times the 5 s default. + assert took < WAIT * 1.75, took From 1f1c66d925313a5abb1cdd8caec37460c18d3e7c Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Mon, 2 Nov 2026 10:28:28 -0500 Subject: [PATCH 12/25] fix(listen,speak): name Flux models, not API versions Flux errors and the speak --model help said "Flux STT (listen v2)", "Speak v2" and "Speak v1": API names nobody types. They now name the model families (Flux models, flux-* and aura-* voices) and say what to do instead. --- .../src/deepctl_cmd_listen/command.py | 14 +++++++------- .../src/deepctl_cmd_speak/command.py | 12 +++++------- .../tests/unit/test_speak_command.py | 2 +- src/deepctl/_manifest.d/speak.json | 2 +- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py b/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py index af2f5576..a53dda93 100644 --- a/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py +++ b/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py @@ -667,15 +667,15 @@ def handle( # a header dump. Say so up front instead. if api_version >= 2 and mode in ("prerecorded_file", "prerecorded_url"): raise click.ClickException( - f"Flux STT ({model}) is streaming-only and cannot transcribe " - "a file or URL. Use a live source (--mic, stdin, or '-'), or " - "pick a v1 model (e.g. nova-3) for pre-recorded audio." + f"Flux models ({model}) are streaming-only and can't " + "transcribe a file or URL. Use a live source (--mic, or - for " + "stdin), or pick another model (e.g. nova-3) for recorded " + "audio." ) if api_version >= 2 and channels != 1: raise click.ClickException( - f"--channels {channels} is not supported by Flux STT ({model}); " - "listen v2 accepts mono audio only (--channels 1)." + f"Flux models stream mono audio only: use --channels 1, not {channels}." ) # Flux STT (listen v2) only accepts a narrow --redact vocabulary; the @@ -686,8 +686,8 @@ def handle( if bad: allowed = " or ".join(f"'{v}'" for v in _FLUX_REDACT) raise click.ClickException( - f"--redact {', '.join(bad)} is not supported by Flux STT " - f"({model}); listen v2 accepts only {allowed}." + f"Flux models accept only {allowed} for --redact, not " + f"{', '.join(bad)}." ) # ── Caption format ───────────────────────────────────────────── diff --git a/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py b/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py index caeb1692..eaf7ebda 100644 --- a/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py +++ b/packages/deepctl-cmd-speak/src/deepctl_cmd_speak/command.py @@ -919,10 +919,9 @@ def get_arguments(self) -> list[dict[str, Any]]: { "names": ["--model", "-m"], "help": ( - "TTS model. flux-* = Flux TTS / Speak v2 (WebSocket " - "streaming; default flux-alexis-en); aura-* = Speak v1 " - "(REST batch, e.g. aura-2-asteria-en). " - "See voices: dg speak --list-voices" + "Voice model: flux-* voices stream as they speak " + "(default flux-alexis-en); aura-* voices return the whole " + "file. See voices: dg speak --list-voices" ), "type": str, "is_option": True, @@ -1237,9 +1236,8 @@ def handle( # so the failure exits non-zero in every output mode. if not is_flux and (speed is not None or expressivity is not None): raise click.ClickException( - "--speed and --expressivity are only supported for Flux TTS " - "(Speak v2) models (flux-*). They are not available for " - f"Speak v1 model '{model}'." + "--speed and --expressivity work only with Flux voices " + f"(flux-*), not '{model}'." ) if speed is not None and speed not in _FLUX_SPEEDS: allowed = ", ".join(f"{s:.2f}" for s in _FLUX_SPEEDS) diff --git a/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py b/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py index b513fd84..330368f6 100644 --- a/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py +++ b/packages/deepctl-cmd-speak/tests/unit/test_speak_command.py @@ -654,7 +654,7 @@ def test_handle_speed_rejected_for_aura( mock_sys.stdin.isatty.return_value = True mock_sys.stdout.isatty.return_value = True - with pytest.raises(click.ClickException, match="only supported for Flux"): + with pytest.raises(click.ClickException, match="work only with Flux voices"): command.handle( config=mock_config, auth_manager=mock_auth_manager, diff --git a/src/deepctl/_manifest.d/speak.json b/src/deepctl/_manifest.d/speak.json index b91768e1..0e4501d5 100644 --- a/src/deepctl/_manifest.d/speak.json +++ b/src/deepctl/_manifest.d/speak.json @@ -205,7 +205,7 @@ "is_flag": false, "count": false, "hidden": false, - "help": "TTS model. flux-* = Flux TTS / Speak v2 (WebSocket streaming; default flux-alexis-en); aura-* = Speak v1 (REST batch, e.g. aura-2-asteria-en). See voices: dg speak --list-voices" + "help": "Voice model: flux-* voices stream as they speak (default flux-alexis-en); aura-* voices return the whole file. See voices: dg speak --list-voices" }, { "class": "option", From 366017b8cc847b7194b129c77dbfbd4a0dafafe0 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Mon, 2 Nov 2026 10:31:06 -0500 Subject: [PATCH 13/25] fix(listen): give each OS its own stdin and microphone help The stdin banner said to send EOF with Ctrl+D, which doesn't end input in a Windows console; Windows now gets "Ctrl+Z, then Enter". A refused microphone printed PortAudio's text and macOS-only advice. It now uses the catalogue's mic-denied message and hint, plus the setting's place on macOS or Windows. Other microphone errors keep their own text. --- .../src/deepctl_cmd_listen/command.py | 33 +++++++++--- .../src/deepctl_cmd_listen/messages.py | 12 +++++ .../tests/unit/test_platform_text.py | 51 +++++++++++++++++++ 3 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 packages/deepctl-cmd-listen/tests/unit/test_platform_text.py diff --git a/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py b/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py index a53dda93..74bc4631 100644 --- a/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py +++ b/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/command.py @@ -285,6 +285,26 @@ def _prog() -> str: return "dg" +def _finish_keys() -> str: + """The keys that end typed input: Ctrl+D doesn't in a Windows console.""" + return "Ctrl+Z, then Enter," if sys.platform == "win32" else "Ctrl+D" + + +def _mic_denied_text() -> str: + """The microphone was refused: what happened, what to do, and where + that setting lives on this OS.""" + hint = msg.A_MIC_DENIED if (_agentic or agent_mode()) else msg.H_MIC_DENIED + lines = [msg.E_MIC_DENIED.format(prog=_prog()), hint] + if sys.platform == "darwin": + lines.append("macOS: System Settings → Privacy & Security → Microphone") + elif sys.platform == "win32": + lines.append( + "Windows: Settings → Privacy & security → Microphone, and turn on " + "'Let desktop apps access your microphone'" + ) + return "\n".join(lines) + + class ListenCommand(BaseCommand): """Unified speech-to-text command supporting files, URLs, mic, and streams.""" @@ -1140,17 +1160,13 @@ def _stream_mic( raise except Exception as e: err = str(e) - msg_text = f"Microphone error: {err}" if ( "Invalid input device" in err or "PortAudio" in err or "No Default Input" in err ): - msg_text += ( - "\n\nOn macOS, make sure your terminal has microphone access:" - "\n System Settings → Privacy & Security → Microphone" - ) - return BaseResult(status="error", message=msg_text) + return BaseResult(status="error", message=_mic_denied_text()) + return BaseResult(status="error", message=f"Microphone error: {err}") finally: early.restore() @@ -1321,7 +1337,7 @@ def _stream_stdin( note = f"Model: {model} · Language: {language}" if diarize: note += " · Speaker labels enabled" - note += " · Ctrl+D to finish" + note += f" · {_finish_keys()} to finish" try: caption_writer.print_header( note=note if caption_format == "webvtt" else None @@ -1330,7 +1346,8 @@ def _stream_stdin( live.silence_stdout() else: status.print( - f"[green]✓[/green] Streaming stdin · {model} · send EOF (Ctrl+D) to finish" + f"[green]✓[/green] Streaming stdin · {model} · press " + f"{_finish_keys()} to finish" ) _freeze_at_exit() diff --git a/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/messages.py b/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/messages.py index bf1b8ac9..8ea85364 100644 --- a/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/messages.py +++ b/packages/deepctl-cmd-listen/src/deepctl_cmd_listen/messages.py @@ -137,3 +137,15 @@ "Not using {path} for saved results: it isn't a private directory owned " "by you. Using a new temporary directory instead." ) + +# The microphone was refused (a PortAudio "invalid input device" or "no +# default input"). Catalogue mic_not_allowed; the code itself ships later. +E_MIC_DENIED = "{prog} can't use the microphone: access was denied." +H_MIC_DENIED = ( + "Allow microphone access for your terminal app in your system privacy " + "settings, then try again." +) +A_MIC_DENIED = ( + "Ask the user to allow microphone access for their terminal, or " + "transcribe a file instead." +) diff --git a/packages/deepctl-cmd-listen/tests/unit/test_platform_text.py b/packages/deepctl-cmd-listen/tests/unit/test_platform_text.py new file mode 100644 index 00000000..f9d77e45 --- /dev/null +++ b/packages/deepctl-cmd-listen/tests/unit/test_platform_text.py @@ -0,0 +1,51 @@ +"""Text that depends on the OS: how to end stdin, where mic access lives. + +``sys.platform`` is patched, so this proves the wording each OS gets, not +the console's own behaviour. +""" + +from __future__ import annotations + +import sys + +import pytest +from deepctl_cmd_listen import command +from deepctl_cmd_listen import messages as msg + + +@pytest.mark.parametrize( + ("platform", "keys"), + [("win32", "Ctrl+Z, then Enter,"), ("linux", "Ctrl+D"), ("darwin", "Ctrl+D")], +) +def test_finish_keys(monkeypatch, platform, keys): + monkeypatch.setattr(sys, "platform", platform) + assert command._finish_keys() == keys + + +@pytest.mark.parametrize( + ("platform", "where"), + [ + ("darwin", "macOS: System Settings → Privacy & Security → Microphone"), + ("win32", "Windows: Settings → Privacy & security → Microphone"), + ("linux", None), + ], +) +def test_mic_denied_text(monkeypatch, platform, where): + monkeypatch.setattr(sys, "platform", platform) + monkeypatch.setattr(command, "_agentic", False) + monkeypatch.setattr(command, "agent_mode", lambda: False) + monkeypatch.setattr(command, "_prog", lambda: "dg") + lines = command._mic_denied_text().split("\n") + assert lines[0] == "dg can't use the microphone: access was denied." + assert lines[1] == msg.H_MIC_DENIED + if where is None: + assert len(lines) == 2 + else: + assert lines[2].startswith(where) + assert "PortAudio" not in "\n".join(lines) + + +def test_mic_denied_text_for_an_agent(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(command, "agent_mode", lambda: True) + assert command._mic_denied_text().split("\n")[1] == msg.A_MIC_DENIED From 2f690c73c08047d0543cf09cd38017b26de4bfb6 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Mon, 2 Nov 2026 10:28:41 -0500 Subject: [PATCH 14/25] test: give timing checks room on a busy host Some tests that came from earlier releases measured wall-clock time with bounds a loaded machine can miss. Each check keeps what it proves, with a wider margin: - skills: the lock wait is 2 s, so one wait (2 s) and one per tool (4 s) stay apart; a stale lock is taken well inside a 10 s timeout. - config save: one 5 s lock wait is under 8.5 s; two would not be. - FileInfo creation is measured in CPU time, 2 s for 1000 models. - dg mcp e2e: no connection attempt within 12 s (one waits 15 s). --- packages/deepctl-cmd-mcp/tests/e2e/test_stdio_e2e.py | 2 +- .../tests/unit/test_skills_command.py | 5 +++-- packages/deepctl-core/tests/unit/test_config_save.py | 2 +- .../deepctl-core/tests/unit/test_skill_generator.py | 10 +++++----- .../deepctl-shared-utils/tests/unit/test_file_info.py | 9 +++++---- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/deepctl-cmd-mcp/tests/e2e/test_stdio_e2e.py b/packages/deepctl-cmd-mcp/tests/e2e/test_stdio_e2e.py index 17f8164a..93d4e82f 100644 --- a/packages/deepctl-cmd-mcp/tests/e2e/test_stdio_e2e.py +++ b/packages/deepctl-cmd-mcp/tests/e2e/test_stdio_e2e.py @@ -328,7 +328,7 @@ def test_exits_on_eof_without_connecting(tmp_path: Path, argv: list[str]) -> Non except (BlockingIOError, OSError): connected = False assert proc.returncode == 0, proc.stderr - assert elapsed < 5 + assert elapsed < 12 # a connect attempt would wait out its 15 s assert not connected replies = [json.loads(line) for line in proc.stdout.splitlines()] assert [r["id"] for r in replies] == [0, 1] diff --git a/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py b/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py index 4abf3b5f..0b5578cf 100644 --- a/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py +++ b/packages/deepctl-cmd-skills/tests/unit/test_skills_command.py @@ -461,7 +461,8 @@ def test_remove_waits_for_the_lock_once_for_all_tools( roots = [gen(c).skills_root() for c in ("claude", "cursor")] before = sg._STATE_FILE.read_bytes(), [sha_tree(r) for r in roots] capsys.readouterr() - monkeypatch.setattr(sg, "_LOCK_TIMEOUT", 0.5) + # 2 s per wait: one wait is 2 s, one per tool would be 4 s. + monkeypatch.setattr(sg, "_LOCK_TIMEOUT", 2.0) calls = [] real_remove = sg.remove_tool monkeypatch.setattr( @@ -479,7 +480,7 @@ def test_remove_waits_for_the_lock_once_for_all_tools( sg.msvcrt.locking(fd, sg.msvcrt.LK_UNLCK, 1) os.close(fd) assert (exc.value.exit_code, exc.value.message) == (1, _msg("E27")) - assert 0.5 <= took < 1.0 # One wait, not one per tool. + assert 2.0 <= took < 3.9 # One wait, not one per tool. assert calls == [] and _msg("E27") not in err_text(capsys) assert (sg._STATE_FILE.read_bytes(), [sha_tree(r) for r in roots]) == before diff --git a/packages/deepctl-core/tests/unit/test_config_save.py b/packages/deepctl-core/tests/unit/test_config_save.py index 191407c7..2070e0ca 100644 --- a/packages/deepctl-core/tests/unit/test_config_save.py +++ b/packages/deepctl-core/tests/unit/test_config_save.py @@ -989,7 +989,7 @@ def busy(src, dst): assert "another process is writing this config, so nothing was saved" in str( err.value ) - assert 4.0 <= elapsed < 6.5 + assert 4.0 <= elapsed < 8.5 # one 5 s wait, not two monkeypatch.setattr(config_module, "_LOCK_TIMEOUT", 0.2) config.save(implicit=True) # returns without writing assert path.read_text() == original diff --git a/packages/deepctl-core/tests/unit/test_skill_generator.py b/packages/deepctl-core/tests/unit/test_skill_generator.py index 66031ba2..8a78fc07 100644 --- a/packages/deepctl-core/tests/unit/test_skill_generator.py +++ b/packages/deepctl-core/tests/unit/test_skill_generator.py @@ -2712,11 +2712,11 @@ def test_a_killed_holder_never_leaves_a_stale_lock(self, monkeypatch): with lock_child(monkeypatch) as (child, _): child.kill() # SIGKILL on POSIX, TerminateProcess on Windows. child.join(30) - monkeypatch.setattr(sg, "_LOCK_TIMEOUT", 1.0) + monkeypatch.setattr(sg, "_LOCK_TIMEOUT", 10.0) start = time.monotonic() with sg._state_lock(): pass - assert time.monotonic() - start < 1.0 + assert time.monotonic() - start < 5.0 assert sg._STATE_FILE.with_name("skills.json.lock").exists() def test_a_killed_replacement_keeps_its_record_and_old_copy( @@ -2842,7 +2842,7 @@ def fail(*a): took = time.monotonic() - start lock = sg._STATE_FILE.with_name("skills.json.lock") assert str(exc.value) == _msg(key, lock=lock, reason=os.strerror(code)) - assert took < 2.0 if key == "E28" else took >= 0.5 + assert took < 3.5 if key == "E28" else took >= 0.5 assert state_bytes() is None and not root().exists() def test_ctrl_c_while_waiting_closes_the_lock_and_writes_nothing( @@ -2906,11 +2906,11 @@ def locked_then_interrupted(*a): m.setattr(owner, name, locked_then_interrupted) with pytest.raises(KeyboardInterrupt), sg._state_lock(): pass - monkeypatch.setattr(sg, "_LOCK_TIMEOUT", 5.0) + monkeypatch.setattr(sg, "_LOCK_TIMEOUT", 10.0) start = time.monotonic() with sg._state_lock(): # The interrupted take did not keep the file locked. pass - assert time.monotonic() - start < (5.0 if win else 1.0) + assert time.monotonic() - start < 5.0 assert getattr(sg._LOCAL, "fd", None) is None @POSIX diff --git a/packages/deepctl-shared-utils/tests/unit/test_file_info.py b/packages/deepctl-shared-utils/tests/unit/test_file_info.py index 07afe3e6..c7a59f50 100644 --- a/packages/deepctl-shared-utils/tests/unit/test_file_info.py +++ b/packages/deepctl-shared-utils/tests/unit/test_file_info.py @@ -236,15 +236,16 @@ def test_file_info_creation_performance( iterations = 1000 - start_time = time.time() + # CPU time, so a busy host doesn't count against it. + start_time = time.process_time() for _ in range(iterations): FileInfo(**valid_file_data) - end_time = time.time() + end_time = time.process_time() total_time = end_time - start_time - # Assert reasonable performance (should be < 1ms per creation) - assert total_time < 1.0 # Less than 1 second for 1000 iterations + # Assert reasonable performance (should be < 2 ms per creation) + assert total_time < 2.0 # Less than 2 s of CPU for 1000 iterations avg_time = total_time / iterations assert avg_time < 0.001 # Less than 1ms per creation From f83a8c6bb941a3ceafa52c491e9c7c83dde3bb0a Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Mon, 2 Nov 2026 14:12:34 -0500 Subject: [PATCH 15/25] fix(cli): use coded messages for cancel and unexpected errors Ctrl-C and Ctrl-D now print "Cancelled." (exit 2), and an exception nothing else handles prints "Command failed: " with a next step (exit 1). In agent mode both print exactly two prefixed lines, ERROR: and HINT:, with the agent hint from the catalogue. The early Ctrl-C path in main() prints the same lines without rich or Click. --- .../deepctl-core/src/deepctl_core/messages.py | 11 ++++ src/deepctl/_cli.py | 39 +++++++++++++- src/deepctl/main.py | 28 +++++++--- tests/unit/test_entry_point.py | 23 ++++++-- tests/unit/test_main.py | 54 +++++++++++++++++++ 5 files changed, 144 insertions(+), 11 deletions(-) diff --git a/packages/deepctl-core/src/deepctl_core/messages.py b/packages/deepctl-core/src/deepctl_core/messages.py index 10639f8c..a19aac1c 100644 --- a/packages/deepctl-core/src/deepctl_core/messages.py +++ b/packages/deepctl-core/src/deepctl_core/messages.py @@ -235,6 +235,17 @@ "--config." ) +# ── Cancelled and unexpected errors (the root handler) ─────────── + +E_CANCELLED = "Cancelled." +A_CANCELLED = "The user cancelled. Don't retry unless they ask." +E_INTERNAL = "Command failed: {exception_message}" +H_INTERNAL = ( + "Run the command again with --verbose for details, and report it at " + "https://github.com/deepgram/cli/issues." +) +A_INTERNAL = "This is a bug in dg. Tell the user and include the command you ran." + # ── Project file deepgram.yaml ─────────────────────────────────── W_PF = "Ignored {key} in {project_file}: {reason}" diff --git a/src/deepctl/_cli.py b/src/deepctl/_cli.py index 1bdd5555..c5862c6e 100644 --- a/src/deepctl/_cli.py +++ b/src/deepctl/_cli.py @@ -1011,6 +1011,41 @@ def _safe_console_print(message: str) -> None: pass +def _agent_lines(error: str, hint: str) -> None: + """Agent mode: exactly two prefixed lines (ERROR:, HINT:) on stderr.""" + try: + click.echo(f"ERROR: {error}\nHINT: {hint}", err=True, color=False) + except (OSError, ValueError): + pass + + +def _print_cancelled() -> None: + """The line for Ctrl-C/Ctrl-D (exit 2): ``cancelled`` in the catalogue.""" + from deepctl_core import messages as msg + from deepctl_core.output import is_agentic + + if is_agentic(): + _agent_lines(msg.E_CANCELLED, msg.A_CANCELLED) + return + _safe_console_print(f"\n[yellow]{msg.E_CANCELLED}[/yellow]") + + +def _print_internal_error(exc: BaseException) -> None: + """The catch-all for an exception nothing else handled (exit 1).""" + from deepctl_core import messages as msg + from deepctl_core.output import is_agentic + + message = msg.E_INTERNAL.format(exception_message=str(exc) or type(exc).__name__) + if is_agentic(): + _agent_lines(message, msg.A_INTERNAL) + return + from rich.markup import escape + + # The exception text can hold anything: text, not markup. + _safe_console_print(f"[red]Error: {escape(message)}[/red]") + _safe_console_print(f" [dim]{escape(msg.H_INTERNAL)}[/dim]") + + def _print_usage(ctx: click.Context | None) -> None: """Click's usage line and "Try ... --help" before a usage error, so the program name a user typed (or ``DEEPCTL_PROG_NAME``) shows up. @@ -1282,7 +1317,7 @@ def run( # Click wraps a Ctrl-C/Ctrl-D raised inside cli() in Abort when # standalone_mode=False, so a mid-command interrupt arrives here as # Abort, not KeyboardInterrupt. Both are user cancellation: exit 2. - _safe_console_print("\n[yellow]Operation cancelled by user[/yellow]") + _print_cancelled() sys.exit(2) except click.UsageError as e: # Keep the published exit-code contract (1 = error), but explain the @@ -1320,6 +1355,6 @@ def run( pass sys.exit(e.exit_code) except Exception as e: - _safe_console_print(f"[red]Error: {e}[/red]") + _print_internal_error(e) _report_error(e) sys.exit(1) # 1 = error; 2 is reserved for user interrupt diff --git a/src/deepctl/main.py b/src/deepctl/main.py index 91c152cc..99c0537d 100644 --- a/src/deepctl/main.py +++ b/src/deepctl/main.py @@ -191,6 +191,26 @@ def safe_output_streams() -> None: continue # an unknown encoding, or a closed stream +def _write_cancelled() -> None: + """run()'s cancel lines, written without rich or Click.""" + text = "\nCancelled.\n" + try: + from deepctl_core import messages as msg + from deepctl_core.agents import is_agentic + + if is_agentic(): + text = f"ERROR: {msg.E_CANCELLED}\nHINT: {msg.A_CANCELLED}\n" + else: + text = f"\n{msg.E_CANCELLED}\n" + except (Exception, KeyboardInterrupt): # half-loaded: keep the default + pass + try: + sys.stderr.write(text) + sys.stderr.flush() + except (AttributeError, OSError, ValueError): + pass + + def _print_version() -> None: if sys.stdout is None: # started with stdout closed (`dg --version >&-`) return @@ -246,12 +266,8 @@ def main(prog: "str | None" = None) -> None: from deepctl._cli import run except KeyboardInterrupt: # Ctrl-C while the CLI is still loading: cancelled, exit 2, the same - # line run() prints (rich may not be loaded yet), and no traceback. - try: - sys.stderr.write("\nOperation cancelled by user\n") - sys.stderr.flush() - except (AttributeError, OSError, ValueError): - pass + # lines run() prints (rich may not be loaded yet), and no traceback. + _write_cancelled() sys.exit(2) run(args, started_at, prog) diff --git a/tests/unit/test_entry_point.py b/tests/unit/test_entry_point.py index 4f05f6f2..37283d2e 100644 --- a/tests/unit/test_entry_point.py +++ b/tests/unit/test_entry_point.py @@ -258,13 +258,30 @@ def test_import_httpx_skips_its_cli(self, tmp_path: Path) -> None: class TestInterruptWhileLoading: + @pytest.mark.parametrize( + ("dg_agent", "expected"), + [ + ("0", "\nCancelled.\n"), + ( + "1", + "ERROR: Cancelled.\n" + "HINT: The user cancelled. Don't retry unless they ask.\n", + ), + ], + ) def test_ctrl_c_while_the_cli_loads_is_cancelled_quietly( - self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + self, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + dg_agent: str, + expected: str, ) -> None: """A Ctrl-C before run() is entered is cancelled: exit 2, run()'s - cancel line, no traceback.""" + cancel lines, no traceback.""" from deepctl import main as entry + monkeypatch.setenv("DG_AGENT", dg_agent) + class Interrupts: def find_spec(self, name: str, *args: object) -> None: if name == "deepctl._cli": @@ -280,5 +297,5 @@ def find_spec(self, name: str, *args: object) -> None: assert exc.value.code == 2 captured = capsys.readouterr() assert "Traceback" not in captured.err - assert captured.err == "\nOperation cancelled by user\n" + assert captured.err == expected assert captured.out == "" diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index ab2c84b3..e67c5ab6 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -434,6 +434,60 @@ def test_global_option_hint_leaves_out_value_for_flags(self): ) +class TestRootHandlerMessages: + """Cancel and the catch-all print the catalogue's coded messages.""" + + def _run(self, capsys, error, *, agentic): + from deepctl.main import main + + with ( + patch("sys.argv", ["dg", "models"]), + patch("deepctl_core.output.is_agentic", return_value=agentic), + patch.object(_cli_mod, "cli", side_effect=error), + patch.object(_cli_mod, "_report_error"), + pytest.raises(SystemExit) as exc_info, + ): + main() + captured = capsys.readouterr() + assert captured.out == "" + return exc_info.value.code, captured.err + + def test_cancel_human(self, capsys): + code, err = self._run(capsys, click.exceptions.Abort(), agentic=False) + assert code == 2 + assert err == "\nCancelled.\n" + + def test_cancel_agent_mode_prints_two_prefixed_lines(self, capsys): + code, err = self._run(capsys, KeyboardInterrupt(), agentic=True) + assert code == 2 + assert err.splitlines() == [ + "ERROR: Cancelled.", + "HINT: The user cancelled. Don't retry unless they ask.", + ] + + def test_unexpected_error_human_gives_next_step(self, capsys): + code, err = self._run(capsys, RuntimeError("boom [red]x"), agentic=False) + assert code == 1 + flat = " ".join(err.split()) + assert "Error: Command failed: boom [red]x" in flat + assert ( + "Run the command again with --verbose for details, and report it " + "at https://github.com/deepgram/cli/issues." + ) in flat + + def test_unexpected_error_agent_mode(self, capsys): + code, err = self._run(capsys, RuntimeError("boom"), agentic=True) + assert code == 1 + assert err.splitlines() == [ + "ERROR: Command failed: boom", + "HINT: This is a bug in dg. Tell the user and include the command you ran.", + ] + + def test_unexpected_error_without_text_names_its_type(self, capsys): + _, err = self._run(capsys, RuntimeError(), agentic=True) + assert err.splitlines()[0] == "ERROR: Command failed: RuntimeError" + + class TestRichTraceback: """An uncaught exception gets a rich traceback, without frame locals. From 930e34555d131993d4548c7bb35ea99d83e46ee9 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Fri, 6 Nov 2026 21:02:13 -0500 Subject: [PATCH 16/25] test(debug-probe): run the ignored-SIGINT check only on Linux The check reads the SigIgn mask from /proc/self/status, which exists only on Linux, so on macOS and Windows it failed opening the file. It now skips there and, on Linux, always checks the mask. Co-Authored-By: Claude Opus 5.5 --- .../deepctl-cmd-debug-probe/tests/unit/test_probe.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/deepctl-cmd-debug-probe/tests/unit/test_probe.py b/packages/deepctl-cmd-debug-probe/tests/unit/test_probe.py index 4800b470..eb4c1cb7 100644 --- a/packages/deepctl-cmd-debug-probe/tests/unit/test_probe.py +++ b/packages/deepctl-cmd-debug-probe/tests/unit/test_probe.py @@ -2,6 +2,7 @@ import asyncio import json +import sys from unittest.mock import Mock, patch import pytest @@ -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.""" @@ -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): From 4bc6cc75fe92727304adf691b8fc76e752480c64 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Fri, 6 Nov 2026 21:02:47 -0500 Subject: [PATCH 17/25] fix(plugin): refuse a plugin named like a built-in alias before importing it A plugin entry point named like a built-in command was refused before its module was imported, but one named like a built-in alias, such as debug-audio, was imported first and only refused once loaded. The check before import now includes the top-level aliases too, so none of that plugin's code runs. Co-Authored-By: Claude Opus 5.5 --- packages/deepctl-core/src/deepctl_core/plugin_manager.py | 6 ++++-- packages/deepctl-core/tests/unit/test_plugin_manager.py | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/deepctl-core/src/deepctl_core/plugin_manager.py b/packages/deepctl-core/src/deepctl_core/plugin_manager.py index 40390201..03ead454 100644 --- a/packages/deepctl-core/src/deepctl_core/plugin_manager.py +++ b/packages/deepctl-core/src/deepctl_core/plugin_manager.py @@ -338,7 +338,8 @@ def _refused_by_entry_point_name( ) -> bool: """Whether an entry point is refused by its own name, before loading. - An entry point named like a built-in or reserved command must not be + An entry point named like a built-in or reserved command, or (at the + top level) like a built-in alias such as ``debug-audio``, must not be imported unless ``_admit`` would register it under that name. Only a taken name is checked here: an entry point's name isn't always its command's name (``my_tool`` may register ``my-tool``), so the full @@ -347,7 +348,8 @@ def _refused_by_entry_point_name( name = getattr(entry_point, "name", None) if type(name) is not str or _VALID_PLUGIN_NAME.fullmatch(name) is None: return False - if name not in builtin_modules and name not in reserved: + alias = not path_prefix and name in BUILTIN_ALIASES + if name not in builtin_modules and name not in reserved and not alias: return False kind = self._admit(name, entry_point, builtin_modules, reserved, path_prefix) return kind is None diff --git a/packages/deepctl-core/tests/unit/test_plugin_manager.py b/packages/deepctl-core/tests/unit/test_plugin_manager.py index f2ed8809..ba3dcd88 100644 --- a/packages/deepctl-core/tests/unit/test_plugin_manager.py +++ b/packages/deepctl-core/tests/unit/test_plugin_manager.py @@ -1634,6 +1634,13 @@ def test_reserved_name_is_refused_when_the_builtin_is_missing(self, capsys): assert "login" not in group.commands assert "Ignored the plugin command 'login'" in _err(capsys) + def test_plugin_named_like_a_builtin_alias_is_never_imported(self, capsys): + shadow = _unloadable_entry_point("debug-audio", "evil_audio:Audio", "evil") + group = _load(PluginManager(), plugin_eps=[shadow]) + shadow.load.assert_not_called() + assert "debug-audio" not in group.commands + assert "Ignored the plugin command 'debug-audio'" in _err(capsys) + def test_stray_commands_entry_point_is_never_imported(self): stray = _unloadable_entry_point("login", "evil_login:Login", "deepctl-core") stray.group = "deepctl.commands" From 2df00984149f3aa695556a087e72b258ca650e38 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Fri, 6 Nov 2026 21:03:00 -0500 Subject: [PATCH 18/25] fix(login): check the base URL before dg login --complete saves a key dg login checks that the API base URL is trusted before it starts a device login, but dg login --complete went straight to polling and saving the approved key. It now runs the same check first, so an untrusted DEEPGRAM_BASE_URL or --base-url stops it before any request, and the pending login is kept for a retry. Co-Authored-By: Claude Opus 5.5 --- .../src/deepctl_cmd_login/command.py | 6 ++++++ .../tests/unit/test_login_device_agent.py | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py index 8869937b..b45de117 100644 --- a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py +++ b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py @@ -1111,6 +1111,12 @@ def _complete( stop = _preflight(config) if stop is not None: return stop + # The approved key is saved for this profile's base URL: refuse an + # untrusted one before polling, as a new login does. + try: + auth_manager.check_base_url() + except CredentialHostError as e: + return CodedErrorResult(error=e.error, code=e.code, hint=e.hint) prog = _prog() shown = _shown_prog(prog) loaded = load_pending(agent=is_ai_agent()) diff --git a/packages/deepctl-cmd-login/tests/unit/test_login_device_agent.py b/packages/deepctl-cmd-login/tests/unit/test_login_device_agent.py index 527da2dd..e8a49737 100644 --- a/packages/deepctl-cmd-login/tests/unit/test_login_device_agent.py +++ b/packages/deepctl-cmd-login/tests/unit/test_login_device_agent.py @@ -358,6 +358,18 @@ def test_complete_never_sends_the_code_to_another_login_host(env, monkeypatch): assert [r.split()[0] for r in env["fake"].requests] == ["code"] +def test_complete_refuses_an_untrusted_base_url_before_polling(env, monkeypatch): + write_user(env) + run(env) + env["fake"].answers = [approved()] + monkeypatch.setenv("DEEPGRAM_BASE_URL", "https://api.evil.example") + _, result = run(env, complete=True) + assert result.code == "foreign_host" + assert [r.split()[0] for r in env["fake"].requests] == ["code"] + assert env["keyring"] == {} + assert pending_path(env).exists() + + def test_complete_with_api_key_is_a_usage_error(env): import click From 02ca0e26658fc5239efd6f8e0572dd48158a5846 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Fri, 6 Nov 2026 21:03:16 -0500 Subject: [PATCH 19/25] test: merge the two fixtures that clear AI agent markers The speak tests had their own autouse fixture that cleared the agent markers and DG_AGENT, which the root fixtures already clear for every test. It is removed, and the remaining fixture's docstring covers the agent output rules the speak tests relied on. Co-Authored-By: Claude Opus 5.5 --- conftest.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/conftest.py b/conftest.py index 02c51581..20c94591 100644 --- a/conftest.py +++ b/conftest.py @@ -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 @@ -57,19 +59,6 @@ def _clean_deepgram_env(monkeypatch): monkeypatch.delenv("DG_AGENT", 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 ``agent_mode`` 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", "DG_AGENT"): - monkeypatch.delenv(name, raising=False) - - @pytest.fixture(autouse=True) def _disable_telemetry(monkeypatch): """Keep tests that call `main()` from initializing real telemetry. From 4eac83550952c616cb674825b8a7dc82c4568698 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Fri, 6 Nov 2026 21:03:29 -0500 Subject: [PATCH 20/25] docs: compare with 0.4.1 in comments, the release before 0.5.0 Comments, docstrings and the manifest schema compared behavior with a version number that was never released. The release they mean is 0.4.1. Co-Authored-By: Claude Opus 5.5 --- docs/contracts/manifest.schema.json | 2 +- .../tests/unit/test_probe.py | 2 +- .../src/deepctl_cmd_login/command.py | 2 +- .../tests/unit/test_keyring_bound.py | 4 ++-- packages/deepctl-core/src/deepctl_core/auth.py | 2 +- .../deepctl-core/src/deepctl_core/credentials.py | 10 +++++----- .../deepctl-core/src/deepctl_core/transport.py | 4 ++-- .../deepctl-core/tests/unit/test_credentials.py | 16 ++++++++-------- src/deepctl/_cache.py | 2 +- src/deepctl/_cli.py | 4 ++-- src/deepctl/_manifest_build.py | 2 +- tests/unit/test_lazy_group.py | 2 +- tests/upgrade/lib.sh | 2 +- 13 files changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/contracts/manifest.schema.json b/docs/contracts/manifest.schema.json index f6691a5f..af91c466 100644 --- a/docs/contracts/manifest.schema.json +++ b/docs/contracts/manifest.schema.json @@ -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}$" }, diff --git a/packages/deepctl-cmd-debug-probe/tests/unit/test_probe.py b/packages/deepctl-cmd-debug-probe/tests/unit/test_probe.py index eb4c1cb7..defecc6c 100644 --- a/packages/deepctl-cmd-debug-probe/tests/unit/test_probe.py +++ b/packages/deepctl-cmd-debug-probe/tests/unit/test_probe.py @@ -464,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" diff --git a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py index b45de117..edf9b314 100644 --- a/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py +++ b/packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py @@ -1646,7 +1646,7 @@ def handle( # key_source uses these same words): one bounded keyring read, and an # expired finite profile credential doesn't mask the environment's. # Like before, --api-key isn't a storage source here. Read-only, as in - # 0.4.2: an expired credential's keyring entry stays (prune=False). + # 0.4.1: an expired credential's keyring entry stays (prune=False). resolved = auth_manager.resolve( profile=profile_name, use_flags=False, prune=False ) diff --git a/packages/deepctl-cmd-login/tests/unit/test_keyring_bound.py b/packages/deepctl-cmd-login/tests/unit/test_keyring_bound.py index 790c1ddb..c872217c 100644 --- a/packages/deepctl-cmd-login/tests/unit/test_keyring_bound.py +++ b/packages/deepctl-cmd-login/tests/unit/test_keyring_bound.py @@ -99,12 +99,12 @@ def test_profiles_list_is_bounded(setup): config = Config() result, took = _timed(lambda: AuthManager(config).list_profiles()) assert took < 5.0 # the keyring hangs until the test ends - # A timeout falls back to the file, like a keyring error did in 0.4.2. + # A timeout falls back to the file, like a keyring error did in 0.4.1. assert result.profiles["work"].api_key == "****" + "w" * 4 def test_whoami_leaves_an_expired_entry_alone(tmp_path, monkeypatch): - """Whoami is read-only, as in 0.4.2; key_source unchanged.""" + """Whoami is read-only, as in 0.4.1; key_source unchanged.""" from deepctl_cmd_login.command import WhoamiCommand monkeypatch.setattr(config_module, "_pending_notices", []) diff --git a/packages/deepctl-core/src/deepctl_core/auth.py b/packages/deepctl-core/src/deepctl_core/auth.py index 4b14c1fc..075f06ac 100644 --- a/packages/deepctl-core/src/deepctl_core/auth.py +++ b/packages/deepctl-core/src/deepctl_core/auth.py @@ -396,7 +396,7 @@ def has_profile_credentials( ) -> tuple[bool, bool]: """Check if profile has stored credentials. - As in 0.4.2, a profile key is a usable keyring entry or a key in the + As in 0.4.1, a profile key is a usable keyring entry or a key in the profile as the config holds it (which includes ``DEEPGRAM_API_KEY``). Args: diff --git a/packages/deepctl-core/src/deepctl_core/credentials.py b/packages/deepctl-core/src/deepctl_core/credentials.py index b3bd5786..589ea857 100644 --- a/packages/deepctl-core/src/deepctl_core/credentials.py +++ b/packages/deepctl-core/src/deepctl_core/credentials.py @@ -8,7 +8,7 @@ is answered from it, so ``mcp``, ``init``, ``debug toolkit`` and ``login`` share one precedence. -The 0.5.0 precedence is 0.4.2's, unchanged: ``--api-key`` > the profile's +The 0.5.0 precedence is 0.4.1's, unchanged: ``--api-key`` > the profile's keyring entry > the profile in the config file > ``DEEPGRAM_API_KEY``. The config file layer already holds ``DEEPGRAM_API_KEY`` when it is set (``Config`` merges the environment into the profile), so a file key equal to @@ -101,7 +101,7 @@ class Resolved: untrusted_source: str | None = None # Why a stored credential was unusable when nothing else was set. expiry_error: str | None = None - # 0.4.2's has_profile_credentials(): a usable keyring entry, or a key in + # 0.4.1's has_profile_credentials(): a usable keyring entry, or a key in # the profile as Config holds it (which includes DEEPGRAM_API_KEY). profile_has_key: bool = False profile_has_project: bool = False @@ -400,8 +400,8 @@ def resolve( leaves ``DEEPGRAM_API_KEY`` and ``DEEPGRAM_PROJECT_ID`` out. ``need_key=False`` answers project questions without reading the keyring. ``prune=False`` keeps the answer read-only: an expired credential's - keyring entry stays (whoami and the other introspection, as in 0.4.2); - commands that use the key prune it, as 0.4.2's ``get_api_key`` did. + keyring entry stays (whoami and the other introspection, as in 0.4.1); + commands that use the key prune it, as 0.4.1's ``get_api_key`` did. Raises nothing; ``expiry_error`` says why a stored credential wasn't used. """ try: @@ -499,7 +499,7 @@ def result( if raw_env_key and file_key == raw_env_key else KEY_SOURCE_FILE ) - # As in 0.4.2, ignore_env doesn't skip the env value Config + # As in 0.4.1, ignore_env doesn't skip the env value Config # merged into the profile; it skips only the last fallback. mismatch = source == KEY_SOURCE_FILE and _mismatch(file_key, env_key) return result(file_key, source, has_key=True, env_mismatch=mismatch) diff --git a/packages/deepctl-core/src/deepctl_core/transport.py b/packages/deepctl-core/src/deepctl_core/transport.py index 198ae2d6..8844d8be 100644 --- a/packages/deepctl-core/src/deepctl_core/transport.py +++ b/packages/deepctl-core/src/deepctl_core/transport.py @@ -10,7 +10,7 @@ cookies, and verifies TLS. A person's ``DEEPGRAM_CLI_INSECURE=1`` (or ``true``) skips verification for localhost, 127.0.0.1 and [::1] only, and never under an AI agent (``auth._insecure_allowed`` and -``auth._insecure_mounts``, as in 0.4.2). :func:`verified_client` is the +``auth._insecure_mounts``, as in 0.4.1). :func:`verified_client` is the always-verified client for ``dg api``; WebSocket streams always verify. The SDK facade builds its client through :attr:`Transport.backend`, a seam for other backends (#142's SageMaker), with the REST backend as the default. @@ -89,7 +89,7 @@ def _new_client(*, insecure_loopback: bool) -> httpx.Client: from .auth import _insecure_mounts, _warn_if_verified if insecure_loopback: - # As AuthManager.client (0.4.2): verification stays on, and only the + # As AuthManager.client (0.4.1): verification stays on, and only the # loopback hosts get unverified transports. return httpx.Client( timeout=DEFAULT_TIMEOUT_S, diff --git a/packages/deepctl-core/tests/unit/test_credentials.py b/packages/deepctl-core/tests/unit/test_credentials.py index 3cf3a01e..e636f65d 100644 --- a/packages/deepctl-core/tests/unit/test_credentials.py +++ b/packages/deepctl-core/tests/unit/test_credentials.py @@ -1,4 +1,4 @@ -"""deepctl_core.credentials: one resolver, 0.4.2's precedence.""" +"""deepctl_core.credentials: one resolver, 0.4.1's precedence.""" from __future__ import annotations @@ -85,7 +85,7 @@ def write_config(home: Path, data: dict[str, Any], mode: int = 0o600) -> Path: return path -# ── 0.4.2's get_api_key(), verbatim in substance, as the golden ───────── +# ── 0.4.1's get_api_key(), verbatim in substance, as the golden ───────── def golden_042(config: Config, explicit: str | None, kr: FakeKeyring) -> str | None: @@ -104,7 +104,7 @@ def golden_042(config: Config, explicit: str | None, kr: FakeKeyring) -> str | N def whoami_source_042(config: Config, key: str | None, kr: FakeKeyring) -> str: - """`dg whoami`'s key_source rule (0.4.2), plus the flag.""" + """`dg whoami`'s key_source rule (0.4.1), plus the flag.""" if key is None: return credentials.KEY_SOURCE_NONE name = config.profile or "default" @@ -180,7 +180,7 @@ def test_login_forgets_the_cached_answer(home, kr, monkeypatch): def test_project_id_precedence(home, kr, monkeypatch): write_config(home, {"profiles": {"default": {"project_id": "p-file"}}}) monkeypatch.setenv("DEEPGRAM_PROJECT_ID", "p-env") - # Config merges the env value over the file's, as in 0.4.2. + # Config merges the env value over the file's, as in 0.4.1. assert AuthManager(Config()).get_project_id() == "p-env" assert AuthManager(Config(), None, "p-flag").get_project_id() == "p-flag" monkeypatch.delenv("DEEPGRAM_PROJECT_ID") @@ -194,9 +194,9 @@ def test_project_id_precedence(home, kr, monkeypatch): def test_env_key_is_reported_as_the_environments(home, kr, monkeypatch): monkeypatch.setenv("DEEPGRAM_API_KEY", "f" * 40) auth = AuthManager(Config()) - # 0.4.2 said "profile 'default'": Config merges the env key into the profile. + # 0.4.1 said "profile 'default'": Config merges the env key into the profile. assert auth.get_credential_source() == "environment variables" - assert auth.has_profile_credentials() == (True, False) # 0.4.2's answer + assert auth.has_profile_credentials() == (True, False) # 0.4.1's answer def test_stored_key_is_reported_as_the_profiles(home, kr, monkeypatch): @@ -265,7 +265,7 @@ def test_trusted_expiry_still_deletes_keyring(home, kr): def test_introspection_never_prunes(home, kr, monkeypatch): - """Whoami and login's checks stay read-only, as in 0.4.2.""" + """Whoami and login's checks stay read-only, as in 0.4.1.""" past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() write_config(home, {"profiles": {"default": {"dg_token_expires_at": past}}}) kr.entries["api-key.default"] = "k" * 40 @@ -275,7 +275,7 @@ def test_introspection_never_prunes(home, kr, monkeypatch): assert auth.has_profile_credentials("default") == (False, False) assert auth.is_authenticated() is True assert kr.deletes == [] - # A command that uses the key prunes it, once (0.4.2's get_api_key). + # A command that uses the key prunes it, once (0.4.1's get_api_key). assert auth.get_api_key() == "e" * 40 assert auth.get_api_key() == "e" * 40 assert kr.deletes == ["api-key.default"] diff --git a/src/deepctl/_cache.py b/src/deepctl/_cache.py index e42900b8..05b9deb5 100644 --- a/src/deepctl/_cache.py +++ b/src/deepctl/_cache.py @@ -66,7 +66,7 @@ ) -# Plugin command names (the 0.4.2 rule; plugin_manager._VALID_PLUGIN_NAME). +# Plugin command names (the 0.4.1 rule; plugin_manager._VALID_PLUGIN_NAME). VALID_PLUGIN_NAME = re.compile(r"[a-z][a-z0-9-]{0,63}") # Format characters a cached row may carry: ZWNJ and ZWJ, which Persian, # Indic scripts and emoji sequences need. Every other Cf character (bidi diff --git a/src/deepctl/_cli.py b/src/deepctl/_cli.py index c5862c6e..21f82fe5 100644 --- a/src/deepctl/_cli.py +++ b/src/deepctl/_cli.py @@ -261,7 +261,7 @@ class LazyGroup(click.Group): Discovery (``PluginManager.discover_plugins``) runs for a name that isn't built in, for root ``--help``, for ``dg plugin list`` and for a - built-in name listed in ``plugins.allow_override``. Every 0.4.2 plugin + built-in name listed in ``plugins.allow_override``. Every 0.4.1 plugin rule applies unchanged there: reserved built-in names, ``W-PLUGIN-BADNAME``, ``plugins.allow_override`` and the trust root. Built-ins are never loaded through an entry point: their module comes @@ -501,7 +501,7 @@ def _expand_command_word(self, ctx: click.Context, args: list[str]) -> list[str] unless ``plugins.allow_override`` lists the alias. Any other ``-`` runs `` `` when ```` is a built-in group and ```` is one of its subcommands, a plugin's included - (0.4.2 parity); otherwise argv is left for unknown-name discovery. + (0.4.1 parity); otherwise argv is left for unknown-name discovery. """ word = args[0] if word in _manifest.top_level(): diff --git a/src/deepctl/_manifest_build.py b/src/deepctl/_manifest_build.py index a5c76907..06fb0d11 100644 --- a/src/deepctl/_manifest_build.py +++ b/src/deepctl/_manifest_build.py @@ -42,7 +42,7 @@ MANIFEST_NAME = "_manifest.json" HELP_DIR_NAME = "_help" -# Command, alias and tool names: the plugin name rule (0.4.2), so a built-in +# Command, alias and tool names: the plugin name rule (0.4.1), so a built-in # name is always one a plugin could also try to claim. NAME = re.compile(r"[a-z][a-z0-9-]{0,63}") TOOL_NAME = re.compile(r"[a-z][a-z0-9_]{0,63}") diff --git a/tests/unit/test_lazy_group.py b/tests/unit/test_lazy_group.py index 7da1284c..7e017e92 100644 --- a/tests/unit/test_lazy_group.py +++ b/tests/unit/test_lazy_group.py @@ -382,7 +382,7 @@ def test_eager_alias_runs_the_builtin( class TestHyphenatedSubcommands: """``dg -`` runs ``dg `` for any subcommand of a - built-in group, a plugin's included (0.4.2 parity).""" + built-in group, a plugin's included (0.4.1 parity).""" @pytest.fixture def debug_with_extra( diff --git a/tests/upgrade/lib.sh b/tests/upgrade/lib.sh index 5e99f33c..34cb0165 100644 --- a/tests/upgrade/lib.sh +++ b/tests/upgrade/lib.sh @@ -14,7 +14,7 @@ export PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_INPUT=1 mkdir -p /idx/pool W=/w; WH=$W/wheelhouse CUR=$(cat $W/lockstep/cur/VERSION); NEXT=$(cat $W/lockstep/next/VERSION) -FIELD=$(cat $W/FIELD_VERSIONS) # e.g. "0.3.3 0.4.0 0.4.1 0.4.2" +FIELD=$(cat $W/FIELD_VERSIONS) # e.g. "0.3.3 0.4.0 0.4.1" LCUR="$W/lockstep/cur"; LNEXT="$W/lockstep/next" field_dirs(){ for d in $W/field/*/; do [ -d "$d" ] && printf '%s ' "${d%/}"; done; } mk(){ python -I /s/mkidx.py /idx/$1 /idx/pool "${@:2}" 2>>/o/idx.log; } From 8ffaa275ec24b41e563f2686bc9676f799db2b07 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Fri, 6 Nov 2026 21:03:53 -0500 Subject: [PATCH 21/25] build: regenerate the stale package READMEs and run the generator with uv The deepctl-core and deepctl-cmd-debug-probe READMEs didn't list the websockets requirement they now have. make readmes and make readmes-check ran the system python3, which on some machines is too old for the generator (it needs tomllib, Python 3.11+); they now use uv run python, like the other Makefile targets, and the --check message points at make readmes. Co-Authored-By: Claude Opus 5.5 --- Makefile | 4 ++-- packages/deepctl-cmd-debug-probe/README.md | 2 +- packages/deepctl-core/README.md | 1 + scripts/generate_readmes.py | 5 +---- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 9011d43f..aff388c0 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/packages/deepctl-cmd-debug-probe/README.md b/packages/deepctl-cmd-debug-probe/README.md index dbb70ddb..e9f70bc7 100644 --- a/packages/deepctl-cmd-debug-probe/README.md +++ b/packages/deepctl-cmd-debug-probe/README.md @@ -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 diff --git a/packages/deepctl-core/README.md b/packages/deepctl-core/README.md index f173d1f9..1b27b9ae 100644 --- a/packages/deepctl-core/README.md +++ b/packages/deepctl-core/README.md @@ -39,6 +39,7 @@ pipx run deepctl --help - `pyyaml>=6.0.0` - `platformdirs>=3.0.0,<5` - `packaging>=21.0` +- `websockets>=13.1,<18` ## License diff --git a/scripts/generate_readmes.py b/scripts/generate_readmes.py index 59900eb6..c07d478a 100644 --- a/scripts/generate_readmes.py +++ b/scripts/generate_readmes.py @@ -489,10 +489,7 @@ def main() -> None: stale.append("README.md") if args.check and stale: - print( - f"\n{len(stale)} README(s) out of date. " - "Run: python scripts/generate_readmes.py" - ) + print(f"\n{len(stale)} README(s) out of date. Run: make readmes") sys.exit(1) if not args.check and not args.dry_run: From 01b5615bf199565a1d74db4887cc00eeca3a7c8a Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Fri, 6 Nov 2026 21:04:26 -0500 Subject: [PATCH 22/25] docs(telemetry): say this version sends no telemetry docs/telemetry.md described error reports as sent by default. This version has no built-in destination: the reporting code runs only with a loopback DEEPCTL_TELEMETRY_DSN, for tests and local development. The page now says so up front, and describes the switches, DEEPCTL_TELEMETRY=log and the field schema as they apply to a loopback sink. Co-Authored-By: Claude Opus 5.5 --- docs/telemetry.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/telemetry.md b/docs/telemetry.md index a9ad2f1f..577847d8 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -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 | |---|---| @@ -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 From 7cb4b007c9df20a613631e4daa67d08d03ff0f07 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Fri, 6 Nov 2026 21:08:33 -0500 Subject: [PATCH 23/25] test(cli): patch both built-in tables in the missing-package test The test removed a built-in's package by pointing its manifest entry at a module that doesn't exist, but plugin_manager had already read the built-in module table from the real manifest. The module then wasn't the built-in's own, so the check that runs before import refused it as a plugin replacing a built-in, and the test never reached the dependency_missing error. That order is right: a module that isn't the built-in's own is never imported. The test now patches both tables, as an uninstalled package leaves them, and gets dependency_missing again. Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_lazy_group.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_lazy_group.py b/tests/unit/test_lazy_group.py index 7e017e92..06a94cfe 100644 --- a/tests/unit/test_lazy_group.py +++ b/tests/unit/test_lazy_group.py @@ -725,10 +725,24 @@ def test_missing_module_still_prints_help( capsys: pytest.CaptureFixture[str], ) -> None: """A built-in whose package is gone still shows its help; running it - reports dependency_missing.""" + reports dependency_missing. + + The manifest and the built-in module table plugin_manager reads from + it at import both name the missing module, as they do when a + package is uninstalled. (With only the manifest patched, the module + wouldn't be the built-in's own, and the check before import would + refuse it as a plugin replacing a built-in, which is right for a + module that isn't the built-in's.)""" + from deepctl_core import plugin_manager + gone = dict(_manifest.commands()["models"], module="deepctl_cmd_gone.command") commands = dict(_manifest.commands(), models=gone) monkeypatch.setattr(_manifest, "commands", lambda: commands) + monkeypatch.setattr( + plugin_manager, + "BUILTIN_COMMAND_MODULES", + {**plugin_manager.BUILTIN_COMMAND_MODULES, "models": gone["module"]}, + ) _cli.run(["models", "--help"], prog="dg") out = capsys.readouterr() assert out.out.startswith("Usage: dg models [OPTIONS]") From 30b71777f40b28468ba4d91bcf2d2a37618dd332 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Fri, 6 Nov 2026 21:08:33 -0500 Subject: [PATCH 24/25] test(mcp): let the docs lint pass the entry point's transport refusal python -m deepctl_cmd_mcp still parses the transport names an old MCP config may use, streamable-http among them, so it can print the stdio-only error instead of an argparse error. The lint that keeps shipped text from promising other transports flagged that refusal. It now skips that file for that one check. Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_mcp_docs_lint.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/test_mcp_docs_lint.py b/tests/unit/test_mcp_docs_lint.py index 6a193537..2d924cd1 100644 --- a/tests/unit/test_mcp_docs_lint.py +++ b/tests/unit/test_mcp_docs_lint.py @@ -71,6 +71,12 @@ } SKIP_DIRS = {"node_modules", "dist", ".astro", "__pycache__", "tests"} +# Source that names a transport only to refuse it: the standalone entry point +# (`python -m deepctl_cmd_mcp`) still parses the names an old MCP config may +# use, so it prints E_MCP_TRANSPORT instead of an argparse error. +REFUSES = { + ("packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/__main__.py", "streamable-http"), +} TEXT_SUFFIXES = {".md", ".txt", ".py", ".astro", ".ts", ".tsx", ".js", ".json"} @@ -121,6 +127,8 @@ def _hits() -> list[str]: lines = text.splitlines() rel = path.relative_to(REPO) for number, label in check_text(text): + if (rel.as_posix(), label) in REFUSES: + continue hits.append(f"{rel}:{number}: {label}: {lines[number - 1].strip()}") return hits From 28234d45160d582dff85ee939eb5f39f718cefe0 Mon Sep 17 00:00:00 2001 From: Corey Weathers Date: Fri, 6 Nov 2026 21:08:58 -0500 Subject: [PATCH 25/25] chore: release 0.5.0 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