diff --git a/libs/code/deepagents_code/_env_vars.py b/libs/code/deepagents_code/_env_vars.py index 0087e46569..b1bc5b20e0 100644 --- a/libs/code/deepagents_code/_env_vars.py +++ b/libs/code/deepagents_code/_env_vars.py @@ -167,6 +167,29 @@ _FALSY_VALUES = frozenset({"0", "false", "no", "off", ""}) +def classify_env_bool(raw: str) -> bool | None: + """Classify a raw env-var string as a truthy, falsy, or unrecognized token. + + The single source of truth for which strings count as boolean on/off + values; `is_env_truthy` and the config resolver both build on it so they + agree on what "recognizably boolean" means. + + Args: + raw: The raw (unstripped) environment-variable value. + + Returns: + `True` for `1`/`true`/`yes`/`on`, `False` for `0`/`false`/`no`/`off`/ + empty string (case-insensitive), or `None` when the value + is neither. + """ + lowered = raw.strip().lower() + if lowered in _TRUTHY_VALUES: + return True + if lowered in _FALSY_VALUES: + return False + return None + + def is_env_truthy(name: str, *, default: bool = False) -> bool: """Return whether env var *name* is set to a recognizably truthy value. @@ -187,9 +210,5 @@ def is_env_truthy(name: str, *, default: bool = False) -> bool: raw = os.environ.get(name) if raw is None: return default - lowered = raw.strip().lower() - if lowered in _TRUTHY_VALUES: - return True - if lowered in _FALSY_VALUES: - return False - return default + classified = classify_env_bool(raw) + return default if classified is None else classified diff --git a/libs/code/deepagents_code/config.py b/libs/code/deepagents_code/config.py index f7aa07af5c..3f0d79715d 100644 --- a/libs/code/deepagents_code/config.py +++ b/libs/code/deepagents_code/config.py @@ -21,6 +21,15 @@ from deepagents_code._env_vars import HIDE_SPLASH_VERSION, is_env_truthy from deepagents_code._git import resolve_git_branch from deepagents_code._version import __version__ +from deepagents_code.config_manifest import ( + INTERPRETER_ENABLE_DEFAULT, + INTERPRETER_MAX_PTC_CALLS_DEFAULT, + INTERPRETER_MAX_RESULT_CHARS_DEFAULT, + INTERPRETER_MEMORY_LIMIT_MB_DEFAULT, + INTERPRETER_PTC_ACKNOWLEDGE_UNSAFE_DEFAULT, + INTERPRETER_PTC_DEFAULT, + INTERPRETER_TIMEOUT_SECONDS_DEFAULT, +) logger = logging.getLogger(__name__) @@ -966,36 +975,6 @@ def parse_shell_allow_list(allow_list_str: str | None) -> list[str] | None: `INTERPRETER_PTC_SAFE_PRESET`.""" -def _read_config_toml_interpreter() -> dict[str, Any] | None: - """Read `[interpreter]` from `~/.deepagents/config.toml`. - - Returns: - Mapping of interpreter setting names to raw values, or `None` if the - section is absent or the file cannot be read. - """ - import tomllib - - from deepagents_code.model_config import DEFAULT_CONFIG_PATH - - try: - with DEFAULT_CONFIG_PATH.open("rb") as f: - data = tomllib.load(f) - except FileNotFoundError: - return None - except (PermissionError, OSError, tomllib.TOMLDecodeError): - logger.warning( - "Could not read interpreter config from %s", - DEFAULT_CONFIG_PATH, - exc_info=True, - ) - return None - - section = data.get("interpreter") - if isinstance(section, dict): - return section - return None - - def _parse_interpreter_ptc( raw: Any, # noqa: ANN401 # accepts TOML-shaped value ) -> str | bool | list[str]: @@ -1059,86 +1038,6 @@ def _parse_interpreter_ptc( raise ValueError(msg) -def _resolve_interpreter_kwargs( - section: dict[str, Any] | None, -) -> dict[str, Any]: - """Translate the `[interpreter]` TOML section into `Settings` kwargs. - - Unknown keys are ignored; invalid values fall back to the dataclass - default and emit a warning so a malformed config never blocks startup. - - Args: - section: Raw mapping returned by `_read_config_toml_interpreter`, or - `None` when the section is absent. - - Returns: - Subset of `Settings` field kwargs to splat into the constructor. - """ - if not section: - return {} - - kwargs: dict[str, Any] = {} - - def _coerce(name: str, expected: type, raw: Any) -> None: # noqa: ANN401 - if isinstance(raw, expected): - kwargs[name] = raw - return - logger.warning( - "Ignoring [interpreter].%s=%r in config.toml (expected %s)", - name, - raw, - expected.__name__, - ) - - if "enable_interpreter" in section: - _coerce("enable_interpreter", bool, section["enable_interpreter"]) - if "timeout_seconds" in section: - raw = section["timeout_seconds"] - if isinstance(raw, (int, float)) and not isinstance(raw, bool): - kwargs["interpreter_timeout_seconds"] = float(raw) - else: - logger.warning( - "Ignoring [interpreter].timeout_seconds=%r in config.toml", raw - ) - if "memory_limit_mb" in section: - raw = section["memory_limit_mb"] - if isinstance(raw, int) and not isinstance(raw, bool): - kwargs["interpreter_memory_limit_mb"] = raw - else: - logger.warning( - "Ignoring [interpreter].memory_limit_mb=%r in config.toml", raw - ) - if "max_ptc_calls" in section: - raw = section["max_ptc_calls"] - if isinstance(raw, int) and not isinstance(raw, bool): - kwargs["interpreter_max_ptc_calls"] = raw - else: - logger.warning( - "Ignoring [interpreter].max_ptc_calls=%r in config.toml", raw - ) - if "max_result_chars" in section: - raw = section["max_result_chars"] - if isinstance(raw, int) and not isinstance(raw, bool): - kwargs["interpreter_max_result_chars"] = raw - else: - logger.warning( - "Ignoring [interpreter].max_result_chars=%r in config.toml", raw - ) - if "ptc" in section: - try: - kwargs["interpreter_ptc"] = _parse_interpreter_ptc(section["ptc"]) - except ValueError as exc: - logger.warning("Ignoring [interpreter].ptc in config.toml: %s", exc) - if "ptc_acknowledge_unsafe" in section: - _coerce( - "interpreter_ptc_acknowledge_unsafe", - bool, - section["ptc_acknowledge_unsafe"], - ) - - return kwargs - - def _read_config_toml_retries() -> dict[str, Any] | None: """Read and lightly validate `[retries]` from `~/.deepagents/config.toml`. @@ -1533,32 +1432,35 @@ class Settings: `[skills].extra_allowed_dirs` in `~/.deepagents/config.toml`. """ - enable_interpreter: bool = False + enable_interpreter: bool = INTERPRETER_ENABLE_DEFAULT """Wire `CodeInterpreterMiddleware` from `langchain-quickjs` into the main agent. Local-mode only; raises `ValueError` at agent-build time when a remote sandbox is active. Subagents never receive the interpreter in v1. The `quickjs` optional extra must be installed when this flag is `True`. + + Defaults are owned by `config_manifest` (the canonical config surface) so + they are defined in exactly one place. """ - interpreter_timeout_seconds: float = 5.0 + interpreter_timeout_seconds: float = INTERPRETER_TIMEOUT_SECONDS_DEFAULT """Per-`js_eval`-call wall-clock timeout (seconds) for the QuickJS REPL.""" - interpreter_memory_limit_mb: int = 64 + interpreter_memory_limit_mb: int = INTERPRETER_MEMORY_LIMIT_MB_DEFAULT """QuickJS heap memory cap (MB), shared across all calls within a session.""" - interpreter_max_ptc_calls: int = 256 + interpreter_max_ptc_calls: int = INTERPRETER_MAX_PTC_CALLS_DEFAULT """Maximum `tools.*` host-bridge invocations allowed per `js_eval` call. PTC calls bypass `interrupt_on`/HITL approval — this budget is the only runtime limiter on bursty tool fan-out from inside the REPL. """ - interpreter_max_result_chars: int = 4000 + interpreter_max_result_chars: int = INTERPRETER_MAX_RESULT_CHARS_DEFAULT """Independent cap (chars) on `js_eval` result and stdout blocks before truncation.""" - interpreter_ptc: str | bool | list[str] = False + interpreter_ptc: str | bool | list[str] = INTERPRETER_PTC_DEFAULT """Programmatic tool calling allowlist for `js_eval`. Accepted values: @@ -1573,7 +1475,9 @@ class Settings: runtime, so names not present are simply not exposed. """ - interpreter_ptc_acknowledge_unsafe: bool = False + interpreter_ptc_acknowledge_unsafe: bool = ( + INTERPRETER_PTC_ACKNOWLEDGE_UNSAFE_DEFAULT + ) """Explicit acknowledgement required when `interpreter_ptc="all"` is set without `auto_approve`. @@ -1638,9 +1542,9 @@ def from_environment(cls, *, start_path: Path | None = None) -> Settings: _read_config_toml_skills_dirs(), ) - interpreter_kwargs = _resolve_interpreter_kwargs( - _read_config_toml_interpreter() - ) + from deepagents_code.config_manifest import resolve_interpreter_kwargs + + interpreter_kwargs = resolve_interpreter_kwargs() return cls( openai_api_key=openai_key, diff --git a/libs/code/deepagents_code/config_commands.py b/libs/code/deepagents_code/config_commands.py new file mode 100644 index 0000000000..e6ae3797eb --- /dev/null +++ b/libs/code/deepagents_code/config_commands.py @@ -0,0 +1,487 @@ +"""CLI commands for the `config` group: inspect the configuration surface. + +`config list` prints the static manifest (every tunable option, its type, +default, and where it can be set). `config show` resolves each option against +the live environment and `config.toml`, reporting the effective value and which +source provided it. `config get ` does the same for a single option. +`config path` prints the on-disk config locations. + +Secret-flagged options (API keys and other credentials) are never printed by +value — `config show`/`config get` report only whether they are set and from +which source, so the output is safe to paste into a bug report. + +Help rendering for a bare `config` invocation is served by `ui.show_config_help`, +which does not import this module. The heavy manifest/runtime imports here are +function-local to the subcommands, so a bare `config`/`config -h` invocation +never pulls them onto the startup path (`parse_args` does import this module to +register the subparsers, but only its light top-level imports run then). +""" + +from __future__ import annotations + +import importlib.util +import logging +import sys +from typing import TYPE_CHECKING, Any + +from deepagents_code.output import write_json + +if TYPE_CHECKING: + import argparse + from collections.abc import Callable + + from deepagents_code.config_manifest import ConfigOption + from deepagents_code.output import OutputFormat + +logger = logging.getLogger(__name__) + + +def _lazy_ui_help(fn_name: str) -> Callable[[], None]: + """Return a callable that lazily imports and invokes a `ui` help function.""" + + def _show() -> None: + from deepagents_code import ui + + getattr(ui, fn_name)() + + return _show + + +def setup_config_parser( + subparsers: Any, # noqa: ANN401 + *, + make_help_action: Callable[[Callable[[], None]], type[argparse.Action]], + add_output_args: Callable[..., None], +) -> None: + """Register the `dcode config` command group. + + Args: + subparsers: The `argparse` subparsers object from the top-level CLI + parser, onto which the `config` command group is attached. + make_help_action: Factory that wraps a `show_*` callable into an + `argparse.Action` so `-h/--help` renders the hand-maintained + help screens from `deepagents_code.ui`. + add_output_args: Helper that adds the shared `--json` flag. + """ + config_parser = subparsers.add_parser( + "config", + help="Inspect configuration options and their sources", + add_help=False, + ) + config_parser.add_argument( + "-h", + "--help", + action=make_help_action(_lazy_ui_help("show_config_help")), + ) + add_output_args(config_parser) + config_sub = config_parser.add_subparsers(dest="config_command") + + show_parser = config_sub.add_parser( + "show", + help="Show effective config values and their source", + add_help=False, + ) + show_parser.add_argument( + "-h", + "--help", + action=make_help_action(_lazy_ui_help("show_config_help")), + ) + add_output_args(show_parser) + + list_parser = config_sub.add_parser( + "list", + aliases=["ls"], + help="List all available config options", + add_help=False, + ) + list_parser.add_argument( + "-h", + "--help", + action=make_help_action(_lazy_ui_help("show_config_help")), + ) + add_output_args(list_parser) + + get_parser = config_sub.add_parser( + "get", + help="Show the effective value and source for one option", + add_help=False, + ) + get_parser.add_argument("key", help="Option key (e.g. interpreter.memory_limit_mb)") + get_parser.add_argument( + "-h", + "--help", + action=make_help_action(_lazy_ui_help("show_config_help")), + ) + add_output_args(get_parser) + + path_parser = config_sub.add_parser( + "path", + help="Show config file locations", + add_help=False, + ) + path_parser.add_argument( + "-h", + "--help", + action=make_help_action(_lazy_ui_help("show_config_help")), + ) + add_output_args(path_parser) + + +# --- Resolution ------------------------------------------------------------- + + +def _resolve(option: ConfigOption, toml_data: dict[str, Any]) -> tuple[bool, str, Any]: + """Resolve an option via the shared manifest resolver. + + Delegates to `config_manifest.resolve_scalar` so `config show`/`get` + report exactly what the runtime reads. + + Returns: + `(is_set, source, value)`, where `is_set` is `False` when the value + came from the typed default. + """ + from deepagents_code.config_manifest import resolve_scalar + + value, source = resolve_scalar(option, toml_data=toml_data) + return source != "default", source, value + + +def _display_value(option: ConfigOption, *, is_set: bool, value: object) -> str: + """Render an option value for human output, redacting secrets. + + Returns: + `configured`/`not configured` for credential options, otherwise the value + as text. + """ + if option.group == "Credentials": + if value is None: + return _with_availability(option, "not configured") + if option.redacted: + status = "configured" if is_set else "not configured" + return _with_availability(option, status) + if value is None: + return "(unset)" + if option.key == "display.charset" and value == "auto": + return _charset_display_value() + text = str(value) + if option.group == "Credentials": + text = _with_availability(option, text) + max_len = 60 + if len(text) > max_len: + return text[: max_len - 1] + "\N{HORIZONTAL ELLIPSIS}" + return text + + +def _source_label(source: str) -> str: + """Render the source column for human output. + + Returns: + Source label for the value's origin. + """ + return source + + +def _with_availability(option: ConfigOption, text: str) -> str: + """Append provider availability to a credential display value when needed. + + Returns: + Display text with `, unavailable` appended when the provider integration + package is missing. + """ + if _missing_extra_hint(option): + return f"{text}, unavailable" + return text + + +def _charset_display_value() -> str: + """Return the `display.charset=auto` value with its effective glyph mode.""" + from deepagents_code.config import _detect_charset_mode + + mode = _detect_charset_mode().value + label = "Unicode" if mode == "unicode" else "ASCII" + return f"auto (using {label} glyphs)" + + +def _missing_extra_hint(option: ConfigOption) -> bool: + """Return whether a credential option's provider integration is unavailable.""" + if option.group != "Credentials" or option.dependency_module is None: + return False + return importlib.util.find_spec(option.dependency_module) is None + + +# --- Commands --------------------------------------------------------------- + + +def _run_show(output_format: OutputFormat) -> int: + """Resolve every option and print its effective value and source. + + Returns: + Process exit code (`0` on success). + """ + from deepagents_code.config import _ensure_bootstrap + from deepagents_code.config_manifest import get_config_options, load_config_toml + + # Load `.env` files into the environment so resolution reflects what the + # app actually reads, not just shell exports. + _ensure_bootstrap() + toml_data = load_config_toml() + + options = get_config_options() + resolved = [(opt, *_resolve(opt, toml_data)) for opt in options] + + if output_format == "json": + write_json( + "config show", + [ + { + "key": opt.key, + "group": opt.group, + "source": source, + "set": is_set, + "redacted": opt.redacted, + # Redact secret values: report presence only. + "value": None if opt.redacted else value, + } + for opt, is_set, source, value in resolved + ], + ) + return 0 + + from rich.markup import escape + + from deepagents_code.config import console + from deepagents_code.config_manifest import iter_groups + + console.print() + for group in iter_groups(options): + console.print(f"[bold]{group}[/bold]") + for opt, is_set, source, value in resolved: + if opt.group != group: + continue + display = _display_value(opt, is_set=is_set, value=value) + source_label = _source_label(source) + # `display` and `source_label` may contain Rich markup from env/TOML + # or terminal metadata; escape them so values can't break rendering. + display_text = escape(display) + source_text = escape(source_label) + console.print( + f" {opt.key:<34} {display_text:<22} [dim]{source_text}[/dim]", + highlight=False, + ) + console.print() + return 0 + + +def _run_list(output_format: OutputFormat) -> int: + """Print the static catalog of available options (no resolution). + + Returns: + Process exit code (`0` on success). + """ + from deepagents_code.config_manifest import get_config_options + + options = get_config_options() + if output_format == "json": + write_json( + "config list", + [ + { + "key": opt.key, + "group": opt.group, + "summary": opt.summary, + "type": opt.type, + "default": opt.default, + "redacted": opt.redacted, + "env_var": opt.env_var, + "toml_path": opt.toml_path, + "cli_flag": opt.cli_flag, + } + for opt in options + ], + ) + return 0 + + from deepagents_code.config import console + from deepagents_code.config_manifest import iter_groups + + console.print() + for group in iter_groups(options): + console.print(f"[bold]{group}[/bold]") + for opt in options: + if opt.group != group: + continue + console.print(f" [cyan]{opt.key}[/cyan] [dim]({opt.type})[/dim]") + console.print(f" {opt.summary}", highlight=False) + console.print(f" {_sources_line(opt)}", highlight=False, style="dim") + console.print() + return 0 + + +def _run_get(key: str, output_format: OutputFormat) -> int: + """Resolve and print a single option by key. + + Returns: + Process exit code (`0` on success, `1` for an unknown key). + """ + from deepagents_code.config_manifest import get_option + + option = get_option(key) + if option is None: + if output_format == "json": + write_json("config get", {"key": key, "error": "unknown option"}) + else: + print( # noqa: T201 + f"Unknown config option: {key!r}. Run `dcode config list` to " + "see available keys.", + file=sys.stderr, + ) + return 1 + + from deepagents_code.config import _ensure_bootstrap + from deepagents_code.config_manifest import load_config_toml + + _ensure_bootstrap() + toml_data = load_config_toml() + is_set, source, value = _resolve(option, toml_data) + + if output_format == "json": + write_json( + "config get", + { + "key": option.key, + "source": source, + "set": is_set, + "redacted": option.redacted, + "value": None if option.redacted else value, + }, + ) + return 0 + + from rich.markup import escape + + from deepagents_code.config import console + + display = _display_value(option, is_set=is_set, value=value) + source_label = _source_label(source) + console.print( + f"{option.key} = {escape(display)} [dim]({escape(source_label)})[/dim]", + highlight=False, + ) + return 0 + + +def _run_path(output_format: OutputFormat) -> int: + """Print the on-disk config file locations and whether they exist. + + Returns: + Process exit code (`0` on success). + """ + paths = _config_paths() + + if output_format == "json": + write_json( + "config path", + [ + {"label": label, "path": str(path), "exists": exists} + for label, path, exists in paths + ], + ) + return 0 + + from deepagents_code.config import console + + console.print() + console.print("[bold]Config locations[/bold]") + for label, path, exists in paths: + marker = "[green]exists[/green]" if exists else "[dim]missing[/dim]" + console.print(f" {label:<22} {path} ({marker})", highlight=False) + console.print() + return 0 + + +def run_config_command(args: argparse.Namespace) -> int: + """Dispatch a parsed `config` subcommand. + + Returns: + Process exit code from the dispatched subcommand. + """ + output_format: OutputFormat = getattr(args, "output_format", "text") + command = getattr(args, "config_command", None) + + if command == "show": + return _run_show(output_format) + if command in {"list", "ls"}: + return _run_list(output_format) + if command == "get": + return _run_get(args.key, output_format) + if command == "path": + return _run_path(output_format) + + from deepagents_code.ui import show_config_help + + show_config_help() + return 0 + + +# --- Helpers ---------------------------------------------------------------- + + +def _sources_line(option: ConfigOption) -> str: + """Render a compact 'set via' line for `config list`. + + Returns: + A human-readable description of where the option can be set. + """ + parts: list[str] = [] + if option.env_var: + parts.append(f"env {option.env_var}") + if option.toml_path: + parts.append(f"toml {option.toml_path}") + if option.cli_flag: + parts.append(f"cli {option.cli_flag}") + default = f"default {option.default}" if option.default is not None else "" + set_via = "set via " + ", ".join(parts) if parts else "managed by the app" + return f"{set_via}{(' | ' + default) if default else ''}" + + +def _config_paths() -> list[tuple[str, Any, bool]]: + """Collect known config file locations and whether each exists. + + Returns: + A list of `(label, path, exists)` rows in display order. + """ + from pathlib import Path + + from deepagents_code.config import _GLOBAL_DOTENV_PATH, _find_dotenv_from_start_path + from deepagents_code.model_config import ( + DEFAULT_CONFIG_PATH, + DEFAULT_STATE_DIR, + RECENT_MODELS_FILENAME, + ) + + base = DEFAULT_CONFIG_PATH.parent + project_dotenv = _find_dotenv_from_start_path(Path.cwd()) + + candidates: list[tuple[str, Path | None]] = [ + ("config.toml", DEFAULT_CONFIG_PATH), + ("project .env", project_dotenv), + ("global .env", _GLOBAL_DOTENV_PATH), + ("hooks.json", base / "hooks.json"), + ("auth.json", DEFAULT_STATE_DIR / "auth.json"), + ("recent models", DEFAULT_STATE_DIR / RECENT_MODELS_FILENAME), + ] + + 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 + rows.append((label, path, exists)) + return rows diff --git a/libs/code/deepagents_code/config_manifest.py b/libs/code/deepagents_code/config_manifest.py new file mode 100644 index 0000000000..c0c31683ff --- /dev/null +++ b/libs/code/deepagents_code/config_manifest.py @@ -0,0 +1,1046 @@ +"""Canonical manifest and resolver for every user-tunable scalar config option. + +This module is the single source of truth for the configuration *surface*: the +set of options, their types, typed defaults, env-var names, and `config.toml` +locations. The typed defaults for config-file-only options (notably the +`[interpreter]` section) live here as module constants, and `Settings` derives +its dataclass defaults from them — so a default is defined in exactly one place. + +`resolve_scalar` is the shared resolution engine used both by the runtime +(`Settings.from_environment`) and by the `config` CLI command, so introspection +can never drift from what the app actually reads. Resolution precedence mirrors +the loaders: a `DEEPAGENTS_CODE_`-prefixed env var beats the canonical name, +env beats `config.toml`, and the typed default is the final fallback. A +malformed numeric/list/PTC value, an unrecognized boolean token, or a +wrong-typed TOML value is logged and falls back to the next layer rather than +raising, so a bad config never blocks startup. + +Structured, user-defined config is *not* a flat scalar option and is parsed by +dedicated typed loaders elsewhere. The manifest references `[threads].columns` +and `[warnings].suppress` as `STRUCTURED` options for discovery; other tables +such as `[models.providers.*]` and `[themes.*]` are handled entirely by their +own loaders and the manifest does not enumerate them at all. + +Import discipline: the module top level stays stdlib + `_env_vars` only (both +light) so it is safe to import from `config.py` at class-definition time without +pulling the heavy `model_config`/agent runtime onto the startup fast path. +Anything needing `model_config` (provider credentials, the config path, env-var +prefix resolution) is imported lazily inside functions. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from enum import Enum +from functools import lru_cache +from typing import TYPE_CHECKING, Any, assert_never, cast + +from deepagents_code import _env_vars +from deepagents_code._env_vars import classify_env_bool + +if TYPE_CHECKING: + from collections.abc import Iterable + +logger = logging.getLogger(__name__) + + +# --- Canonical typed defaults ---------------------------------------------- +# These are the single source of truth for `[interpreter]` defaults. The +# `Settings` dataclass references them so the default is defined once. + +INTERPRETER_ENABLE_DEFAULT = False +INTERPRETER_TIMEOUT_SECONDS_DEFAULT = 5.0 +INTERPRETER_MEMORY_LIMIT_MB_DEFAULT = 64 +INTERPRETER_MAX_PTC_CALLS_DEFAULT = 256 +INTERPRETER_MAX_RESULT_CHARS_DEFAULT = 4000 +INTERPRETER_PTC_DEFAULT: str | bool | list[str] = False +INTERPRETER_PTC_ACKNOWLEDGE_UNSAFE_DEFAULT = False + + +class OptionKind(Enum): + """How an option's raw env/TOML value is coerced to a typed value. + + All kinds flow through `resolve_scalar`. The scalar kinds (`BOOL`, + `BOOL_PRESENCE`, `INT`, `FLOAT`, `STR`) are coerced inline by + `_coerce_env`/`_coerce_toml`. `SHELL_LIST_DELEGATE`, `SKILLS_DIRS_DELEGATE`, + and `PTC_DELEGATE` defer to a bespoke parser (their semantics — colon-split + Path resolution, comma + `recommended`/`all` sentinels, the PTC allowlist — + do not compress into a generic coercion). `THEME_DELEGATE` is resolved + separately at the top of `resolve_scalar` and never reaches the inline + coercers. `STRUCTURED` marks user-defined tables that the scalar resolver + only passes through for display. + """ + + BOOL = "bool" + """Recognized truthy (`1`/`true`/`yes`/`on`) or falsy (`0`/`false`/`no`/`off`) + tokens; an unrecognized value is logged and skipped to the next layer.""" + + BOOL_PRESENCE = "bool_presence" + """Any non-empty env value enables the flag (e.g. debug injectors).""" + + INT = "int" + + FLOAT = "float" + + STR = "str" + + SHELL_LIST_DELEGATE = "shell_list" + """Delegates to `config.parse_shell_allow_list`.""" + + SKILLS_DIRS_DELEGATE = "skills_dirs" + """Delegates to `config._parse_extra_skills_dirs`.""" + + PTC_DELEGATE = "ptc" + """Delegates to `config._parse_interpreter_ptc`.""" + + THEME_DELEGATE = "theme" + """Delegates to the app theme-preference loader semantics.""" + + STRUCTURED = "structured" + """User-defined table parsed by a dedicated loader; not scalar-coerced.""" + + +_KIND_TYPE_LABEL: dict[OptionKind, str] = { + OptionKind.BOOL: "bool", + OptionKind.BOOL_PRESENCE: "bool", + OptionKind.INT: "int", + OptionKind.FLOAT: "float", + OptionKind.STR: "str", + OptionKind.SHELL_LIST_DELEGATE: "list[str]", + OptionKind.SKILLS_DIRS_DELEGATE: "list[path]", + OptionKind.PTC_DELEGATE: "str | list[str]", + OptionKind.THEME_DELEGATE: "theme", + OptionKind.STRUCTURED: "table", +} + +if _KIND_TYPE_LABEL.keys() != set(OptionKind): + # Fail at import (and in the test suite) rather than KeyError-ing from + # `ConfigOption.type` only when an unlabeled kind happens to be rendered. + msg = "_KIND_TYPE_LABEL is missing an OptionKind entry" + raise RuntimeError(msg) + + +# Python types accepted for a `ConfigOption.default` of each scalar kind, +# enforced by `ConfigOption.__post_init__`. Delegate kinds accept their parser's +# output shape and are validated by those parsers, so they are omitted here. +_KIND_DEFAULT_TYPES: dict[OptionKind, tuple[type, ...]] = { + OptionKind.BOOL: (bool,), + OptionKind.BOOL_PRESENCE: (bool,), + OptionKind.INT: (int,), + OptionKind.FLOAT: (int, float), + OptionKind.STR: (str,), +} + + +@dataclass(frozen=True) +class ConfigOption: + """One user-tunable configuration option and where it can be set.""" + + key: str + """Canonical dotted identifier used by `config get`. + + Also used as the stable display key. + """ + + group: str + """Human-readable grouping for `config list` and `config show`.""" + + summary: str + """One-line description of what the option controls.""" + + kind: OptionKind + """How env/TOML values are coerced to a typed value.""" + + default: Any = None + """Typed default value, or `None` when there is no static default.""" + + env_var: str | None = None + """Primary environment variable name the loader reads, or `None`. + + For provider credentials this is the canonical name; the + `DEEPAGENTS_CODE_` prefix override is applied dynamically at resolution time. + """ + + toml_keys: tuple[str, ...] | None = None + """Section/key path within `config.toml`, or `None`.""" + + invert_toml_bool: bool = False + """Whether a TOML bool should be negated after validation.""" + + cli_flag: str | None = None + """Representative CLI flag that sets the option, or `None`.""" + + redacted: bool = False + """Whether `config show` reports only set/not-set, never the raw value. + + Named `redacted` rather than `secret` so the value (and the JSON field it + populates) carries no credential-suggesting identifier — the flag is + boolean metadata, and a `secret`-named value tripped CodeQL's clear-text + logging heuristic when written to stdout. + """ + + settings_field: str | None = None + """Name of the `Settings` attribute this option backs, or `None`. + + `None` means the option is read elsewhere inline or is descriptive. + """ + + dependency_module: str | None = None + """Import module required to use the option, or `None`. + + `None` means the option is always available or descriptive only. + """ + + install_extra: str | None = None + """Optional `deepagents-code[...]` extra that provides `dependency_module`.""" + + def __post_init__(self) -> None: + """Reject a `default` that contradicts `kind` at construction time. + + The manifest is a hand-edited literal table with `default: Any`, so a + mistyped default (an `INT` option defaulting to a `str`) or a mutable + one would otherwise slip through to runtime — a wrong-typed default + feeds `Settings` unchecked, and a mutable default is shared by reference + through the `get_config_options` `lru_cache` and returned verbatim by + `resolve_scalar`. Catching it here fails the import (and the test suite). + + Raises: + TypeError: When `default` is mutable, a `STRUCTURED` option declares + a default, or a scalar option's default has the wrong type. + """ + default = self.default + if default is None: + if self.invert_toml_bool: + self._validate_invert_toml_bool() + return + if isinstance(default, (list, dict, set)): + msg = ( + f"{self.key}: mutable default {default!r} is unsafe under the " + "shared lru_cache; use an immutable value (e.g. a tuple)" + ) + raise TypeError(msg) + if self.kind is OptionKind.STRUCTURED: + msg = f"{self.key}: STRUCTURED options must not declare a default" + raise TypeError(msg) + if self.invert_toml_bool: + self._validate_invert_toml_bool() + expected = _KIND_DEFAULT_TYPES.get(self.kind) + if expected is None: + # Delegate kinds validate their own (immutable) default shapes. + return + # `bool` is an `int` subclass; an INT/FLOAT default must not be a bool. + if not isinstance(default, expected) or ( + self.kind in {OptionKind.INT, OptionKind.FLOAT} + and isinstance(default, bool) + ): + msg = ( + f"{self.key}: default {default!r} is not valid for kind " + f"{self.kind.value}" + ) + raise TypeError(msg) + + def _validate_invert_toml_bool(self) -> None: + """Validate the inverted TOML bool marker is only used where coherent. + + Raises: + TypeError: When the marker is used without a boolean TOML source. + """ + if self.kind not in {OptionKind.BOOL, OptionKind.BOOL_PRESENCE}: + msg = f"{self.key}: invert_toml_bool requires a boolean option kind" + raise TypeError(msg) + if self.toml_keys is None: + msg = f"{self.key}: invert_toml_bool requires toml_keys" + raise TypeError(msg) + + @property + def type(self) -> str: + """Human-readable type label derived from `kind`.""" + return _KIND_TYPE_LABEL[self.kind] + + @property + def toml_path(self) -> str | None: + """Render `toml_keys` as a `[section].key` display string.""" + if not self.toml_keys: + return None + *sections, leaf = self.toml_keys + if not sections: + return leaf + return f"[{'.'.join(sections)}].{leaf}" + + +# --- Resolution ------------------------------------------------------------- + +_INVALID = object() +"""Sentinel: a raw value failed coercion and the next layer should be tried.""" + + +def load_config_toml() -> dict[str, Any]: + """Load `~/.deepagents/config.toml`. + + Returns: + The parsed config mapping, or `{}` when the file is absent or invalid. + """ + import tomllib + + from deepagents_code.model_config import DEFAULT_CONFIG_PATH + + try: + with DEFAULT_CONFIG_PATH.open("rb") as f: + return tomllib.load(f) + except FileNotFoundError: + return {} + except (OSError, tomllib.TOMLDecodeError): + # `exc_info=True` preserves the TOML line/column (or permission cause): + # a corrupt file makes every option fall back to its default, so the + # log must say *why*, not just that the read failed. + logger.warning( + "Could not read config from %s; using defaults for all options", + DEFAULT_CONFIG_PATH, + exc_info=True, + ) + return {} + + +def _toml_lookup(data: dict[str, Any], keys: tuple[str, ...]) -> tuple[bool, Any]: + """Navigate nested `keys` in `data`. + + Returns: + `(found, value)`, where `found` is `False` if any key was missing. + """ + node: Any = data + for key in keys: + if not isinstance(node, dict) or key not in node: + return False, None + node = node[key] + return True, node + + +def _coerce_env(option: ConfigOption, raw: str, name: str) -> object: + """Coerce a raw environment-variable string by the option's kind. + + Returns: + The typed value, or `_INVALID` when the raw value cannot be coerced. + """ + kind = option.kind + if kind is OptionKind.BOOL: + classified = classify_env_bool(raw) + if classified is None: + # Unrecognized boolean token: log and fall through like every other + # malformed scalar, so `config show` reports the real source + # (config.toml/default) instead of crediting the env var with a + # value it did not actually supply. + logger.warning("Ignoring %s=%r (expected bool)", name, raw) + return _INVALID + return classified + if kind is OptionKind.BOOL_PRESENCE: + return bool(raw) + if kind is OptionKind.STR: + return raw + if kind is OptionKind.INT: + try: + return int(raw.strip()) + except ValueError: + logger.warning("Ignoring %s=%r (expected int)", name, raw) + return _INVALID + if kind is OptionKind.FLOAT: + try: + return float(raw.strip()) + except ValueError: + logger.warning("Ignoring %s=%r (expected number)", name, raw) + return _INVALID + if kind is OptionKind.SHELL_LIST_DELEGATE: + from deepagents_code.config import parse_shell_allow_list + + try: + return parse_shell_allow_list(raw) + except ValueError: + logger.warning("Ignoring invalid %s", name) + return _INVALID + if kind is OptionKind.SKILLS_DIRS_DELEGATE: + from deepagents_code.config import _parse_extra_skills_dirs + + try: + return _parse_extra_skills_dirs(raw, None) + except (ValueError, RuntimeError): + # `Path.expanduser()` raises on an unresolvable `~user`, `.resolve()` + # on a NUL byte; fall back rather than crash resolution/startup. + logger.warning("Ignoring %s (could not resolve a path)", name) + return _INVALID + if kind is OptionKind.THEME_DELEGATE: + # Resolved upstream in `resolve_scalar` and never reaches here; the raw + # passthrough is a defensive fallback only. + return raw + if kind is OptionKind.PTC_DELEGATE or kind is OptionKind.STRUCTURED: + # Neither kind declares an `env_var`, so the `if option.env_var` guard in + # `resolve_scalar` means this is unreachable today. If a future option + # ever adds an env var for one of these, return `_INVALID` rather than + # the raw string: passing an uncoerced value into a typed `Settings` + # field (e.g. `interpreter_ptc`) would bypass the delegate parser's + # validation. Falling back to the validated default is the safe choice. + logger.warning("%s is not env-backed; ignoring %s=%r", option.key, name, raw) + return _INVALID + assert_never(kind) + + +def _coerce_toml(option: ConfigOption, raw: object) -> object: + """Coerce a raw TOML value by the option's kind, logging on mismatch. + + Returns: + The typed value, or `_INVALID` when the raw value has the wrong shape. + """ + kind = option.kind + label = option.toml_path or option.key + + if kind in {OptionKind.BOOL, OptionKind.BOOL_PRESENCE}: + if isinstance(raw, bool): + return not raw if option.invert_toml_bool else raw + elif kind is OptionKind.INT: + if isinstance(raw, int) and not isinstance(raw, bool): + return raw + elif kind is OptionKind.FLOAT: + if isinstance(raw, (int, float)) and not isinstance(raw, bool): + return float(raw) + elif kind is OptionKind.STR: + if isinstance(raw, str): + return raw + elif kind is OptionKind.SKILLS_DIRS_DELEGATE: + if isinstance(raw, list): + from deepagents_code.config import _parse_extra_skills_dirs + + try: + # `raw` is a TOML list of unknown element type; the callee + # guards each entry with `isinstance(p, str)`. + return _parse_extra_skills_dirs(None, cast("list[str]", raw)) + except (ValueError, RuntimeError): + # Unresolvable `~user` / NUL byte in a path string: fall back + # rather than crash resolution. + logger.warning( + "Ignoring %s in config.toml (could not resolve a path)", label + ) + return _INVALID + elif kind is OptionKind.PTC_DELEGATE: + from deepagents_code.config import _parse_interpreter_ptc + + try: + return _parse_interpreter_ptc(raw) + except ValueError as exc: + logger.warning("Ignoring %s in config.toml: %s", label, exc) + return _INVALID + elif kind is OptionKind.STRUCTURED: + # Passed through verbatim for display; parsed by a dedicated loader. + return raw + elif kind is OptionKind.SHELL_LIST_DELEGATE: + # Env-only; never read from TOML, so passed through untouched. + return raw + # Any other (future) kind falls through to the warning below, so a missing + # branch logs and falls back rather than passing a raw value through. + + logger.warning( + "Ignoring %s=%r in config.toml (expected %s)", label, raw, option.type + ) + return _INVALID + + +def _resolve_theme(toml_data: dict[str, Any]) -> tuple[str, str]: + """Resolve the active theme using the same precedence as app startup. + + Returns: + `(theme_name, source)` for the effective Textual theme. + """ + from deepagents_code import theme + from deepagents_code._env_vars import THEME + from deepagents_code.app import _resolve_terminal_mapping, _resolve_theme_name + + env_name = os.environ.get(THEME) + if env_name is not None: + resolved = _resolve_theme_name(env_name) + if resolved is not None: + return resolved, f"env ({THEME})" + logger.warning( + "Unknown theme '%s' in %s; falling back to default", + env_name, + THEME, + ) + return theme.DEFAULT_THEME, "default" + + ui = toml_data.get("ui", {}) + if not isinstance(ui, dict): + if ui is not None: + logger.warning( + "[ui] should be a table; got %s while resolving theme", + type(ui).__name__, + ) + return theme.DEFAULT_THEME, "default" + + resolved = _resolve_terminal_mapping(ui) + if resolved is not None: + term_program = os.environ.get("TERM_PROGRAM", "").strip() + return resolved, f"config.toml [ui.terminal_themes.{term_program}]" + + saved = ui.get("theme") + resolved = _resolve_theme_name(saved) + if resolved is not None: + return resolved, "config.toml [ui.theme]" + if isinstance(saved, str): + logger.warning("Unknown theme '%s' in config; falling back to default", saved) + + return theme.DEFAULT_THEME, "default" + + +def resolve_scalar( + option: ConfigOption, *, toml_data: dict[str, Any] +) -> tuple[Any, str]: + """Resolve an option against the environment then `config.toml`. + + Args: + option: The option to resolve. + toml_data: Parsed `config.toml` mapping (see `load_config_toml`). + + Returns: + `(value, source)`, where `source` is `env ()`, `config.toml`, or + `default`. A malformed `int`/`float`/list/PTC value, an unrecognized + boolean token, or any TOML value of the wrong type is logged and skipped + so the next layer (or the typed default) applies. An empty env value is + treated as unset (mirroring `resolve_env_var`), so it falls through to + `config.toml`/`default` rather than counting as set. Theme resolution + (`THEME_DELEGATE`) reports its own richer `config.toml [ui.*]` sources. + """ + if option.kind is OptionKind.THEME_DELEGATE: + return _resolve_theme(toml_data) + + if option.env_var: + from deepagents_code.model_config import resolved_env_var_name + + name = resolved_env_var_name(option.env_var) + # An empty string counts as unset, matching `resolve_env_var`: this + # keeps `config show`/`get` aligned with what the runtime reads (and + # lets a prefixed empty var suppress a canonical one). + raw = os.environ.get(name) + if raw: + value = _coerce_env(option, raw, name) + if value is not _INVALID: + return value, f"env ({name})" + + if option.toml_keys: + found, raw = _toml_lookup(toml_data, option.toml_keys) + if found: + value = _coerce_toml(option, raw) + if value is not _INVALID: + return value, "config.toml" + + return option.default, "default" + + +def resolve_interpreter_kwargs( + *, toml_data: dict[str, Any] | None = None +) -> dict[str, Any]: + """Resolve the `[interpreter]` options into `Settings` constructor kwargs. + + Only the interpreter group is resolved through the manifest. Credentials, + the shell allow-list, and the LangSmith project keep their dedicated + loaders in `config.py` (their empty-string-to-`None` and reload semantics + do not fit the generic resolver), so this stays scoped to the section whose + defaults this module owns. + + Args: + toml_data: Parsed `config.toml`; loaded automatically when omitted. + + Returns: + Mapping of `Settings` field name to resolved value for the interpreter + section, suitable for splatting into `Settings(...)`. + """ + data = load_config_toml() if toml_data is None else toml_data + resolved: dict[str, Any] = {} + for option in get_config_options(): + if option.group != "Interpreter" or option.settings_field is None: + continue + value, _ = resolve_scalar(option, toml_data=data) + resolved[option.settings_field] = value + return resolved + + +# --- Option definitions ----------------------------------------------------- + +# Search credentials that are not provider API keys live outside +# `PROVIDER_API_KEY_ENV`, so they are declared explicitly. +_EXTRA_CREDENTIAL_ENV: dict[str, str] = { + "tavily": "TAVILY_API_KEY", +} + +_SECRET_NAME_MARKERS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "APIKEY") + +_PROVIDER_DEPENDENCIES: dict[str, tuple[str, str]] = { + "anthropic": ("langchain_anthropic", "anthropic"), + "azure_openai": ("langchain_openai", "openai"), + "baseten": ("langchain_baseten", "baseten"), + "cohere": ("langchain_cohere", "cohere"), + "deepseek": ("langchain_deepseek", "deepseek"), + "fireworks": ("langchain_fireworks", "fireworks"), + "google_genai": ("langchain_google_genai", "google-genai"), + "google_vertexai": ("langchain_google_vertexai", "vertex"), + "groq": ("langchain_groq", "groq"), + "huggingface": ("langchain_huggingface", "huggingface"), + "ibm": ("langchain_ibm", "ibm"), + "litellm": ("langchain_litellm", "litellm"), + "mistralai": ("langchain_mistralai", "mistralai"), + "nvidia": ("langchain_nvidia_ai_endpoints", "nvidia"), + "openai": ("langchain_openai", "openai"), + "openrouter": ("langchain_openrouter", "openrouter"), + "perplexity": ("langchain_perplexity", "perplexity"), + "together": ("langchain_together", "together"), + "xai": ("langchain_xai", "xai"), +} +"""Provider integration import modules and the extras that install them.""" + +# Credentials that back a `Settings` field, keyed by canonical env var. +_CREDENTIAL_SETTINGS_FIELD: dict[str, str] = { + "OPENAI_API_KEY": "openai_api_key", + "ANTHROPIC_API_KEY": "anthropic_api_key", + "GOOGLE_API_KEY": "google_api_key", + "NVIDIA_API_KEY": "nvidia_api_key", + "TAVILY_API_KEY": "tavily_api_key", + "GOOGLE_CLOUD_PROJECT": "google_cloud_project", +} + + +def _is_secret_env(name: str) -> bool: + """Return whether a credential env var name carries secret material.""" + return any(marker in name for marker in _SECRET_NAME_MARKERS) + + +def _credential_options() -> tuple[ConfigOption, ...]: + """Build credential options from the canonical provider/key registries. + + Generating these from `PROVIDER_API_KEY_ENV` (rather than hand-listing + them) guarantees every provider the app knows how to authenticate has a + manifest entry, so new providers can never silently miss the config + surface. + + Returns: + One credential `ConfigOption` per known provider/key env var. + """ + from deepagents_code.model_config import PROVIDER_API_KEY_ENV + + options: list[ConfigOption] = [] + seen: set[str] = set() + sources = {**PROVIDER_API_KEY_ENV, **_EXTRA_CREDENTIAL_ENV} + for name, env_var in sorted(sources.items()): + if env_var in seen: + continue + seen.add(env_var) + redacted = _is_secret_env(env_var) + summary = ( + f"Credential for the {name} provider." + if redacted + else f"Project/identifier for the {name} provider." + ) + dependency = _PROVIDER_DEPENDENCIES.get(name) + options.append( + ConfigOption( + key=f"credentials.{name}", + group="Credentials", + summary=summary, + kind=OptionKind.STR, + env_var=env_var, + redacted=redacted, + settings_field=_CREDENTIAL_SETTINGS_FIELD.get(env_var), + dependency_module=dependency[0] if dependency else None, + install_extra=dependency[1] if dependency else None, + ) + ) + return tuple(options) + + +# Options with a static (non-credential) definition, grouped by domain. The +# drift test asserts every `DEEPAGENTS_CODE_*` constant in `_env_vars` appears +# here (or in `NON_OPTION_ENV_VARS`). +_STATIC_OPTIONS: tuple[ConfigOption, ...] = ( + # --- Display / UI --------------------------------------------------- + ConfigOption( + key="display.charset", + group="Display", + summary="Glyph set for the TUI ('unicode', 'ascii', or 'auto').", + kind=OptionKind.STR, + default="auto", + env_var="UI_CHARSET_MODE", + ), + ConfigOption( + key="display.theme", + group="Display", + summary="Active CLI theme from env, terminal mapping, or saved preference.", + kind=OptionKind.THEME_DELEGATE, + env_var=_env_vars.THEME, + toml_keys=("ui", "theme"), + ), + ConfigOption( + key="display.show_header", + group="Display", + summary="Show Textual's native header bar at the top of the TUI.", + kind=OptionKind.BOOL, + default=False, + env_var=_env_vars.SHOW_HEADER, + ), + ConfigOption( + key="display.kitty_keyboard", + group="Display", + summary="Override kitty-keyboard detection (1 forces on, 0 forces off).", + kind=OptionKind.BOOL, + env_var=_env_vars.KITTY_KEYBOARD, + ), + ConfigOption( + key="display.hide_cwd", + group="Display", + summary="Hide local path displays in the footer and startup splash.", + kind=OptionKind.BOOL, + default=False, + env_var=_env_vars.HIDE_CWD, + ), + ConfigOption( + key="display.hide_git_branch", + group="Display", + summary="Hide the current git branch in the TUI footer.", + kind=OptionKind.BOOL, + default=False, + env_var=_env_vars.HIDE_GIT_BRANCH, + ), + ConfigOption( + key="display.hide_langsmith_tracing", + group="Display", + summary="Hide LangSmith tracing info in the startup splash.", + kind=OptionKind.BOOL, + default=False, + env_var=_env_vars.HIDE_LANGSMITH_TRACING, + ), + ConfigOption( + key="display.hide_splash_tips", + group="Display", + summary="Hide rotating tips in the startup splash.", + kind=OptionKind.BOOL, + default=False, + env_var=_env_vars.HIDE_SPLASH_TIPS, + ), + ConfigOption( + key="display.hide_splash_version", + group="Display", + summary="Hide version and local-install details in the splash screen.", + kind=OptionKind.BOOL, + default=False, + env_var=_env_vars.HIDE_SPLASH_VERSION, + ), + ConfigOption( + key="display.no_terminal_escape", + group="Display", + summary="Disable all terminal escape/control sequence output.", + kind=OptionKind.BOOL, + default=False, + env_var=_env_vars.NO_TERMINAL_ESCAPE, + ), + # --- Models / Tracing ---------------------------------------------- + ConfigOption( + key="models.default", + group="Models", + summary="Default model spec ('provider:model') used at launch.", + kind=OptionKind.STR, + toml_keys=("models", "default"), + cli_flag="--set-default-model", + ), + ConfigOption( + key="models.recent", + group="Models", + summary="Most recently switched-to model (managed by the app).", + kind=OptionKind.STR, + toml_keys=("models", "recent"), + ), + ConfigOption( + key="tracing.langsmith_project", + group="Models", + summary="LangSmith project name for deepagents agent traces.", + kind=OptionKind.STR, + env_var=_env_vars.LANGSMITH_PROJECT, + settings_field="deepagents_langchain_project", + ), + ConfigOption( + key="tracing.user_id", + group="Models", + summary="User identifier attached to LangSmith trace metadata.", + kind=OptionKind.STR, + env_var=_env_vars.USER_ID, + ), + # --- Tools / Features ---------------------------------------------- + ConfigOption( + key="shell.allow_list", + group="Tools", + summary=( + "Shell commands allowed without approval (comma-separated, or " + "'recommended'/'all')." + ), + kind=OptionKind.SHELL_LIST_DELEGATE, + env_var=_env_vars.SHELL_ALLOW_LIST, + cli_flag="--shell-allow-list", + settings_field="shell_allow_list", + ), + ConfigOption( + key="skills.extra_allowed_dirs", + group="Tools", + summary=( + "Extra directories added to the skill symlink containment " + "allowlist (env is colon-separated)." + ), + kind=OptionKind.SKILLS_DIRS_DELEGATE, + env_var=_env_vars.EXTRA_SKILLS_DIRS, + toml_keys=("skills", "extra_allowed_dirs"), + settings_field="extra_skills_dirs", + ), + ConfigOption( + key="models.ollama_discovery", + group="Tools", + summary="Toggle Ollama model and profile discovery probes.", + kind=OptionKind.BOOL, + default=True, + env_var=_env_vars.OLLAMA_DISCOVERY, + ), + ConfigOption( + key="events.external_socket", + group="Tools", + summary="Enable the local Unix-socket external event listener (experimental).", + kind=OptionKind.BOOL, + default=False, + env_var=_env_vars.EXTERNAL_EVENT_SOCKET, + ), + ConfigOption( + key="events.external_socket_path", + group="Tools", + summary="Override the default Unix-socket path for the event listener.", + kind=OptionKind.STR, + env_var=_env_vars.EXTERNAL_EVENT_SOCKET_PATH, + ), + # --- Interpreter (config.toml-only; defaults owned by this module) -- + ConfigOption( + key="interpreter.enable_interpreter", + group="Interpreter", + summary="Wire the QuickJS REPL middleware into the main agent (local only).", + kind=OptionKind.BOOL, + default=INTERPRETER_ENABLE_DEFAULT, + toml_keys=("interpreter", "enable_interpreter"), + cli_flag="--enable-interpreter", + settings_field="enable_interpreter", + ), + ConfigOption( + key="interpreter.timeout_seconds", + group="Interpreter", + summary="Per-call wall-clock timeout for the QuickJS REPL.", + kind=OptionKind.FLOAT, + default=INTERPRETER_TIMEOUT_SECONDS_DEFAULT, + toml_keys=("interpreter", "timeout_seconds"), + settings_field="interpreter_timeout_seconds", + ), + ConfigOption( + key="interpreter.memory_limit_mb", + group="Interpreter", + summary="QuickJS heap memory cap (MB) shared across a session.", + kind=OptionKind.INT, + default=INTERPRETER_MEMORY_LIMIT_MB_DEFAULT, + toml_keys=("interpreter", "memory_limit_mb"), + settings_field="interpreter_memory_limit_mb", + ), + ConfigOption( + key="interpreter.max_ptc_calls", + group="Interpreter", + summary="Maximum tools.* host-bridge invocations per js_eval call.", + kind=OptionKind.INT, + default=INTERPRETER_MAX_PTC_CALLS_DEFAULT, + toml_keys=("interpreter", "max_ptc_calls"), + settings_field="interpreter_max_ptc_calls", + ), + ConfigOption( + key="interpreter.max_result_chars", + group="Interpreter", + summary="Cap (chars) on js_eval result and stdout before truncation.", + kind=OptionKind.INT, + default=INTERPRETER_MAX_RESULT_CHARS_DEFAULT, + toml_keys=("interpreter", "max_result_chars"), + settings_field="interpreter_max_result_chars", + ), + ConfigOption( + key="interpreter.ptc", + group="Interpreter", + summary="Programmatic tool-calling allowlist ('safe', 'all', or names).", + kind=OptionKind.PTC_DELEGATE, + default=INTERPRETER_PTC_DEFAULT, + toml_keys=("interpreter", "ptc"), + cli_flag="--interpreter-tools", + settings_field="interpreter_ptc", + ), + ConfigOption( + key="interpreter.ptc_acknowledge_unsafe", + group="Interpreter", + summary="Acknowledge exposing every tool when interpreter.ptc='all'.", + kind=OptionKind.BOOL, + default=INTERPRETER_PTC_ACKNOWLEDGE_UNSAFE_DEFAULT, + toml_keys=("interpreter", "ptc_acknowledge_unsafe"), + settings_field="interpreter_ptc_acknowledge_unsafe", + ), + # --- Threads (config.toml-only; structured column table excepted) --- + ConfigOption( + key="threads.relative_time", + group="Threads", + summary="Show thread timestamps as relative time.", + kind=OptionKind.BOOL, + default=True, + toml_keys=("threads", "relative_time"), + cli_flag="--relative", + ), + ConfigOption( + key="threads.sort_order", + group="Threads", + summary="Default thread sort key ('updated_at' or 'created_at').", + kind=OptionKind.STR, + default="updated_at", + toml_keys=("threads", "sort_order"), + cli_flag="--sort", + ), + ConfigOption( + key="threads.columns", + group="Threads", + summary="Per-column visibility for the threads list.", + kind=OptionKind.STRUCTURED, + toml_keys=("threads", "columns"), + ), + # --- Warnings (config.toml-only) ----------------------------------- + ConfigOption( + key="warnings.suppress", + group="Warnings", + summary="Warning keys to suppress (e.g. 'ripgrep').", + kind=OptionKind.STRUCTURED, + toml_keys=("warnings", "suppress"), + ), + # --- Updates -------------------------------------------------------- + ConfigOption( + key="update.auto_update", + group="Updates", + summary="Enable automatic app updates.", + kind=OptionKind.BOOL, + default=False, + env_var=_env_vars.AUTO_UPDATE, + toml_keys=("update", "auto_update"), + cli_flag="--set-auto-update", + ), + ConfigOption( + key="update.no_update_check", + group="Updates", + summary="Disable automatic update checking.", + kind=OptionKind.BOOL_PRESENCE, + default=False, + env_var=_env_vars.NO_UPDATE_CHECK, + toml_keys=("update", "check"), + invert_toml_bool=True, + ), + # --- Debug / Development ------------------------------------------- + ConfigOption( + key="debug.enabled", + group="Debug", + summary="Enable verbose debug logging and preserve the server log.", + kind=OptionKind.BOOL, + default=False, + env_var=_env_vars.DEBUG, + ), + ConfigOption( + key="debug.file", + group="Debug", + summary="Path for the debug log file.", + kind=OptionKind.STR, + default="/tmp/deepagents_debug.log", # noqa: S108 # documents the app default, not a write target + env_var=_env_vars.DEBUG_FILE, + ), + ConfigOption( + key="debug.onboarding", + group="Debug", + summary="Force the onboarding flow to open on every interactive startup.", + kind=OptionKind.BOOL, + default=False, + env_var=_env_vars.DEBUG_ONBOARDING, + ), + ConfigOption( + key="debug.notifications", + group="Debug", + summary="Inject sample missing-dependency notifications at launch.", + kind=OptionKind.BOOL_PRESENCE, + default=False, + env_var=_env_vars.DEBUG_NOTIFICATIONS, + ), + ConfigOption( + key="debug.update", + group="Debug", + summary="Inject a sample update notification and open the update modal.", + kind=OptionKind.BOOL_PRESENCE, + default=False, + env_var=_env_vars.DEBUG_UPDATE, + ), + ConfigOption( + key="debug.mcp_project_trust", + group="Debug", + summary="Force the project MCP approval prompt for manual UI testing.", + kind=OptionKind.BOOL, + default=False, + env_var=_env_vars.DEBUG_MCP_PROJECT_TRUST, + ), + ConfigOption( + key="debug.override_startup_subheader", + group="Debug", + summary="Override the startup splash subheader text.", + kind=OptionKind.STR, + env_var=_env_vars.DANGEROUSLY_OVERRIDE_STARTUP_SUBHEADER, + ), +) + + +# Env-var constants in `_env_vars` that are not standalone options: prefixes +# and aggregates the manifest does not enumerate, plus internal/transient +# signaling flags the app sets for itself rather than reading as user config. +NON_OPTION_ENV_VARS: frozenset[str] = frozenset( + { + _env_vars.SERVER_ENV_PREFIX, + # Set then popped during the self-update restart handshake (main.py); + # never user-configured. + _env_vars.RESTARTED_AFTER_UPDATE, + } +) +"""`_env_vars` constants intentionally excluded from the option catalog.""" + + +@lru_cache(maxsize=1) +def get_config_options() -> tuple[ConfigOption, ...]: + """Return every option, credentials-first then by domain group. + + Cached: provider credentials are generated once from `PROVIDER_API_KEY_ENV` + on first call (which lazily imports `model_config`). The cache assumes that + registry is an immutable module constant; a test that monkeypatches it must + call `get_config_options.cache_clear()` (and `_options_by_key.cache_clear()`). + """ + return _credential_options() + _STATIC_OPTIONS + + +def get_option(key: str) -> ConfigOption | None: + """Return the manifest entry for `key`, or `None` when unknown.""" + return _options_by_key().get(key) + + +def option_keys() -> tuple[str, ...]: + """Return every manifest key in definition order.""" + return tuple(opt.key for opt in get_config_options()) + + +@lru_cache(maxsize=1) +def _options_by_key() -> dict[str, ConfigOption]: + return {opt.key: opt for opt in get_config_options()} + + +def iter_groups(options: Iterable[ConfigOption]) -> list[str]: + """Return group names from `options` in first-seen order.""" + groups: list[str] = [] + for opt in options: + if opt.group not in groups: + groups.append(opt.group) + return groups diff --git a/libs/code/deepagents_code/main.py b/libs/code/deepagents_code/main.py index 9aadcff2ff..a4f4b87f46 100644 --- a/libs/code/deepagents_code/main.py +++ b/libs/code/deepagents_code/main.py @@ -598,6 +598,7 @@ async def _preload_session_mcp_server_info( "skills": ("skills_command", "show_skills_help"), "threads": ("threads_command", "show_threads_help"), "mcp": ("mcp_command", "show_mcp_help"), + "config": ("config_command", "show_config_help"), } """Maps top-level command names to their startup-fast-path help dispatch. @@ -621,7 +622,7 @@ def _show_bare_command_group_help(args: argparse.Namespace) -> bool: Short-circuits before `console`/`settings` are imported so help-only invocations stay snappy. Mirrors the dispatch in `cli_main` for the - `help`, `agents`, `skills`, `threads`, and `mcp` commands when no + `help`, `agents`, `skills`, `threads`, `mcp`, and `config` commands when no subcommand was given. Args: @@ -659,6 +660,7 @@ def parse_args() -> argparse.Namespace: Parsed arguments namespace. """ from deepagents_code._constants import DEFAULT_AGENT_NAME + from deepagents_code.config_commands import setup_config_parser from deepagents_code.mcp_commands import setup_mcp_parsers from deepagents_code.output import add_json_output_arg from deepagents_code.skills import setup_skills_parser @@ -789,6 +791,12 @@ def help_parent(help_fn: Callable[[], None]) -> list[argparse.ArgumentParser]: make_help_action=_make_help_action, ) + setup_config_parser( + subparsers, + make_help_action=_make_help_action, + add_output_args=add_json_output_arg, + ) + threads_parser = subparsers.add_parser( "threads", help="Manage conversation threads", @@ -2078,6 +2086,11 @@ 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)) + # 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 diff --git a/libs/code/deepagents_code/ui.py b/libs/code/deepagents_code/ui.py index eee70d560b..1be50399c4 100644 --- a/libs/code/deepagents_code/ui.py +++ b/libs/code/deepagents_code/ui.py @@ -106,6 +106,7 @@ def show_help() -> None: " dcode threads Manage conversation threads" ) console.print(" dcode mcp Manage MCP servers") + console.print(" dcode config Inspect configuration") console.print( " dcode update Check for and install updates" ) @@ -536,6 +537,38 @@ def show_mcp_config_help() -> None: console.print() +def show_config_help() -> None: + """Show help information for the `config` subcommand. + + Invoked via the `-h` argparse action, the startup fast-path, or + `run_config_command` when no config subcommand is given. Kept import-light + so it stays on the startup fast path. + """ + console.print() + console.print("[bold]Usage:[/bold]", style=theme.PRIMARY) + console.print(" dcode config [options]") + console.print() + console.print("[bold]Commands:[/bold]", style=theme.PRIMARY) + console.print(" show Show effective values and their source") + console.print(" list|ls List all available options") + console.print(" get Show one option's value and source") + console.print(" path Show config file locations") + console.print() + _print_option_section() + console.print() + console.print( + " Credentials are reported as set/not set only; values are never printed.", + style=theme.MUTED, + ) + console.print() + console.print("[bold]Examples:[/bold]", style=theme.PRIMARY) + console.print(" dcode config show") + console.print(" dcode config list --json") + console.print(" dcode config get interpreter.memory_limit_mb") + console.print(" dcode config path") + console.print() + + def show_threads_help() -> None: """Show help information for the `threads` subcommand. diff --git a/libs/code/tests/unit_tests/test_args.py b/libs/code/tests/unit_tests/test_args.py index 64540cc45a..7d82960b8c 100644 --- a/libs/code/tests/unit_tests/test_args.py +++ b/libs/code/tests/unit_tests/test_args.py @@ -460,6 +460,46 @@ def test_no_mcp_and_mcp_config_mutual_exclusion(self) -> None: assert exc_info.value.code == 2 +class TestConfigCommandDispatch: + """Tests for `cli_main()` dispatch of `dcode config` subcommands.""" + + def test_config_command_exits_before_stdin_pipe(self) -> None: + """`dcode config` is headless and must not read stdin.""" + from deepagents_code.main import cli_main + + with ( + patch.object( + sys, + "argv", + [ + "deepagents", + "config", + "get", + "interpreter.memory_limit_mb", + "--json", + ], + ), + patch("deepagents_code.main.check_cli_dependencies"), + patch( + "deepagents_code.main.apply_stdin_pipe", + side_effect=AssertionError("config command read stdin"), + ) as stdin_mock, + patch( + "deepagents_code.config_commands.run_config_command", + return_value=0, + ) as config_mock, + pytest.raises(SystemExit) as exc_info, + ): + cli_main() + + assert exc_info.value.code == 0 + stdin_mock.assert_not_called() + config_mock.assert_called_once() + args = config_mock.call_args.args[0] + assert args.command == "config" + assert args.config_command == "get" + + class TestMcpCommandDispatch: """Tests for `cli_main()` dispatch of `dcode mcp` subcommands.""" diff --git a/libs/code/tests/unit_tests/test_config_manifest.py b/libs/code/tests/unit_tests/test_config_manifest.py new file mode 100644 index 0000000000..3807ebbf59 --- /dev/null +++ b/libs/code/tests/unit_tests/test_config_manifest.py @@ -0,0 +1,999 @@ +"""Drift, resolution, and behavior tests for the configuration manifest. + +These guard the contract that the manifest is the single source of truth for +the scalar config surface, that its resolver matches what the runtime reads, +and that secret-flagged options are never rendered by value. +""" + +from __future__ import annotations + +import argparse + +import pytest + +from deepagents_code import _env_vars +from deepagents_code.config_commands import ( + _display_value, + _missing_extra_hint, + _resolve, + _run_get, + _source_label, + run_config_command, +) +from deepagents_code.config_manifest import ( + NON_OPTION_ENV_VARS, + ConfigOption, + OptionKind, + get_config_options, + get_option, + option_keys, + resolve_interpreter_kwargs, + resolve_scalar, +) +from deepagents_code.model_config import PROVIDER_API_KEY_ENV + + +def _declared_deepagents_env_vars() -> set[str]: + """Every `DEEPAGENTS_CODE_*` constant declared in `_env_vars`.""" + return { + value + for name, value in vars(_env_vars).items() + if not name.startswith("_") + and isinstance(value, str) + and value.startswith("DEEPAGENTS_CODE_") + } + + +# --- Drift / coverage ------------------------------------------------------- + + +def test_manifest_covers_every_deepagents_env_var() -> None: + """Every `DEEPAGENTS_CODE_*` env var must have a manifest entry.""" + manifest_env_vars = {opt.env_var for opt in get_config_options() if opt.env_var} + declared = _declared_deepagents_env_vars() - NON_OPTION_ENV_VARS + missing = declared - manifest_env_vars + assert not missing, ( + f"`DEEPAGENTS_CODE_*` env vars without a manifest entry: {sorted(missing)}. " + "Add a ConfigOption in config_manifest.py or list it in NON_OPTION_ENV_VARS." + ) + + +def test_manifest_covers_every_provider_credential() -> None: + """Every provider in `PROVIDER_API_KEY_ENV` must have a credential option.""" + manifest_env_vars = {opt.env_var for opt in get_config_options() if opt.env_var} + missing = set(PROVIDER_API_KEY_ENV.values()) - manifest_env_vars + assert not missing, ( + f"Provider credential env vars without a manifest entry: {sorted(missing)}." + ) + + +def test_option_keys_unique() -> None: + """Manifest keys must be unique so `config get` lookups are unambiguous.""" + keys = option_keys() + assert len(keys) == len(set(keys)) + + +# --- Secrets ---------------------------------------------------------------- + + +def test_api_key_credentials_are_secret() -> None: + """Credential options backed by key/token env vars must be secret-flagged.""" + for opt in get_config_options(): + if opt.group != "Credentials" or not opt.env_var: + continue + looks_secret = any( + marker in opt.env_var for marker in ("KEY", "TOKEN", "APIKEY") + ) + assert opt.redacted is looks_secret, ( + f"{opt.key} redacted={opt.redacted} but env_var {opt.env_var!r} " + f"implies redacted={looks_secret}" + ) + + +def test_google_cloud_project_is_not_secret() -> None: + """The Vertex project identifier is not secret material and shows its value.""" + opt = get_option("credentials.google_vertexai") + assert opt is not None + assert opt.env_var == "GOOGLE_CLOUD_PROJECT" + assert opt.redacted is False + + +def test_display_value_redacts_secrets() -> None: + """A secret option never renders its raw value, only configured state.""" + option = ConfigOption( + key="x", + group="Credentials", + summary="", + kind=OptionKind.STR, + redacted=True, + ) + assert _display_value(option, is_set=True, value="sk-supersecret") == "configured" + assert _display_value(option, is_set=False, value=None) == "not configured" + + +def test_display_value_uses_credential_language_for_non_secret_unset() -> None: + """Non-secret credential identifiers still use configured-state language.""" + option = ConfigOption( + key="credentials.example", + group="Credentials", + summary="", + kind=OptionKind.STR, + redacted=False, + ) + assert _display_value(option, is_set=False, value=None) == "not configured" + + +def test_missing_extra_hint_checks_provider_dependency(monkeypatch) -> None: + """Credential rows can show when their provider integration is unavailable.""" + option = ConfigOption( + key="credentials.example", + group="Credentials", + summary="", + kind=OptionKind.STR, + redacted=True, + dependency_module="langchain_missing_provider", + install_extra="missing-provider", + ) + monkeypatch.setattr( + "deepagents_code.config_commands.importlib.util.find_spec", + lambda name: None if name == "langchain_missing_provider" else object(), + ) + assert _missing_extra_hint(option) is True + assert ( + _display_value(option, is_set=True, value="sk-secret") + == "configured, unavailable" + ) + assert _source_label("default") == "default" + + +def test_run_get_json_omits_secret_value(monkeypatch, capsys) -> None: + """JSON output for a secret option reports presence but never the value.""" + import json + + monkeypatch.setenv("DEEPAGENTS_CODE_ANTHROPIC_API_KEY", "sk-secret") + assert _run_get("credentials.anthropic", "json") == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["data"]["set"] is True + assert payload["data"]["value"] is None + + +def test_charset_auto_display_value_includes_effective_glyph_mode() -> None: + """The charset auto value says which glyph mode is actually being used.""" + option = get_option("display.charset") + assert option is not None + value = _display_value(option, is_set=False, value="auto") + assert value in { + "auto (using Unicode glyphs)", + "auto (using ASCII glyphs)", + } + assert _source_label("default") == "default" + + +# --- Single-source defaults ------------------------------------------------- + + +def test_interpreter_defaults_match_settings() -> None: + """Manifest interpreter defaults are the same objects `Settings` uses. + + This is what makes the manifest the single source of truth: the dataclass + default and the manifest default cannot diverge because they are one value. + """ + from deepagents_code.config import Settings + + settings = Settings.from_environment() + for opt in get_config_options(): + if opt.group != "Interpreter" or opt.settings_field is None: + continue + assert getattr(settings, opt.settings_field) == opt.default + + +def test_every_settings_field_names_a_real_settings_attribute() -> None: + """Catch a typo'd `settings_field` on any option, not just interpreter ones. + + `settings_field` is a free-form string with no compile-time link to the + `Settings` dataclass, so a misspelling would only surface at runtime + `getattr`. This locks the mapping across the whole catalog. + """ + from dataclasses import fields + + from deepagents_code.config import Settings + + valid = {f.name for f in fields(Settings)} + bad = { + opt.key: opt.settings_field + for opt in get_config_options() + if opt.settings_field is not None and opt.settings_field not in valid + } + assert not bad, f"options reference unknown Settings fields: {bad}" + + +# --- Resolution ------------------------------------------------------------- + + +def test_resolve_prefers_prefixed_env(monkeypatch) -> None: + """A `DEEPAGENTS_CODE_`-prefixed env var wins over the canonical name.""" + opt = get_option("credentials.openai") + assert opt is not None + monkeypatch.setenv("OPENAI_API_KEY", "canonical") + monkeypatch.setenv("DEEPAGENTS_CODE_OPENAI_API_KEY", "prefixed") + value, source = resolve_scalar(opt, toml_data={}) + assert source == "env (DEEPAGENTS_CODE_OPENAI_API_KEY)" + assert value == "prefixed" + + +def test_resolve_empty_env_is_unset_matching_resolve_env_var(monkeypatch) -> None: + """An empty (prefixed) env var is unset for `config show`, as the app sees it. + + The runtime `resolve_env_var` returns `None` for an empty prefixed var (and + a prefixed empty suppresses the canonical). `resolve_scalar` must agree, or + `config show` would report a credential as "set" that the app treats as + unset — the exact drift this feature exists to prevent. + """ + from deepagents_code.model_config import resolve_env_var + + opt = get_option("credentials.openai") + assert opt is not None + monkeypatch.setenv("OPENAI_API_KEY", "canonical") + monkeypatch.setenv("DEEPAGENTS_CODE_OPENAI_API_KEY", "") + + value, source = resolve_scalar(opt, toml_data={}) + assert resolve_env_var("OPENAI_API_KEY") is None + assert source == "default" + assert value is None + + +def test_run_show_json_redacts_every_secret(monkeypatch, capsys) -> None: + """The `config show` aggregate (separate path from `get`) never leaks a secret.""" + import json + + monkeypatch.setenv("DEEPAGENTS_CODE_ANTHROPIC_API_KEY", "sk-secret") + args = argparse.Namespace(config_command="show", output_format="json") + assert run_config_command(args) == 0 + rows = json.loads(capsys.readouterr().out)["data"] + assert any(r["key"] == "credentials.anthropic" and r["set"] for r in rows) + assert all(r["value"] is None for r in rows if r["redacted"]) + + +def test_resolve_int_falls_back_to_toml_then_default() -> None: + """config.toml is consulted when env is unset; default is the last resort.""" + opt = get_option("interpreter.memory_limit_mb") + assert opt is not None + assert resolve_scalar(opt, toml_data={"interpreter": {"memory_limit_mb": 128}}) == ( + 128, + "config.toml", + ) + assert resolve_scalar(opt, toml_data={}) == (64, "default") + + +def test_resolve_malformed_toml_int_falls_back_with_warning(caplog) -> None: + """A bad TOML scalar is logged and falls back to the default, never raising.""" + import logging + + opt = get_option("interpreter.memory_limit_mb") + assert opt is not None + with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"): + value, source = resolve_scalar( + opt, toml_data={"interpreter": {"memory_limit_mb": "oops"}} + ) + assert (value, source) == (64, "default") + assert any("memory_limit_mb" in r.getMessage() for r in caplog.records) + + +def test_resolve_bool_env_uses_truthy_semantics(monkeypatch) -> None: + """BOOL options honor is_env_truthy semantics ('0' is falsy, not 'set').""" + opt = get_option("display.hide_cwd") + assert opt is not None + monkeypatch.setenv(opt.env_var, "1") + assert resolve_scalar(opt, toml_data={})[0] is True + monkeypatch.setenv(opt.env_var, "0") + assert resolve_scalar(opt, toml_data={})[0] is False + + +def test_thread_relative_time_default_matches_runtime_loader() -> None: + """Fresh thread config shows relative timestamps by default.""" + opt = get_option("threads.relative_time") + assert opt is not None + assert resolve_scalar(opt, toml_data={}) == (True, "default") + + +def test_auto_update_resolves_persisted_config() -> None: + """`set_auto_update()` writes the TOML path surfaced by the manifest.""" + opt = get_option("update.auto_update") + assert opt is not None + assert resolve_scalar(opt, toml_data={"update": {"auto_update": True}}) == ( + True, + "config.toml", + ) + + +def test_no_update_check_env_uses_presence_semantics(monkeypatch) -> None: + """Any non-empty no-update-check env var disables checks, including '0'.""" + opt = get_option("update.no_update_check") + assert opt is not None + assert opt.kind is OptionKind.BOOL_PRESENCE + monkeypatch.setenv(_env_vars.NO_UPDATE_CHECK, "0") + assert resolve_scalar(opt, toml_data={}) == ( + True, + f"env ({_env_vars.NO_UPDATE_CHECK})", + ) + + +def test_no_update_check_resolves_inverted_persisted_check() -> None: + """`[update].check = false` means the effective no-check flag is enabled.""" + opt = get_option("update.no_update_check") + assert opt is not None + assert resolve_scalar(opt, toml_data={"update": {"check": False}}) == ( + True, + "config.toml", + ) + assert resolve_scalar(opt, toml_data={"update": {"check": True}}) == ( + False, + "config.toml", + ) + + +def test_resolve_ptc_delegates_to_parser() -> None: + """The PTC kind routes through the dedicated allowlist parser.""" + opt = get_option("interpreter.ptc") + assert opt is not None + assert resolve_scalar(opt, toml_data={"interpreter": {"ptc": "safe"}}) == ( + "safe", + "config.toml", + ) + # Invalid PTC value is rejected by the parser and falls back to default. + value, source = resolve_scalar(opt, toml_data={"interpreter": {"ptc": "bogus"}}) + assert (value, source) == (opt.default, "default") + + +def test_resolve_interpreter_kwargs_maps_settings_fields() -> None: + """The interpreter resolver returns Settings-constructor kwargs.""" + kwargs = resolve_interpreter_kwargs( + toml_data={"interpreter": {"memory_limit_mb": 256, "enable_interpreter": True}} + ) + assert kwargs["interpreter_memory_limit_mb"] == 256 + assert kwargs["enable_interpreter"] is True + # Unspecified fields resolve to their manifest defaults. + assert kwargs["interpreter_timeout_seconds"] == pytest.approx(5.0) + + +def test_resolve_theme_uses_terminal_mapping_before_saved_theme(monkeypatch) -> None: + """Theme resolution mirrors startup: terminal mapping wins over `[ui].theme`.""" + opt = get_option("display.theme") + assert opt is not None + monkeypatch.delenv("DEEPAGENTS_CODE_THEME", raising=False) + monkeypatch.setenv("TERM_PROGRAM", "vscode") + + value, source = resolve_scalar( + opt, + toml_data={ + "ui": { + "theme": "atom-one-light", + "terminal_themes": {"vscode": "ansi-dark"}, + } + }, + ) + + assert value == "ansi-dark" + assert source == "config.toml [ui.terminal_themes.vscode]" + + +def test_resolve_theme_uses_saved_theme_without_terminal_match(monkeypatch) -> None: + """A saved `[ui].theme` is reported when no terminal mapping applies.""" + opt = get_option("display.theme") + assert opt is not None + monkeypatch.delenv("DEEPAGENTS_CODE_THEME", raising=False) + monkeypatch.setenv("TERM_PROGRAM", "unknown-terminal") + + value, source = resolve_scalar( + opt, + toml_data={ + "ui": { + "theme": "atom-one-light", + "terminal_themes": {"vscode": "ansi-dark"}, + } + }, + ) + + assert value == "atom-one-light" + assert source == "config.toml [ui.theme]" + + +def test_resolve_theme_env_wins_over_config(monkeypatch) -> None: + """The explicit theme env var wins over saved config, matching startup.""" + opt = get_option("display.theme") + assert opt is not None + monkeypatch.setenv("DEEPAGENTS_CODE_THEME", "ansi-dark") + monkeypatch.setenv("TERM_PROGRAM", "vscode") + + value, source = resolve_scalar( + opt, + toml_data={ + "ui": { + "theme": "atom-one-light", + "terminal_themes": {"vscode": "langchain"}, + } + }, + ) + + assert value == "ansi-dark" + assert source == "env (DEEPAGENTS_CODE_THEME)" + + +# --- Misc ------------------------------------------------------------------- + + +def test_get_option_unknown_returns_none() -> None: + assert get_option("does.not.exist") is None + + +def test_run_get_unknown_key_returns_error_code(capsys) -> None: + args = argparse.Namespace(config_command="get", key="nope", output_format="text") + assert run_config_command(args) == 1 + assert "Unknown config option" in capsys.readouterr().err + + +def test_config_registered_in_help_specs() -> None: + """The `config` group must be wired for the startup fast-path help dispatch.""" + from deepagents_code import ui + from deepagents_code.main import _HELP_SPECS + + assert _HELP_SPECS.get("config") == ("config_command", "show_config_help") + assert callable(ui.show_config_help) + + +# --- ConfigOption validation ------------------------------------------------ + + +def test_config_option_rejects_type_mismatched_default() -> None: + """A default whose type contradicts `kind` fails at construction.""" + import pytest + + with pytest.raises(TypeError, match="not valid for kind int"): + ConfigOption(key="x", group="g", summary="s", kind=OptionKind.INT, default="5") + + +def test_config_option_rejects_bool_default_for_int() -> None: + """`bool` is an `int` subclass but must not pass as an INT/FLOAT default.""" + import pytest + + with pytest.raises(TypeError, match="not valid for kind int"): + ConfigOption(key="x", group="g", summary="s", kind=OptionKind.INT, default=True) + + +def test_config_option_rejects_mutable_default() -> None: + """A mutable default would be shared by reference through the lru_cache.""" + import pytest + + with pytest.raises(TypeError, match="mutable default"): + ConfigOption( + key="x", group="g", summary="s", kind=OptionKind.STR, default=["a"] + ) + + +def test_config_option_rejects_default_on_structured() -> None: + """STRUCTURED options are display-only pass-throughs and take no default.""" + import pytest + + with pytest.raises(TypeError, match="must not declare a default"): + ConfigOption( + key="x", group="g", summary="s", kind=OptionKind.STRUCTURED, default="x" + ) + + +def test_config_option_rejects_inverted_non_bool_toml() -> None: + """Only boolean TOML options can use inverted config-file semantics.""" + import pytest + + with pytest.raises(TypeError, match="requires a boolean option kind"): + ConfigOption( + key="x", + group="g", + summary="s", + kind=OptionKind.STR, + default="x", + toml_keys=("section", "key"), + invert_toml_bool=True, + ) + + +# --- Coercion matrix -------------------------------------------------------- + + +def test_resolve_bool_presence_enables_on_any_value(monkeypatch) -> None: + """BOOL_PRESENCE treats any non-empty value as set, including '0'. + + This is the one branch whose semantics differ from BOOL, where '0' is + falsy; here `bool(raw)` makes a literal '0' enable the flag. + """ + opt = get_option("debug.notifications") + assert opt is not None + assert opt.kind is OptionKind.BOOL_PRESENCE + monkeypatch.setenv(opt.env_var, "0") + assert resolve_scalar(opt, toml_data={})[0] is True + monkeypatch.setenv(opt.env_var, "") + # An empty value is unset (see resolve_scalar), so it falls back to default. + assert resolve_scalar(opt, toml_data={}) == (False, "default") + + +def test_resolve_malformed_int_env_falls_back_with_warning(monkeypatch, caplog) -> None: + """A non-numeric env value for an INT option logs and falls back. + + Interpreter options are TOML-only, so the int env-coercion branch is + exercised through a synthetic option with an env var. + """ + import logging + + int_opt = ConfigOption( + key="t.int", + group="g", + summary="s", + kind=OptionKind.INT, + default=7, + env_var="DEEPAGENTS_CODE_TEST_INT", + ) + monkeypatch.setenv("DEEPAGENTS_CODE_TEST_INT", "not-a-number") + with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"): + value, source = resolve_scalar(int_opt, toml_data={}) + assert (value, source) == (7, "default") + assert any("TEST_INT" in r.getMessage() for r in caplog.records) + + +def test_resolve_toml_int_rejects_bool() -> None: + """A TOML boolean must not coerce to an INT (bool is an int subclass).""" + opt = get_option("interpreter.memory_limit_mb") + assert opt is not None + assert resolve_scalar( + opt, toml_data={"interpreter": {"memory_limit_mb": True}} + ) == (64, "default") + + +def test_resolve_toml_float_rejects_bool() -> None: + """A TOML boolean must not coerce to a FLOAT.""" + opt = get_option("interpreter.timeout_seconds") + assert opt is not None + assert resolve_scalar( + opt, toml_data={"interpreter": {"timeout_seconds": True}} + ) == (5.0, "default") + + +def test_resolve_structured_passes_value_through() -> None: + """STRUCTURED options return the raw table verbatim for display.""" + opt = get_option("threads.columns") + assert opt is not None + assert opt.kind is OptionKind.STRUCTURED + table = {"created": True, "updated": False} + assert resolve_scalar(opt, toml_data={"threads": {"columns": table}}) == ( + table, + "config.toml", + ) + + +def test_resolve_malformed_skills_dir_env_falls_back(monkeypatch, caplog) -> None: + """An unresolvable skills-dir env path logs and falls back, never raising.""" + import logging + + opt = get_option("skills.extra_allowed_dirs") + assert opt is not None + # `~nobodyuser_xyz` cannot resolve to a home directory; `expanduser` raises + # RuntimeError, which the resolver must catch. + monkeypatch.setenv(opt.env_var, "~nobodyuser_xyz/skills") + with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"): + value, source = resolve_scalar(opt, toml_data={}) + assert (value, source) == (None, "default") + assert any("could not resolve" in r.getMessage() for r in caplog.records) + + +def test_resolve_malformed_skills_dir_toml_falls_back(caplog) -> None: + """An unresolvable skills-dir in config.toml logs and falls back.""" + import logging + + opt = get_option("skills.extra_allowed_dirs") + assert opt is not None + with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"): + value, source = resolve_scalar( + opt, + toml_data={"skills": {"extra_allowed_dirs": ["~nobodyuser_xyz/skills"]}}, + ) + assert (value, source) == (None, "default") + assert any("could not resolve" in r.getMessage() for r in caplog.records) + + +# --- load_config_toml ------------------------------------------------------- + + +def test_load_config_toml_absent_returns_empty(monkeypatch, tmp_path) -> None: + """An absent config file is not an error: returns {} silently.""" + from deepagents_code import config_manifest, model_config + + monkeypatch.setattr(model_config, "DEFAULT_CONFIG_PATH", tmp_path / "missing.toml") + assert config_manifest.load_config_toml() == {} + + +def test_load_config_toml_corrupt_returns_empty_with_warning( + monkeypatch, tmp_path, caplog +) -> None: + """A corrupt config file logs a warning and falls back to {}.""" + import logging + + from deepagents_code import config_manifest, model_config + + bad = tmp_path / "config.toml" + bad.write_text("this is = not valid = toml ][") + monkeypatch.setattr(model_config, "DEFAULT_CONFIG_PATH", bad) + with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"): + assert config_manifest.load_config_toml() == {} + assert any("Could not read config" in r.getMessage() for r in caplog.records) + + +def test_load_config_toml_valid_parses(monkeypatch, tmp_path) -> None: + """A valid config file is parsed into a mapping.""" + from deepagents_code import config_manifest, model_config + + good = tmp_path / "config.toml" + good.write_text("[interpreter]\nmemory_limit_mb = 128\n") + monkeypatch.setattr(model_config, "DEFAULT_CONFIG_PATH", good) + assert config_manifest.load_config_toml() == { + "interpreter": {"memory_limit_mb": 128} + } + + +# --- Display rendering ------------------------------------------------------ + + +def test_display_value_unset_renders_placeholder() -> None: + """A non-secret option with no value renders the unset placeholder.""" + opt = ConfigOption(key="x", group="g", summary="s", kind=OptionKind.STR) + assert _display_value(opt, is_set=False, value=None) == "(unset)" + + +def test_display_value_truncates_long_values() -> None: + """A long value is truncated to 60 chars with a trailing ellipsis.""" + opt = ConfigOption(key="x", group="g", summary="s", kind=OptionKind.STR) + rendered = _display_value(opt, is_set=True, value="a" * 100) + assert len(rendered) == 60 + assert rendered.endswith("\N{HORIZONTAL ELLIPSIS}") + + +def test_config_show_text_survives_markup_in_value(monkeypatch) -> None: + """A value containing Rich close-tag markup must not crash text rendering.""" + monkeypatch.setenv( + _env_vars.EXTERNAL_EVENT_SOCKET_PATH, + "/tmp/sock[/]oops", + ) + args = argparse.Namespace(config_command="show", output_format="text") + assert run_config_command(args) == 0 + + +# --- Command smoke (text paths) --------------------------------------------- + + +def test_run_show_text_returns_zero() -> None: + """The default (text) `config show` rendering path runs without error.""" + args = argparse.Namespace(config_command="show", output_format="text") + assert run_config_command(args) == 0 + + +def test_run_list_text_returns_zero() -> None: + """The default (text) `config list` rendering path runs without error.""" + args = argparse.Namespace(config_command="list", output_format="text") + assert run_config_command(args) == 0 + + +def test_run_get_text_returns_zero() -> None: + """The default (text) `config get` rendering path runs without error.""" + args = argparse.Namespace( + config_command="get", key="interpreter.memory_limit_mb", output_format="text" + ) + assert run_config_command(args) == 0 + + +def test_run_path_text_returns_zero() -> None: + """The `config path` rendering path runs without error.""" + args = argparse.Namespace(config_command="path", output_format="text") + assert run_config_command(args) == 0 + + +# --- BOOL env coercion ------------------------------------------------------ + + +def test_resolve_bool_unrecognized_env_falls_back_with_warning( + monkeypatch, caplog +) -> None: + """An unrecognized boolean env token logs and falls through, not source=env. + + `is_env_truthy` would silently return the default for `maybe`, but the + resolver must not then credit the env var with that value: doing so would + make `config show` report `source=env` for a variable the runtime ignored. + """ + import logging + + opt = get_option("display.hide_cwd") + assert opt is not None + monkeypatch.setenv(opt.env_var, "maybe") + with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"): + value, source = resolve_scalar(opt, toml_data={}) + assert (value, source) == (False, "default") + assert any("expected bool" in r.getMessage() for r in caplog.records) + + +# --- FLOAT / shell-list env coercion --------------------------------------- + + +def test_resolve_float_env_coerces_and_falls_back(monkeypatch, caplog) -> None: + """The FLOAT env branch coerces a number and logs+falls back on garbage. + + Interpreter floats are TOML-only, so — like the INT branch — a synthetic + env-backed option exercises both arms of `_coerce_env`'s FLOAT path. + """ + import logging + + float_opt = ConfigOption( + key="t.float", + group="g", + summary="s", + kind=OptionKind.FLOAT, + default=1.5, + env_var="DEEPAGENTS_CODE_TEST_FLOAT", + ) + monkeypatch.setenv("DEEPAGENTS_CODE_TEST_FLOAT", "2.5") + value, source = resolve_scalar(float_opt, toml_data={}) + assert value == pytest.approx(2.5) + assert source == "env (DEEPAGENTS_CODE_TEST_FLOAT)" + + monkeypatch.setenv("DEEPAGENTS_CODE_TEST_FLOAT", "not-a-number") + with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"): + value, source = resolve_scalar(float_opt, toml_data={}) + assert (value, source) == (1.5, "default") + assert any("TEST_FLOAT" in r.getMessage() for r in caplog.records) + + +def test_resolve_shell_list_env_happy_and_invalid(monkeypatch, caplog) -> None: + """The shell-list env delegate parses a valid list and rejects bad input.""" + import logging + + opt = get_option("shell.allow_list") + assert opt is not None + monkeypatch.setenv(opt.env_var, "git status,ls") + value, source = resolve_scalar(opt, toml_data={}) + assert source == f"env ({opt.env_var})" + assert isinstance(value, list) + assert "ls" in value + + # `'all'` cannot be combined with other commands; the parser raises and the + # resolver logs + falls back rather than crashing. + monkeypatch.setenv(opt.env_var, "all,ls") + with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"): + value, source = resolve_scalar(opt, toml_data={}) + assert source == "default" + assert any("Ignoring invalid" in r.getMessage() for r in caplog.records) + + +def test_coerce_env_delegate_returns_invalid_not_raw(caplog) -> None: + """A delegate kind reaching `_coerce_env` returns `_INVALID`, never raw. + + PTC/STRUCTURED options declare no env var, so this branch is unreachable in + the live manifest. The guard exists so that if one ever gains an env var, + an uncoerced raw string cannot leak into a typed `Settings` field. + """ + import logging + + from deepagents_code.config_manifest import _INVALID, _coerce_env + + opt = get_option("interpreter.ptc") + assert opt is not None + with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"): + result = _coerce_env(opt, "safe", "DEEPAGENTS_CODE_FAKE") + assert result is _INVALID + assert any("not env-backed" in r.getMessage() for r in caplog.records) + + +# --- TOML coercion (success + mismatch) ------------------------------------ + + +def test_resolve_toml_str_success_and_type_mismatch(caplog) -> None: + """A STR option reads a string from TOML and rejects a wrong-typed value.""" + import logging + + opt = get_option("threads.sort_order") + assert opt is not None + assert resolve_scalar(opt, toml_data={"threads": {"sort_order": "created_at"}}) == ( + "created_at", + "config.toml", + ) + + with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"): + value, source = resolve_scalar(opt, toml_data={"threads": {"sort_order": 123}}) + assert (value, source) == ("updated_at", "default") + assert any("sort_order" in r.getMessage() for r in caplog.records) + + +def test_resolve_toml_float_success_non_bool() -> None: + """A FLOAT option reads a real number from TOML and coerces an int to float.""" + opt = get_option("interpreter.timeout_seconds") + assert opt is not None + assert resolve_scalar(opt, toml_data={"interpreter": {"timeout_seconds": 2.5}}) == ( + 2.5, + "config.toml", + ) + # A bare TOML integer is accepted and coerced to float. + assert resolve_scalar(opt, toml_data={"interpreter": {"timeout_seconds": 3}}) == ( + 3.0, + "config.toml", + ) + + +# --- Theme resolution warnings ---------------------------------------------- + + +def test_resolve_theme_unknown_env_warns(monkeypatch, caplog) -> None: + """An unknown theme in the env var warns and falls back to the default.""" + import logging + + from deepagents_code import theme + + opt = get_option("display.theme") + assert opt is not None + monkeypatch.setenv("DEEPAGENTS_CODE_THEME", "no-such-theme") + with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"): + value, source = resolve_scalar(opt, toml_data={}) + assert (value, source) == (theme.DEFAULT_THEME, "default") + assert any("Unknown theme" in r.getMessage() for r in caplog.records) + + +def test_resolve_theme_non_table_ui_warns(monkeypatch, caplog) -> None: + """A non-table `[ui]` value warns and falls back to the default theme.""" + import logging + + from deepagents_code import theme + + opt = get_option("display.theme") + assert opt is not None + monkeypatch.delenv("DEEPAGENTS_CODE_THEME", raising=False) + with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"): + value, source = resolve_scalar(opt, toml_data={"ui": "oops"}) + assert (value, source) == (theme.DEFAULT_THEME, "default") + assert any("should be a table" in r.getMessage() for r in caplog.records) + + +def test_resolve_theme_unknown_saved_warns(monkeypatch, caplog) -> None: + """An unknown saved `[ui].theme` warns and falls back to the default.""" + import logging + + from deepagents_code import theme + + opt = get_option("display.theme") + assert opt is not None + monkeypatch.delenv("DEEPAGENTS_CODE_THEME", raising=False) + monkeypatch.setenv("TERM_PROGRAM", "no-mapping-terminal") + with caplog.at_level(logging.WARNING, logger="deepagents_code.config_manifest"): + value, source = resolve_scalar( + opt, toml_data={"ui": {"theme": "no-such-theme"}} + ) + assert (value, source) == (theme.DEFAULT_THEME, "default") + assert any("Unknown theme" in r.getMessage() for r in caplog.records) + + +# --- config path: existence + OSError --------------------------------------- + + +def test_config_paths_logs_and_reports_missing_on_oserror(monkeypatch, caplog) -> None: + """An `OSError` from `path.exists()` is logged and reported as missing.""" + import logging + from pathlib import Path + + from deepagents_code import model_config + from deepagents_code.config_commands import _config_paths + + target = model_config.DEFAULT_CONFIG_PATH + real_exists = Path.exists + + def fake_exists(self, *args: object, **kwargs: object) -> bool: + if self == target: + msg = "boom" + raise OSError(msg) + return real_exists(self, *args, **kwargs) + + monkeypatch.setattr(Path, "exists", fake_exists) + with caplog.at_level(logging.DEBUG, logger="deepagents_code.config_commands"): + rows = _config_paths() + config_row = next(row for row in rows if row[0] == "config.toml") + assert config_row[2] is False + assert any("Could not stat" in r.getMessage() for r in caplog.records) + + +def test_run_path_json_reports_existence(monkeypatch, tmp_path, capsys) -> None: + """`config path --json` reports each location's existence and path.""" + import json + + from deepagents_code import model_config + + cfg = tmp_path / "config.toml" + cfg.write_text("") + monkeypatch.setattr(model_config, "DEFAULT_CONFIG_PATH", cfg) + args = argparse.Namespace(config_command="path", output_format="json") + assert run_config_command(args) == 0 + rows = json.loads(capsys.readouterr().out)["data"] + row = next(r for r in rows if r["label"] == "config.toml") + assert row["exists"] is True + assert row["path"] == str(cfg) + + +def test_run_list_json_serializes_catalog(capsys) -> None: + """`config list --json` serializes the catalog without error.""" + import json + + args = argparse.Namespace(config_command="list", output_format="json") + assert run_config_command(args) == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["command"] == "config list" + rows = payload["data"] + assert any( + r["key"] == "interpreter.memory_limit_mb" and r["default"] == 64 for r in rows + ) + assert all( + {"key", "type", "default", "redacted", "env_var", "toml_path", "cli_flag"} + <= set(r) + for r in rows + ) + + +# --- Provider/credential drift ---------------------------------------------- + + +def test_new_provider_surfaces_after_cache_clear(monkeypatch) -> None: + """A provider added to the registry surfaces once the option cache is cleared. + + Exercises the `cache_clear` caveat documented on `get_config_options`: the + credential surface is regenerated from `PROVIDER_API_KEY_ENV`, so a new + provider must produce a `credentials.` option after the cache resets. + """ + from deepagents_code import config_manifest, model_config + + patched = { + **model_config.PROVIDER_API_KEY_ENV, + "synthetic_xyz": "SYNTHETIC_XYZ_API_KEY", + } + monkeypatch.setattr(model_config, "PROVIDER_API_KEY_ENV", patched) + config_manifest.get_config_options.cache_clear() + config_manifest._options_by_key.cache_clear() + try: + opt = config_manifest.get_option("credentials.synthetic_xyz") + assert opt is not None + assert opt.env_var == "SYNTHETIC_XYZ_API_KEY" + # A *_API_KEY env var is treated as secret material. + assert opt.redacted is True + finally: + # Restore the cache so later tests rebuild against the real registry. + config_manifest.get_config_options.cache_clear() + config_manifest._options_by_key.cache_clear() + + +def test_provider_dependency_metadata_is_exhaustive() -> None: + """Every provider key has dependency metadata, and vice versa. + + The module promises new providers cannot silently miss the config surface; + that guarantee only holds for the *availability hints* if the dependency + table tracks `PROVIDER_API_KEY_ENV` exactly. + """ + from deepagents_code.config_manifest import _PROVIDER_DEPENDENCIES + + assert set(_PROVIDER_DEPENDENCIES) == set(PROVIDER_API_KEY_ENV), ( + "_PROVIDER_DEPENDENCIES must track PROVIDER_API_KEY_ENV so config show's " + "availability hints stay complete for every provider" + ) + + +def test_delegate_static_defaults_are_parseable() -> None: + """A delegate option's static default must satisfy its own parser. + + Delegate defaults bypass the resolver's coercion (they are returned verbatim + on the default path), so `__post_init__` cannot type-check them. This guards + the one class of typo it would otherwise miss (e.g. `ptc` default `'saef'`). + """ + from deepagents_code.config import _parse_interpreter_ptc + + for opt in get_config_options(): + if opt.default is None: + continue + if opt.kind is OptionKind.PTC_DELEGATE: + assert _parse_interpreter_ptc(opt.default) == opt.default diff --git a/libs/code/tests/unit_tests/test_startup_fast_paths.py b/libs/code/tests/unit_tests/test_startup_fast_paths.py index cafc300be4..919ff19e20 100644 --- a/libs/code/tests/unit_tests/test_startup_fast_paths.py +++ b/libs/code/tests/unit_tests/test_startup_fast_paths.py @@ -96,6 +96,7 @@ def _read_marker(stderr: str, prefix: str) -> object: (["skills"], "dcode skills "), (["threads"], "dcode threads "), (["mcp"], "dcode mcp "), + (["config"], "dcode config "), ], ) def test_help_only_commands_skip_runtime_imports( @@ -126,6 +127,7 @@ def test_help_only_commands_skip_runtime_imports( ["skills", "list"], ["threads", "list"], ["mcp", "login", "example.com"], + ["config", "show"], ], ) def test_subcommands_bypass_fast_path(argv: list[str]) -> None: