feat(code): dcode doctor diagnostics command (#4148)

Add a `dcode doctor` command that prints install diagnostics (versions,
platform, update status, config locations), with `--json` support.

---

Adds a `dcode doctor` subcommand to `deepagents-code`, inspired by
`claude doctor`. It prints a grouped, tree-style summary of the install
(versions, platform, install method, path), update status, and on-disk
config locations, with a `--json` mode for machine-readable output.

It deliberately runs offline — the update section reads only the local
cache via `get_cached_update_available` and never contacts PyPI — and
reuses existing helpers (`detect_install_method`,
`is_auto_update_enabled`, editable-install detection, config paths) so
the output stays consistent with `dcode --version` and `dcode update`.
All rendered values go through `rich.markup.escape` with
`highlight=False`.

The new command is a leaf subcommand (like `update`), so it is
intentionally not registered in `_HELP_SPECS`. `ui.show_doctor_help`
backs `dcode doctor -h`, and the command is listed in the top-level help
usage.

Made by [Open
SWE](https://openswe.vercel.app/agents/74c251d7-548f-ee57-6e48-c76e1bd66a1a)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-06-22 18:51:41 -04:00
committed by GitHub
parent a795988061
commit 81797312c7
14 changed files with 873 additions and 72 deletions
+69
View File
@@ -0,0 +1,69 @@
"""Filesystem-path classification shared by the diagnostic CLI commands.
`dcode doctor` and `dcode config path` both probe whether config locations
exist. `Path.exists()` can report `False` for `OSError` cases such as EACCES
when a parent directory denies traversal, so a bare `.exists()` can hide the
very permissions problem these commands should diagnose. `classify_path`
centralizes the guard and reports an unreadable path as a distinct state instead
of conflating it with "missing".
"""
from __future__ import annotations
import errno
import logging
from enum import StrEnum
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
_MISSING_ERRNOS = {errno.ENOENT, errno.ENOTDIR}
class PathState(StrEnum):
"""Whether a probed path exists, is absent, or could not be read.
A `StrEnum` so the value serializes directly to JSON without a custom
encoder.
"""
EXISTS = "exists"
"""The path is present on disk."""
MISSING = "missing"
"""The path is absent (and its parents are readable)."""
UNREADABLE = "unreadable"
"""Existence could not be determined because `Path.stat()` raised.
Typically EACCES when a parent directory denies traversal. Kept distinct
from `MISSING` so diagnostics can flag it as a genuine problem rather than
a not-yet-created path.
"""
def classify_path(path: Path) -> PathState:
"""Classify a path as existing, missing, or unreadable.
Args:
path: Filesystem path to probe.
Returns:
`PathState.EXISTS` for a present path, `PathState.MISSING` for expected
absent-path errors, and `PathState.UNREADABLE` when `Path.stat()`
raises another `OSError` (e.g. a parent directory denies traversal).
The error is logged at debug level so an unreadable path is never
silently indistinguishable from a missing one.
"""
try:
path.stat()
except OSError as exc:
if exc.errno in _MISSING_ERRNOS:
return PathState.MISSING
logger.debug("Could not stat %s", path, exc_info=True)
return PathState.UNREADABLE
else:
return PathState.EXISTS
+5 -12
View File
@@ -4378,11 +4378,6 @@ class DeepAgentsApp(App):
resolved versions of the core LangChain-ecosystem dependencies, which
helps diagnose local checkouts.
"""
from importlib.metadata import (
PackageNotFoundError,
version as _pkg_version,
)
lines: list[str] = []
try:
from deepagents_code._version import __version__ as cli_version
@@ -4397,17 +4392,15 @@ class DeepAgentsApp(App):
logger.warning("Unexpected error looking up app version", exc_info=True)
lines.append("deepagents-code version: unknown")
try:
from deepagents_code.extras_info import resolve_sdk_version
sdk_version, sdk_status = resolve_sdk_version()
if sdk_status == "resolved":
from deepagents_code.update_check import format_sdk_age_suffix
sdk_version = _pkg_version("deepagents")
sdk_age_suffix = await asyncio.to_thread(format_sdk_age_suffix, sdk_version)
lines.append(f"deepagents (SDK) version: {sdk_version}{sdk_age_suffix}")
except PackageNotFoundError:
logger.debug("deepagents SDK package not found in environment")
lines.append("deepagents (SDK) version: unknown")
except Exception:
logger.warning("Unexpected error looking up SDK version", exc_info=True)
else:
lines.append("deepagents (SDK) version: unknown")
editable = False
+5 -5
View File
@@ -754,17 +754,17 @@ Kept short so tracing metadata can never stall app flows.
def _get_deepagents_version() -> str | None:
"""Read the installed Deep Agents SDK version from package metadata.
This intentionally calls `importlib.metadata.version` directly instead of
`resolve_sdk_version`: `config` is on the startup hot path, while
`resolve_sdk_version` lives in `extras_info` and imports `packaging`.
Returns:
The installed Deep Agents SDK version, or `None` when package metadata
is unavailable.
is unavailable.
"""
try:
return version("deepagents")
except PackageNotFoundError:
logger.debug(
"Failed to read deepagents version from package metadata",
exc_info=True,
)
return None
+6 -8
View File
@@ -471,17 +471,15 @@ def _config_paths() -> list[tuple[str, Any, bool]]:
("recent models", DEFAULT_STATE_DIR / RECENT_MODELS_FILENAME),
]
from deepagents_code._paths import PathState, classify_path
rows: list[tuple[str, Any, bool]] = []
for label, path in candidates:
if path is None:
continue
try:
exists = path.exists()
except OSError:
# A permission/transient FS error is not the same as "missing"; log
# it (at debug, to keep normal output clean) so a developer can tell
# the two apart when triaging a `config path` report.
logger.debug("Could not stat %s", path, exc_info=True)
exists = False
# `classify_path` logs unreadable paths at debug level. `config path`
# reports a plain exists/missing bool, so unreadable still collapses to
# missing here while `doctor` can surface it as a problem.
exists = classify_path(path) is PathState.EXISTS
rows.append((label, path, exists))
return rows
+328
View File
@@ -0,0 +1,328 @@
"""The `dcode doctor` command: report install health and diagnostics.
Inspired by `claude doctor`, this prints a grouped, tree-style summary of the
running install, update status, and configuration locations so the output is
safe to paste into a bug report. It stays offline: the update section reads
only the local cache and never contacts PyPI.
Help rendering for `dcode doctor -h` is served by `ui.show_doctor_help`, which
does not import this module, so the help path stays light.
"""
from __future__ import annotations
import logging
import platform
import sys
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from deepagents_code.output import write_json
if TYPE_CHECKING:
import argparse
logger = logging.getLogger(__name__)
@dataclass
class DiagnosticItem:
"""A single labeled diagnostic fact.
`ok` is `False` only for genuine problems (e.g. a missing dependency), not
for informational states such as an available update.
"""
label: str
value: str
ok: bool = True
@dataclass
class DiagnosticSection:
"""A named group of related diagnostic items."""
title: str
items: list[DiagnosticItem] = field(default_factory=list)
@property
def ok(self) -> bool:
"""Whether every item in the section is healthy."""
return all(item.ok for item in self.items)
def _platform_tag() -> str:
"""Return a compact `<os>-<arch>` platform tag (e.g. `darwin-arm64`)."""
return f"{platform.system()}-{platform.machine()}".lower()
def _sdk_version() -> tuple[str, bool]:
"""Return the installed `deepagents` SDK version and whether it resolved.
Maps the shared `resolve_sdk_version` outcome onto doctor's display: a
genuinely missing package reads as `not installed`, an unexpected lookup
failure as `unknown`, and only a resolved version is marked healthy.
"""
from deepagents_code.extras_info import resolve_sdk_version
sdk_version, status = resolve_sdk_version()
if status == "resolved":
# `sdk_version` is a real string when the status is "resolved".
return sdk_version or "unknown", True
if status == "not_installed":
return "not installed", False
return "unknown", False
def _commit_hash(path: str) -> str:
"""Return the short git commit hash for a source path, if available.
Args:
path: Directory used as the git command working directory.
Returns:
The short commit hash, or `unknown` when git metadata cannot be read.
"""
import shutil
import subprocess # noqa: S404 # fixed-argv git metadata probe
from pathlib import Path
cwd = Path(path).expanduser()
git = shutil.which("git")
if git is None:
return "unknown"
try:
git_path = Path(git).expanduser().resolve(strict=True)
result = subprocess.run( # noqa: S603 # fixed argv with absolute Git path
[str(git_path), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
timeout=2,
check=False,
cwd=cwd,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
logger.debug("Git commit hash detection failed", exc_info=True)
return "unknown"
if result.returncode != 0:
return "unknown"
return result.stdout.strip() or "unknown"
def _collect_diagnostics() -> DiagnosticSection:
"""Collect core version, platform, and install-location facts.
Returns:
The `Diagnostics` section.
"""
from deepagents_code._version import __version__
from deepagents_code.config import (
_get_editable_install_path,
_is_editable_install,
)
from deepagents_code.update_check import detect_install_method
sdk_version, sdk_ok = _sdk_version()
editable = _is_editable_install()
if editable:
method = "editable"
path = _get_editable_install_path() or sys.prefix
else:
method = detect_install_method()
path = sys.prefix
return DiagnosticSection(
title="Diagnostics",
items=[
DiagnosticItem("deepagents-code", __version__),
DiagnosticItem("deepagents (SDK)", sdk_version, ok=sdk_ok),
DiagnosticItem("Commit hash", _commit_hash(path)),
DiagnosticItem("Python", platform.python_version()),
DiagnosticItem("Platform", _platform_tag()),
DiagnosticItem("Install method", method),
DiagnosticItem("Path", path),
],
)
def _collect_updates() -> DiagnosticSection:
"""Collect update-channel status from local config and the offline cache.
Returns:
The `Updates` section.
"""
from deepagents_code.config import _is_editable_install
from deepagents_code.update_check import (
get_cached_update_available,
is_auto_update_enabled,
is_update_check_enabled,
)
items = [
DiagnosticItem(
"Update checks",
"enabled" if is_update_check_enabled() else "disabled",
),
]
if _is_editable_install():
items.append(DiagnosticItem("Auto-updates", "disabled (editable install)"))
else:
items.append(
DiagnosticItem(
"Auto-updates",
"enabled" if is_auto_update_enabled() else "disabled",
)
)
available, latest = get_cached_update_available()
if latest is None:
update_status = "unknown (no recent check)"
elif available:
update_status = f"v{latest} available"
else:
update_status = "up to date"
items.append(DiagnosticItem("Latest version", update_status))
return DiagnosticSection(title="Updates", items=items)
def _path_status(label: str, path: object) -> DiagnosticItem:
"""Build an item reporting a path and whether it exists on disk.
An unreadable path (e.g. a parent directory that denies traversal) is
flagged as a genuine problem (`ok=False`) so it surfaces in the section
health and exit code, rather than being mistaken for a not-yet-created one.
Args:
label: Human-readable name for the path.
path: Filesystem path to probe.
Returns:
A diagnostic item describing the path and its existence.
"""
from pathlib import Path
from deepagents_code._paths import PathState, classify_path
resolved = Path(str(path))
state = classify_path(resolved)
suffix = {
PathState.EXISTS: "exists",
PathState.MISSING: "not created",
PathState.UNREADABLE: "unreadable",
}[state]
return DiagnosticItem(
label, f"{resolved} ({suffix})", ok=state is not PathState.UNREADABLE
)
def _collect_configuration() -> DiagnosticSection:
"""Collect on-disk configuration and data locations.
Returns:
The `Configuration` section.
"""
from deepagents_code.model_config import (
DEFAULT_CONFIG_DIR,
DEFAULT_CONFIG_PATH,
)
return DiagnosticSection(
title="Configuration",
items=[
_path_status("Data directory", DEFAULT_CONFIG_DIR),
_path_status("Config file", DEFAULT_CONFIG_PATH),
],
)
def collect_sections() -> list[DiagnosticSection]:
"""Gather every diagnostic section in display order.
Returns:
The diagnostic sections, in render order.
"""
return [
_collect_diagnostics(),
_collect_updates(),
_collect_configuration(),
]
def _tree_connectors() -> tuple[str, str]:
"""Return the `(tee, corner)` tree connectors for the active charset."""
from deepagents_code.config import is_ascii_mode
if is_ascii_mode():
return "|-", "`-"
return "\u251c", "\u2514" # ├ └
def _render_text(sections: list[DiagnosticSection]) -> None:
"""Print the diagnostic sections as a styled tree to the console."""
from rich.markup import escape
from deepagents_code import theme
from deepagents_code.config import console, get_glyphs
glyphs = get_glyphs()
tee, corner = _tree_connectors()
console.print()
for section in sections:
status_glyph = glyphs.checkmark if section.ok else glyphs.warning
status_color = theme.SUCCESS if section.ok else theme.WARNING
console.print(
f" [bold]{escape(section.title)}[/bold] "
f"[{status_color}]{status_glyph}[/{status_color}]"
)
for index, item in enumerate(section.items):
connector = corner if index == len(section.items) - 1 else tee
value_color = theme.MUTED if item.ok else "red"
console.print(
f" {connector} {escape(item.label)}: "
f"[{value_color}]{escape(item.value)}[/{value_color}]",
highlight=False,
)
console.print()
def run_doctor_command(args: argparse.Namespace) -> int:
"""Run `dcode doctor`, printing diagnostics as text or JSON.
Args:
args: Parsed CLI namespace. Only `output_format` is read.
Returns:
Process exit code: `0` when all sections are healthy, `1` otherwise.
"""
sections = collect_sections()
healthy = all(section.ok for section in sections)
output_format = getattr(args, "output_format", "text")
if output_format == "json":
write_json(
"doctor",
{
"healthy": healthy,
"sections": [
{
"title": section.title,
"ok": section.ok,
"items": [
{
"label": item.label,
"value": item.value,
"ok": item.ok,
}
for item in section.items
],
}
for section in sections
],
},
)
else:
_render_text(sections)
return 0 if healthy else 1
+34
View File
@@ -16,12 +16,46 @@ from importlib.metadata import (
distribution,
version as pkg_version,
)
from typing import Literal
from packaging.requirements import InvalidRequirement, Requirement
from packaging.utils import canonicalize_name
logger = logging.getLogger(__name__)
SdkVersionStatus = Literal["resolved", "not_installed", "error"]
"""Outcome of an SDK version lookup.
`"not_installed"` means the package metadata is genuinely absent;
`"error"` means an unexpected failure occurred while reading it. Callers
that don't care which kind of failure happened can treat both the same.
"""
def resolve_sdk_version() -> tuple[str | None, SdkVersionStatus]:
"""Resolve the installed `deepagents` SDK version from package metadata.
Single source of truth for the lookup that `--version`, `/version`, and
`doctor` each used to reimplement. Distinguishes a genuinely missing
package from an unexpected metadata error so diagnostic callers can report
the two differently, while collapse-friendly callers can ignore the split.
Returns:
`(version, status)`. `version` is the resolved version string when
`status` is `"resolved"`, otherwise `None`.
"""
try:
return pkg_version("deepagents"), "resolved"
except PackageNotFoundError:
logger.debug("deepagents SDK package not found in environment")
return None, "not_installed"
except Exception: # Best-effort lookup; never propagate to the caller
logger.warning(
"Unexpected error looking up deepagents SDK version", exc_info=True
)
return None, "error"
_EXTRA_MARKER_RE = re.compile(r"""extra\s*==\s*["']([^"']+)["']""")
+39 -22
View File
@@ -55,19 +55,10 @@ def build_version_text() -> str:
Returns:
Multi-line version string suitable for stdout.
"""
from importlib.metadata import (
PackageNotFoundError,
version as _pkg_version,
)
from deepagents_code.extras_info import resolve_sdk_version
try:
sdk_version = _pkg_version("deepagents")
except PackageNotFoundError:
logger.debug("deepagents SDK package not found in environment")
sdk_version = "unknown"
except Exception: # Best-effort SDK version lookup
logger.warning("Unexpected error looking up SDK version", exc_info=True)
sdk_version = "unknown"
sdk_version_value, status = resolve_sdk_version()
sdk_version = sdk_version_value if status == "resolved" else "unknown"
text = f"deepagents-code {__version__}\ndeepagents (SDK) {sdk_version}"
@@ -1186,6 +1177,14 @@ def parse_args() -> argparse.Namespace:
)
add_json_output_arg(update_parser)
doctor_parser = subparsers.add_parser(
"doctor",
help="Print install health and diagnostics",
add_help=False,
parents=help_parent(_lazy_help("show_doctor_help")),
)
add_json_output_arg(doctor_parser)
# Default interactive mode — argument order here determines the
# usage line printed by argparse; keep in sync with ui.show_help().
parser.add_argument(
@@ -2244,6 +2243,29 @@ def cli_main() -> None:
if _show_bare_command_group_help(args):
return
# Keep self-contained commands that do not need global settings here, before
# state migration and settings bootstrap. If a future command only reads
# local files or delegates bootstrap to specific subcommands, dispatch it here
# so lightweight diagnostic paths stay fast.
# Use `getattr` because this fast-path block is for optional top-level
# subcommands only. ACP/root-mode invocations may not define `command`,
# and should fall through to the later handlers instead of raising here.
command = getattr(args, "command", None)
if command == "config":
from deepagents_code.config_commands import run_config_command
sys.exit(run_config_command(args))
if command == "auth" and getattr(args, "auth_command", None) == "path":
from deepagents_code.auth_commands import run_auth_command
sys.exit(run_auth_command(args))
if command == "doctor":
from deepagents_code.doctor import run_doctor_command
sys.exit(run_doctor_command(args))
# Best-effort, idempotent migration. Placed after parse_args and the
# bare-help fast path so --help / --version / `deepagents <group>`
# exit before any I/O. Wrapped broadly so an unexpected non-OSError
@@ -2265,6 +2287,11 @@ def cli_main() -> None:
# `deepagents <group>` pays the settings bootstrap cost.
from deepagents_code.config import console, settings
if command == "auth":
from deepagents_code.auth_commands import run_auth_command
sys.exit(run_auth_command(args))
model_params: dict[str, Any] | None = None
raw_kwargs = getattr(args, "model_params", None)
if raw_kwargs:
@@ -2351,16 +2378,6 @@ def cli_main() -> None:
)
sys.exit(exit_code)
if args.command == "config":
from deepagents_code.config_commands import run_config_command
sys.exit(run_config_command(args))
if args.command == "auth":
from deepagents_code.auth_commands import run_auth_command
sys.exit(run_auth_command(args))
# Apply shell-allow-list from command line if provided (overrides env var)
if args.shell_allow_list:
from deepagents_code.config import parse_shell_allow_list
+27
View File
@@ -113,6 +113,9 @@ def show_help() -> None:
console.print(
" dcode update Check for and install updates"
)
console.print(
" dcode doctor Print install diagnostics"
)
console.print()
console.print("[bold]Options:[/bold]", style=theme.PRIMARY)
@@ -452,6 +455,30 @@ def show_update_help() -> None:
console.print()
def show_doctor_help() -> None:
"""Show help information for the `doctor` subcommand."""
console.print()
console.print("[bold]Usage:[/bold]", style=theme.PRIMARY)
console.print(" dcode doctor [options]", markup=False)
console.print()
console.print(
"Print install health and diagnostics (versions, platform, install",
)
console.print(
"method, update status, and config locations). Runs offline and is",
)
console.print(
"safe to paste into a bug report.",
)
console.print()
_print_option_section()
console.print()
console.print("[bold]Examples:[/bold]", style=theme.PRIMARY)
console.print(" dcode doctor")
console.print(" dcode doctor --json")
console.print()
def _print_mcp_discovery_paths() -> None:
"""Print the auto-discovered MCP config paths in precedence order."""
from deepagents_code.mcp_tools import MCP_CONFIG_DISCOVERY_PATHS
@@ -1030,7 +1030,7 @@ def test_resolve_theme_unknown_saved_warns(monkeypatch, caplog) -> None:
def test_config_paths_logs_and_reports_missing_on_oserror(monkeypatch, caplog) -> None:
"""An `OSError` from `path.exists()` is logged and reported as missing."""
"""An `OSError` from `path.stat()` is logged and reported as missing."""
import logging
from pathlib import Path
@@ -1038,16 +1038,18 @@ def test_config_paths_logs_and_reports_missing_on_oserror(monkeypatch, caplog) -
from deepagents_code.config_commands import _config_paths
target = model_config.DEFAULT_CONFIG_PATH
real_exists = Path.exists
real_stat = Path.stat
def fake_exists(self, *args: object, **kwargs: object) -> bool:
def fake_stat(self, *, follow_symlinks: bool = True) -> object:
if self == target:
msg = "boom"
raise OSError(msg)
return real_exists(self, *args, **kwargs)
return real_stat(self, follow_symlinks=follow_symlinks)
monkeypatch.setattr(Path, "exists", fake_exists)
with caplog.at_level(logging.DEBUG, logger="deepagents_code.config_commands"):
monkeypatch.setattr(Path, "stat", fake_stat)
# The OSError guard and its debug log now live in the shared `_paths`
# classifier that `_config_paths` delegates to.
with caplog.at_level(logging.DEBUG, logger="deepagents_code._paths"):
rows = _config_paths()
config_row = next(row for row in rows if row[0] == "config.toml")
assert config_row[2] is False
+222
View File
@@ -0,0 +1,222 @@
"""Unit tests for the `dcode doctor` command."""
import argparse
import io
import json
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from rich.console import Console
from deepagents_code.doctor import (
DiagnosticItem,
DiagnosticSection,
_commit_hash,
collect_sections,
run_doctor_command,
)
from deepagents_code.main import parse_args
class TestDoctorArgs:
"""Tests for `doctor` argument parsing."""
def test_command_parsed(self) -> None:
"""`dcode doctor` selects the doctor command."""
with patch.object(sys, "argv", ["deepagents", "doctor"]):
args = parse_args()
assert args.command == "doctor"
def test_json_flag(self) -> None:
"""`dcode doctor --json` selects JSON output."""
with patch.object(sys, "argv", ["deepagents", "doctor", "--json"]):
args = parse_args()
assert args.command == "doctor"
assert args.output_format == "json"
class TestDiagnosticSection:
"""Tests for the section dataclass health aggregation."""
def test_ok_when_all_items_ok(self) -> None:
"""A section is healthy when every item is healthy."""
section = DiagnosticSection(
title="X",
items=[DiagnosticItem("a", "1"), DiagnosticItem("b", "2")],
)
assert section.ok is True
def test_not_ok_when_any_item_fails(self) -> None:
"""A single failing item makes the section unhealthy."""
section = DiagnosticSection(
title="X",
items=[DiagnosticItem("a", "1"), DiagnosticItem("b", "2", ok=False)],
)
assert section.ok is False
class TestCollectSections:
"""Tests for the diagnostic data collection."""
def test_section_titles(self) -> None:
"""All three sections are collected in display order."""
sections = collect_sections()
assert [s.title for s in sections] == [
"Diagnostics",
"Updates",
"Configuration",
]
def test_diagnostics_reports_version(self) -> None:
"""The Diagnostics section reports the running CLI version."""
from deepagents_code._version import __version__
diagnostics = collect_sections()[0]
labels = {item.label: item.value for item in diagnostics.items}
assert labels["deepagents-code"] == __version__
assert "Commit hash" in labels
assert labels["Commit hash"]
assert "Platform" in labels
assert "Install method" in labels
class TestCommitHash:
"""Tests for git commit hash detection."""
def test_uses_absolute_git_path(self, tmp_path) -> None:
"""Git metadata probing must not rely on subprocess PATH lookup."""
git = tmp_path / "git"
git.write_text("", encoding="utf-8")
git.chmod(0o755)
with (
patch("shutil.which", return_value=str(git)),
patch(
"subprocess.run",
return_value=SimpleNamespace(returncode=0, stdout="abc123\n"),
) as run,
):
assert _commit_hash(str(tmp_path)) == "abc123"
argv = run.call_args.args[0]
assert Path(argv[0]).is_absolute()
assert argv[1:] == ["rev-parse", "--short", "HEAD"]
def test_missing_git_returns_unknown(self) -> None:
"""Missing Git should degrade to `unknown` without spawning a process."""
with (
patch("shutil.which", return_value=None),
patch("subprocess.run") as run,
):
assert _commit_hash("/tmp") == "unknown"
run.assert_not_called()
class TestRunDoctorCommand:
"""Tests for the text and JSON rendering paths."""
def _run_text(self) -> tuple[int, str]:
buf = io.StringIO()
test_console = Console(file=buf, highlight=False, width=200)
args = argparse.Namespace(output_format="text")
with patch("deepagents_code.config.console", test_console):
code = run_doctor_command(args)
return code, buf.getvalue()
def test_text_output_contains_sections(self) -> None:
"""Text output renders each section title and key facts."""
code, output = self._run_text()
assert code == 0
assert "Diagnostics" in output
assert "Updates" in output
assert "Configuration" in output
assert "deepagents-code" in output
def test_json_output_envelope(self, capsys) -> None:
"""JSON output is a stable envelope with section data."""
args = argparse.Namespace(output_format="json")
code = run_doctor_command(args)
assert code == 0
captured = capsys.readouterr()
envelope = json.loads(captured.out)
assert envelope["command"] == "doctor"
assert envelope["schema_version"] == 1
data = envelope["data"]
assert data["healthy"] is True
titles = [section["title"] for section in data["sections"]]
assert titles == ["Diagnostics", "Updates", "Configuration"]
def test_unhealthy_returns_nonzero(self) -> None:
"""An unhealthy section yields a non-zero exit code."""
unhealthy = [
DiagnosticSection(
title="Diagnostics",
items=[DiagnosticItem("deepagents (SDK)", "not installed", ok=False)],
)
]
args = argparse.Namespace(output_format="text")
buf = io.StringIO()
with (
patch("deepagents_code.doctor.collect_sections", return_value=unhealthy),
patch(
"deepagents_code.config.console",
Console(file=buf, highlight=False, width=200),
),
):
code = run_doctor_command(args)
assert code == 1
class TestPathStatus:
"""Tests for the path-existence diagnostic item."""
def test_existing_path_is_healthy(self, tmp_path) -> None:
"""An existing path reports `exists` and stays healthy."""
from deepagents_code.doctor import _path_status
item = _path_status("Data directory", tmp_path)
assert item.ok is True
assert "exists" in item.value
def test_missing_path_is_healthy(self, tmp_path) -> None:
"""A not-yet-created path is informational, not a failure."""
from deepagents_code.doctor import _path_status
item = _path_status("Data directory", tmp_path / "absent")
assert item.ok is True
assert "not created" in item.value
def test_unreadable_path_is_unhealthy(self, monkeypatch) -> None:
"""An unreadable path is flagged as a genuine problem (`ok=False`)."""
from pathlib import Path
from deepagents_code.doctor import _path_status
def _raise(self: Path) -> object: # noqa: ARG001 # must match Path.stat signature
msg = "permission denied"
raise PermissionError(msg)
monkeypatch.setattr(Path, "stat", _raise)
item = _path_status("Config file", "/some/protected/path")
assert item.ok is False
assert "unreadable" in item.value
class TestDoctorHelp:
"""Tests for the doctor help screen."""
def test_help_renders(self) -> None:
"""`show_doctor_help` prints usage and examples."""
from deepagents_code.ui import show_doctor_help
buf = io.StringIO()
test_console = Console(file=buf, highlight=False, width=200)
with patch("deepagents_code.ui.console", test_console):
show_doctor_help()
output = buf.getvalue()
assert "dcode doctor [options]" in output
assert "Usage:" in output
@@ -19,6 +19,7 @@ from deepagents_code.extras_info import (
format_known_extras,
get_extras_status,
get_optional_dependency_status,
resolve_sdk_version,
verify_interpreter_deps,
)
@@ -355,3 +356,34 @@ def test_format_extras_status_renders_markdown_table() -> None:
assert lines[3] == "| --- | --- | --- |"
assert lines[4] == "| anthropic | langchain-anthropic | 1.4.0 |"
assert lines[5] == "| daytona | langchain-daytona | 0.0.4 |"
class TestResolveSdkVersion:
"""Tests for the shared `deepagents` SDK version resolver."""
def test_resolved_returns_version(self) -> None:
"""A successful lookup reports the version and `resolved` status."""
with patch(
"deepagents_code.extras_info.pkg_version", return_value="1.2.3"
) as mock:
version, status = resolve_sdk_version()
mock.assert_called_once_with("deepagents")
assert (version, status) == ("1.2.3", "resolved")
def test_not_installed_distinguished_from_error(self) -> None:
"""A missing package reports `not_installed`, never `error`."""
with patch(
"deepagents_code.extras_info.pkg_version",
side_effect=PackageNotFoundError("deepagents"),
):
version, status = resolve_sdk_version()
assert (version, status) == (None, "not_installed")
def test_unexpected_error_reports_error_status(self) -> None:
"""Any non-`PackageNotFoundError` failure reports `error`, not a crash."""
with patch(
"deepagents_code.extras_info.pkg_version",
side_effect=RuntimeError("corrupt metadata"),
):
version, status = resolve_sdk_version()
assert (version, status) == (None, "error")
+44
View File
@@ -0,0 +1,44 @@
"""Unit tests for `deepagents_code._paths`."""
from pathlib import Path
import pytest
from deepagents_code._paths import PathState, classify_path
class TestClassifyPath:
"""Tests for the shared path classifier."""
def test_existing_path(self, tmp_path: Path) -> None:
"""A path that exists classifies as EXISTS."""
target = tmp_path / "present"
target.write_text("x")
assert classify_path(target) is PathState.EXISTS
def test_existing_directory(self, tmp_path: Path) -> None:
"""A directory that exists classifies as EXISTS."""
assert classify_path(tmp_path) is PathState.EXISTS
def test_missing_path(self, tmp_path: Path) -> None:
"""A path that does not exist classifies as MISSING."""
assert classify_path(tmp_path / "absent") is PathState.MISSING
def test_unreadable_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""An OSError from `Path.stat()` classifies as UNREADABLE.
Simulates EACCES on a parent directory rather than relying on chmod,
which is ignored when running as root and varies by platform.
"""
def _raise(self: Path) -> object: # noqa: ARG001 # must match Path.stat signature
msg = "permission denied"
raise PermissionError(msg)
monkeypatch.setattr(Path, "stat", _raise)
assert classify_path(Path("/anything")) is PathState.UNREADABLE
def test_state_value_is_json_friendly(self) -> None:
"""`PathState` is a str enum, so its value serializes directly."""
assert PathState.UNREADABLE == "unreadable"
assert PathState.EXISTS.value == "exists"
@@ -46,24 +46,25 @@ def _run_cli_main(argv: list[str]) -> subprocess.CompletedProcess[str]:
from deepagents_code.main import cli_main
argv = ["deepagents", *json.loads(sys.argv[1])]
with (
patch.object(sys, "argv", argv),
patch("deepagents_code.main.check_cli_dependencies"),
):
cli_main()
prefixes = tuple(json.loads(sys.argv[2]))
loaded = sorted(
name for name in sys.modules if name.startswith(prefixes)
)
config_module = sys.modules.get("deepagents_code.config")
bootstrap_done = (
getattr(config_module, "_bootstrap_done", None)
if config_module is not None
else None
)
print("LOADED_MODULES=" + json.dumps(loaded), file=sys.stderr)
print("BOOTSTRAP_DONE=" + json.dumps(bootstrap_done), file=sys.stderr)
try:
with (
patch.object(sys, "argv", argv),
patch("deepagents_code.main.check_cli_dependencies"),
):
cli_main()
finally:
prefixes = tuple(json.loads(sys.argv[2]))
loaded = sorted(
name for name in sys.modules if name.startswith(prefixes)
)
config_module = sys.modules.get("deepagents_code.config")
bootstrap_done = (
getattr(config_module, "_bootstrap_done", None)
if config_module is not None
else None
)
print("LOADED_MODULES=" + json.dumps(loaded), file=sys.stderr)
print("BOOTSTRAP_DONE=" + json.dumps(bootstrap_done), file=sys.stderr)
"""
return subprocess.run(
[
@@ -121,6 +122,38 @@ def test_help_only_commands_skip_runtime_imports(
)
@pytest.mark.parametrize(
("argv", "expected"),
[
(["auth", "path"], "/auth.json"),
(["config", "path", "--json"], '"command": "config path"'),
(["doctor", "--json"], '"command": "doctor"'),
],
)
def test_lightweight_commands_skip_settings_bootstrap(
argv: list[str], expected: str
) -> None:
"""Lightweight diagnostics commands should avoid settings bootstrap."""
result = _run_cli_main(argv)
assert result.returncode == 0, result.stderr
assert expected in result.stdout
bootstrap_done = _read_marker(result.stderr, "BOOTSTRAP_DONE=")
assert bootstrap_done in (None, False), (
f"settings bootstrap must not run for {argv}; got {bootstrap_done!r}"
)
def test_auth_credential_resolution_commands_run_settings_bootstrap() -> None:
"""Auth commands that resolve credentials must see dotenv-loaded values."""
result = _run_cli_main(["auth", "status", "anthropic"])
assert result.returncode == 0, result.stderr
bootstrap_done = _read_marker(result.stderr, "BOOTSTRAP_DONE=")
assert bootstrap_done is True
@pytest.mark.parametrize(
"argv",
[
+3 -1
View File
@@ -138,7 +138,9 @@ async def test_version_slash_command_sdk_unavailable() -> None:
app = DeepAgentsApp()
async with app.run_test() as pilot:
await pilot.pause()
with patch("importlib.metadata.version", side_effect=patched_version):
with patch(
"deepagents_code.extras_info.pkg_version", side_effect=patched_version
):
await app._handle_command("/version")
await pilot.pause()