zjcode 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,176 @@
1
+ """Validation and environment-variable expansion for MCP server config.
2
+
3
+ Resolves `${VAR}` and `${VAR:-default}` references in the supported
4
+ configuration fields (`command`, `url`, `args`, `env`, `headers`) and
5
+ validates their types. A `${VAR:-default}` reference falls back to
6
+ `default` when `VAR` is unset *or* empty (POSIX `:-` semantics).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import copy
12
+ import os
13
+ import re
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Mapping
18
+
19
+ _ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^{}]*))?\}")
20
+ """Matches a supported reference: `${VAR}` or `${VAR:-default}`.
21
+
22
+ Group 1 is the variable name; group 2 (present only for the `:-` form) is
23
+ the default. A bare `$VAR` and a literal `$` are intentionally not matched.
24
+ """
25
+
26
+ _ENV_BRACE_RE = re.compile(r"\$\{")
27
+ """Matches a `${` brace-open, used to catch malformed `${...}` references."""
28
+
29
+
30
+ def _interpolate_env(value: str, *, field: str) -> str:
31
+ """Expand `${VAR}` / `${VAR:-default}` references in one config string.
32
+
33
+ A bare `$VAR` (no braces) and a literal `$` pass through untouched;
34
+ only the braced forms expand. `${VAR:-default}` uses `default` when
35
+ `VAR` is unset or empty. A `${...}` that does not parse as one of the
36
+ supported forms (e.g. `${VAR-default}` or an unterminated `${VAR`) is
37
+ rejected rather than silently emitted, so a typo cannot inject a
38
+ garbage value into a URL, command, or header.
39
+
40
+ Args:
41
+ value: Raw configuration string.
42
+ field: Fully qualified field path for error messages.
43
+
44
+ Returns:
45
+ The interpolated string.
46
+
47
+ Raises:
48
+ RuntimeError: If a required environment variable is unset, or the
49
+ string contains a malformed `${...}` reference.
50
+ """
51
+
52
+ def replace(match: re.Match[str]) -> str:
53
+ name = match.group(1)
54
+ default = match.group(2)
55
+ resolved = os.environ.get(name)
56
+ # A non-empty value always wins, for both `${VAR}` and `${VAR:-default}`.
57
+ if resolved:
58
+ return resolved
59
+ # `resolved` is now "" (set but empty) or None (unset).
60
+ if default is not None:
61
+ # `${VAR:-default}`: `:-` falls back for empty *and* unset (POSIX).
62
+ return default
63
+ if resolved is not None:
64
+ # `${VAR}` set to "": no default, so emit the empty value.
65
+ return resolved
66
+ # `${VAR}` unset with no default: the only hard error.
67
+ msg = (
68
+ f"{field} references unset env var {name}. "
69
+ f"Set {name} in the environment or provide a default."
70
+ )
71
+ raise RuntimeError(msg)
72
+
73
+ # Reject any `${` that isn't the start of a well-formed reference. The
74
+ # check is against the raw `value` (not the substituted result) so a
75
+ # resolved value that happens to contain `${` never trips it.
76
+ ref_spans = [match.span() for match in _ENV_REF_RE.finditer(value)]
77
+ for brace in _ENV_BRACE_RE.finditer(value):
78
+ if not any(start <= brace.start() < end for start, end in ref_spans):
79
+ msg = (
80
+ f"{field} contains a malformed '${{...}}' reference. "
81
+ "Use '${VAR}' or '${VAR:-default}'."
82
+ )
83
+ raise RuntimeError(msg)
84
+
85
+ return _ENV_REF_RE.sub(replace, value)
86
+
87
+
88
+ def _resolve_string(value: object, *, field: str) -> str:
89
+ """Validate and interpolate one string field.
90
+
91
+ Args:
92
+ value: Raw field value.
93
+ field: Fully qualified field path for error messages.
94
+
95
+ Returns:
96
+ The validated and interpolated string.
97
+
98
+ Raises:
99
+ TypeError: If the field value is not a string.
100
+ """
101
+ if not isinstance(value, str):
102
+ msg = f"{field} must be a string, got {type(value).__name__}"
103
+ raise TypeError(msg)
104
+ return _interpolate_env(value, field=field)
105
+
106
+
107
+ def _resolve_mapping_values(
108
+ values: Mapping[str, object],
109
+ *,
110
+ field: str,
111
+ ) -> dict[str, str]:
112
+ """Validate and interpolate string values in a mapping field.
113
+
114
+ Args:
115
+ values: Raw mapping values.
116
+ field: Fully qualified field path for error messages.
117
+
118
+ Returns:
119
+ A new mapping with validated and interpolated values.
120
+ """
121
+ return {
122
+ name: _resolve_string(value, field=f"{field}.{name}")
123
+ for name, value in values.items()
124
+ }
125
+
126
+
127
+ def resolve_mcp_server_env(
128
+ server_name: str,
129
+ server_config: Mapping[str, object],
130
+ ) -> dict[str, Any]:
131
+ """Resolve `${VAR}` references in one MCP server's supported fields.
132
+
133
+ Interpolates the `command`, `url`, `args`, `env`, and `headers`
134
+ fields (see `_interpolate_env` for the reference syntax); every other
135
+ field is copied through verbatim. The input is not mutated.
136
+
137
+ Args:
138
+ server_name: Server name used in field-specific error messages.
139
+ server_config: Raw server configuration.
140
+
141
+ Returns:
142
+ A resolved copy of the server configuration.
143
+
144
+ Raises:
145
+ TypeError: If a supported field has the wrong type — a non-string
146
+ scalar value, or `args`/`env`/`headers` with the wrong container
147
+ type.
148
+ RuntimeError: If a required environment variable is unset.
149
+ """ # noqa: DOC502 - `RuntimeError` is raised by `_interpolate_env`
150
+ resolved: dict[str, Any] = copy.deepcopy(dict(server_config))
151
+ prefix = f"mcpServers.{server_name}"
152
+
153
+ for name in ("command", "url"):
154
+ if name in resolved:
155
+ resolved[name] = _resolve_string(resolved[name], field=f"{prefix}.{name}")
156
+
157
+ if "args" in resolved:
158
+ args = resolved["args"]
159
+ if not isinstance(args, list):
160
+ msg = f"{prefix}.args must be a list, got {type(args).__name__}"
161
+ raise TypeError(msg)
162
+ resolved["args"] = [
163
+ _resolve_string(value, field=f"{prefix}.args[{index}]")
164
+ for index, value in enumerate(args)
165
+ ]
166
+
167
+ for name in ("env", "headers"):
168
+ if name not in resolved:
169
+ continue
170
+ values = resolved[name]
171
+ if not isinstance(values, dict):
172
+ msg = f"{prefix}.{name} must be a dictionary, got {type(values).__name__}"
173
+ raise TypeError(msg)
174
+ resolved[name] = _resolve_mapping_values(values, field=f"{prefix}.{name}")
175
+
176
+ return resolved
@@ -0,0 +1,212 @@
1
+ """Persistent store of MCP server names the user has disabled.
2
+
3
+ Disabled servers are skipped at config merge time so their tools never
4
+ reach the agent and no connection is attempted. State lives under
5
+ `[mcp].disabled_servers` in `~/.deepagents/config.toml`, alongside the
6
+ user's other MCP configuration.
7
+
8
+ The store keys on server *name* alone. Two configs that both declare a
9
+ `github` server will both be disabled by a single entry — intentional,
10
+ since the agent cannot distinguish overlapping names at runtime anyway
11
+ (later configs in the merge order win).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import logging
18
+ import os
19
+ import tempfile
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from deepagents_code.model_config import DEFAULT_CONFIG_PATH as _DEFAULT_CONFIG_PATH
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _SECTION = "mcp"
28
+ _KEY = "disabled_servers"
29
+ _LEGACY_SECTION = "mcp_disabled"
30
+ _LEGACY_KEY = "servers"
31
+
32
+
33
+ class _ConfigLoadError(Exception):
34
+ """Raised when the config exists but cannot be parsed or read.
35
+
36
+ Distinct from "file does not exist" so callers can refuse to
37
+ overwrite a config they could not parse — otherwise a transient
38
+ read error or a hand-edit typo would silently truncate sibling
39
+ sections (e.g. model profiles) on the next write.
40
+ """
41
+
42
+
43
+ def _load_config(config_path: Path) -> dict[str, Any]:
44
+ """Read the TOML config file.
45
+
46
+ Args:
47
+ config_path: Path to the TOML config file.
48
+
49
+ Returns:
50
+ Parsed TOML data, or an empty dict if the file does not exist.
51
+
52
+ Raises:
53
+ _ConfigLoadError: If the file exists but cannot be read or parsed.
54
+ """
55
+ import tomllib
56
+
57
+ if not config_path.exists():
58
+ return {}
59
+ try:
60
+ with config_path.open("rb") as f:
61
+ return tomllib.load(f)
62
+ except (OSError, tomllib.TOMLDecodeError) as exc:
63
+ logger.warning(
64
+ "Could not read MCP disabled config at %s: %s",
65
+ config_path,
66
+ exc,
67
+ )
68
+ msg = f"could not load {config_path}: {exc}"
69
+ raise _ConfigLoadError(msg) from exc
70
+
71
+
72
+ def _save_config(data: dict[str, Any], config_path: Path) -> bool:
73
+ """Atomic TOML write.
74
+
75
+ Returns:
76
+ `True` on success, `False` on I/O failure.
77
+ """
78
+ import tomli_w
79
+
80
+ try:
81
+ config_path.parent.mkdir(parents=True, exist_ok=True)
82
+ fd, tmp_path = tempfile.mkstemp(dir=config_path.parent, suffix=".tmp")
83
+ try:
84
+ with os.fdopen(fd, "wb") as f:
85
+ tomli_w.dump(data, f)
86
+ Path(tmp_path).replace(config_path)
87
+ except BaseException:
88
+ with contextlib.suppress(OSError):
89
+ Path(tmp_path).unlink()
90
+ raise
91
+ except (OSError, ValueError):
92
+ logger.exception("Failed to save config to %s", config_path)
93
+ return False
94
+ return True
95
+
96
+
97
+ def _coerce_entries(entries: object) -> set[str] | None:
98
+ """Return valid server names from a TOML value, or `None` when unset."""
99
+ if not isinstance(entries, list):
100
+ return None
101
+ return {name for name in entries if isinstance(name, str) and name}
102
+
103
+
104
+ def _disabled_entries(data: dict[str, Any]) -> set[str]:
105
+ """Return disabled names from the current config shape with legacy fallback."""
106
+ section = data.get(_SECTION)
107
+ if isinstance(section, dict):
108
+ entries = _coerce_entries(section.get(_KEY))
109
+ if entries is not None:
110
+ return entries
111
+
112
+ legacy_section = data.get(_LEGACY_SECTION)
113
+ if isinstance(legacy_section, dict):
114
+ entries = _coerce_entries(legacy_section.get(_LEGACY_KEY))
115
+ if entries is not None:
116
+ return entries
117
+
118
+ return set()
119
+
120
+
121
+ def _remove_legacy_disabled_section(data: dict[str, Any]) -> None:
122
+ """Drop the old top-level section after writing the folded config shape."""
123
+ legacy_section = data.get(_LEGACY_SECTION)
124
+ if not isinstance(legacy_section, dict):
125
+ data.pop(_LEGACY_SECTION, None)
126
+ return
127
+ legacy_section.pop(_LEGACY_KEY, None)
128
+ if legacy_section:
129
+ data[_LEGACY_SECTION] = legacy_section
130
+ else:
131
+ data.pop(_LEGACY_SECTION, None)
132
+
133
+
134
+ def get_disabled_servers(*, config_path: Path | None = None) -> set[str]:
135
+ """Return the set of server names the user has disabled.
136
+
137
+ Args:
138
+ config_path: Override the default config location; intended for tests.
139
+
140
+ Returns:
141
+ Set of server names. Empty when nothing is disabled or the config
142
+ cannot be read.
143
+ """
144
+ if config_path is None:
145
+ config_path = _DEFAULT_CONFIG_PATH
146
+ try:
147
+ data = _load_config(config_path)
148
+ except _ConfigLoadError:
149
+ return set()
150
+ return _disabled_entries(data)
151
+
152
+
153
+ def is_server_disabled(server_name: str, *, config_path: Path | None = None) -> bool:
154
+ """Return `True` when `server_name` is in the disabled set.
155
+
156
+ Args:
157
+ server_name: MCP server name from `mcpServers` config.
158
+ config_path: Override the default config location; intended for tests.
159
+
160
+ Returns:
161
+ `True` when the server is recorded as disabled, `False` otherwise
162
+ (including when the config cannot be read).
163
+ """
164
+ return server_name in get_disabled_servers(config_path=config_path)
165
+
166
+
167
+ def set_server_disabled(
168
+ server_name: str,
169
+ disabled: bool,
170
+ *,
171
+ config_path: Path | None = None,
172
+ ) -> tuple[bool, str | None]:
173
+ """Add or remove `server_name` from the persistent disabled set.
174
+
175
+ Refuses to write when the existing config cannot be parsed so a
176
+ corrupt or permission-denied file is not silently overwritten —
177
+ that would discard sibling sections such as model profiles.
178
+
179
+ Args:
180
+ server_name: MCP server name from `mcpServers` config.
181
+ disabled: `True` to disable, `False` to re-enable.
182
+ config_path: Override the default config location; intended for tests.
183
+
184
+ Returns:
185
+ Tuple of `(ok, error_detail)`. `ok` is `True` on success; on
186
+ failure `error_detail` is a short user-facing string suitable
187
+ for a toast.
188
+ """
189
+ if config_path is None:
190
+ config_path = _DEFAULT_CONFIG_PATH
191
+ try:
192
+ data = _load_config(config_path)
193
+ except _ConfigLoadError as exc:
194
+ return False, str(exc)
195
+ current = _disabled_entries(data)
196
+ previous = set(current)
197
+ if disabled:
198
+ current.add(server_name)
199
+ else:
200
+ current.discard(server_name)
201
+ if current == previous and _LEGACY_SECTION not in data:
202
+ return True, None
203
+
204
+ section = data.get(_SECTION)
205
+ if not isinstance(section, dict):
206
+ section = {}
207
+ section[_KEY] = sorted(current)
208
+ data[_SECTION] = section
209
+ _remove_legacy_disabled_section(data)
210
+ if _save_config(data, config_path):
211
+ return True, None
212
+ return False, f"could not write {config_path}"
@@ -0,0 +1,281 @@
1
+ """UI-agnostic helpers for resolving an MCP login target.
2
+
3
+ The MCP login flow historically inlined config discovery, trust gating,
4
+ shape validation, and `print()`-based error reporting. The TUI cannot
5
+ consume those print statements, so this module extracts the same logic
6
+ into pure functions that return structured results (`ConfigResolution`,
7
+ `ServerSelection`) plus a typed `ConfigResolutionError`. Callers decide
8
+ how to render those results.
9
+
10
+ No `print()` calls live in this module. No imports happen at module
11
+ top level beyond `dataclasses`/`typing`/`pathlib` so the CLI fast path
12
+ stays cheap; the actual config loaders are imported inside the
13
+ functions that need them.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from enum import StrEnum
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ if TYPE_CHECKING:
24
+ from deepagents_code.mcp_auth import McpServerSpec
25
+
26
+
27
+ class ConfigErrorKind(StrEnum):
28
+ """Discriminator for `ConfigResolutionError` reasons.
29
+
30
+ Only `NO_CONFIG_FOUND` maps to exit code 2 in `run_mcp_login`; all
31
+ other kinds map to exit code 1. The TUI surface translates them into
32
+ in-app status messages.
33
+ """
34
+
35
+ EXPLICIT_LOAD_FAILED = "explicit_load_failed"
36
+ """The `--mcp-config` path could not be parsed."""
37
+
38
+ NO_CONFIG_FOUND = "no_config_found"
39
+ """Auto-discovery returned zero candidate paths."""
40
+
41
+ NO_USABLE_CONFIG = "no_usable_config"
42
+ """Discovered paths existed but none could be loaded successfully."""
43
+
44
+ UNKNOWN_SERVER = "unknown_server"
45
+ """The selected server is not present in the resolved config."""
46
+
47
+ INVALID_SERVER_CONFIG = "invalid_server_config"
48
+ """The selected server's entry failed shape validation."""
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ConfigResolutionError:
53
+ """Structured error returned when a login target cannot be resolved."""
54
+
55
+ kind: ConfigErrorKind
56
+ """Reason category — callers translate this into UI text or exit codes."""
57
+
58
+ message: str
59
+ """Plain-text description suitable for direct display to the user."""
60
+
61
+ untrusted_project_paths: tuple[Path, ...] = ()
62
+ """Project-level configs skipped because the trust store had no match.
63
+
64
+ Populated only when at least one discovered project config was
65
+ skipped during auto-discovery, regardless of `kind`. Callers can
66
+ surface a "skipping untrusted project config" hint alongside the
67
+ primary error.
68
+ """
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ConfigResolution:
73
+ """Successful resolution of a merged MCP config for login."""
74
+
75
+ config: dict[str, Any]
76
+ """The merged `mcpServers`-shaped config dict."""
77
+
78
+ used_paths: tuple[Path, ...]
79
+ """Paths whose contents were merged into `config`, in precedence order."""
80
+
81
+ untrusted_project_paths: tuple[Path, ...] = ()
82
+ """Project-level configs that were skipped during this resolution."""
83
+
84
+ def __post_init__(self) -> None:
85
+ """Enforce the non-empty `used_paths` invariant.
86
+
87
+ Raises:
88
+ ValueError: If `used_paths` is empty.
89
+ """
90
+ if not self.used_paths:
91
+ msg = "ConfigResolution must have at least one used path"
92
+ raise ValueError(msg)
93
+
94
+ @property
95
+ def search_label(self) -> str:
96
+ """Human-readable join of the paths backing this resolution."""
97
+ return ", ".join(str(path) for path in self.used_paths)
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class ServerSelection:
102
+ """Resolved server config plus enough context for error messages."""
103
+
104
+ server_name: str
105
+ """Selected MCP server name (matches an `mcpServers` key)."""
106
+
107
+ server_config: McpServerSpec
108
+ """Validated server config payload for `mcp_auth.login`."""
109
+
110
+ search_label: str = ""
111
+ """Where the config came from — used in not-found errors."""
112
+
113
+ def __post_init__(self) -> None:
114
+ """Enforce the non-empty `server_name` invariant.
115
+
116
+ Raises:
117
+ ValueError: If `server_name` is empty.
118
+ """
119
+ if not self.server_name:
120
+ msg = "ServerSelection.server_name must not be empty"
121
+ raise ValueError(msg)
122
+
123
+
124
+ def resolve_mcp_config(
125
+ config_path: str | None,
126
+ ) -> ConfigResolution | ConfigResolutionError:
127
+ """Resolve an MCP config dict for login without printing anything.
128
+
129
+ Args:
130
+ config_path: Explicit `--mcp-config` path, or `None` for auto-discovery.
131
+
132
+ Returns:
133
+ A `ConfigResolution` on success, or a `ConfigResolutionError`
134
+ describing why no usable config could be assembled.
135
+ """
136
+ from deepagents_code.mcp_tools import (
137
+ classify_discovered_configs,
138
+ discover_mcp_configs,
139
+ load_mcp_config,
140
+ load_mcp_config_lenient,
141
+ merge_mcp_configs,
142
+ )
143
+
144
+ if config_path is not None:
145
+ try:
146
+ config = load_mcp_config(config_path)
147
+ except (OSError, TypeError, ValueError, RuntimeError) as exc:
148
+ return ConfigResolutionError(
149
+ kind=ConfigErrorKind.EXPLICIT_LOAD_FAILED,
150
+ message=f"Failed to load MCP config {config_path}: {exc}",
151
+ )
152
+ return ConfigResolution(
153
+ config=config,
154
+ used_paths=(Path(config_path),),
155
+ )
156
+
157
+ found = discover_mcp_configs()
158
+ if not found:
159
+ return ConfigResolutionError(
160
+ kind=ConfigErrorKind.NO_CONFIG_FOUND,
161
+ message=(
162
+ "No MCP config file found in any auto-discovered location. "
163
+ "Pass --mcp-config <path>, or run `dcode mcp login --help` "
164
+ "to see the search paths and config format."
165
+ ),
166
+ )
167
+
168
+ user_paths, project_paths = classify_discovered_configs(found)
169
+ configs: list[dict[str, Any]] = []
170
+ used_paths: list[Path] = []
171
+ untrusted: tuple[Path, ...] = ()
172
+
173
+ for path in user_paths:
174
+ loaded = load_mcp_config_lenient(path)
175
+ if loaded is not None:
176
+ configs.append(loaded)
177
+ used_paths.append(path)
178
+
179
+ if project_paths:
180
+ from deepagents_code.mcp_trust import (
181
+ compute_config_fingerprint,
182
+ is_project_mcp_trusted,
183
+ )
184
+ from deepagents_code.project_utils import find_project_root
185
+
186
+ project_root = str((find_project_root() or Path.cwd()).resolve())
187
+ fingerprint = compute_config_fingerprint(project_paths)
188
+ if is_project_mcp_trusted(project_root, fingerprint):
189
+ for path in project_paths:
190
+ loaded = load_mcp_config_lenient(path)
191
+ if loaded is not None:
192
+ configs.append(loaded)
193
+ used_paths.append(path)
194
+ else:
195
+ untrusted = tuple(project_paths)
196
+
197
+ if not configs:
198
+ found_paths = ", ".join(str(path) for path in found)
199
+ return ConfigResolutionError(
200
+ kind=ConfigErrorKind.NO_USABLE_CONFIG,
201
+ message=f"No usable MCP config found in: {found_paths}",
202
+ untrusted_project_paths=untrusted,
203
+ )
204
+
205
+ return ConfigResolution(
206
+ config=merge_mcp_configs(configs),
207
+ used_paths=tuple(used_paths),
208
+ untrusted_project_paths=untrusted,
209
+ )
210
+
211
+
212
+ def select_server(
213
+ resolution: ConfigResolution,
214
+ server: str,
215
+ ) -> ServerSelection | ConfigResolutionError:
216
+ """Pull `server` out of a resolved config and validate its shape.
217
+
218
+ Args:
219
+ resolution: A successful `resolve_mcp_config` result.
220
+ server: Target server name as supplied by the user.
221
+
222
+ Returns:
223
+ A `ServerSelection` on success, or a `ConfigResolutionError`
224
+ describing why the server entry is unusable.
225
+ """
226
+ from deepagents_code.mcp_tools import _validate_server_config
227
+
228
+ servers = resolution.config.get("mcpServers", {})
229
+ if server not in servers:
230
+ return ConfigResolutionError(
231
+ kind=ConfigErrorKind.UNKNOWN_SERVER,
232
+ message=(
233
+ f"Server {server!r} not found in {resolution.search_label}. "
234
+ f"Known servers: {sorted(servers)}"
235
+ ),
236
+ )
237
+
238
+ try:
239
+ _validate_server_config(server, servers[server])
240
+ except (TypeError, ValueError) as exc:
241
+ return ConfigResolutionError(
242
+ kind=ConfigErrorKind.INVALID_SERVER_CONFIG,
243
+ message=f"Invalid MCP server config for {server!r}: {exc}",
244
+ )
245
+
246
+ return ServerSelection(
247
+ server_name=server,
248
+ server_config=servers[server],
249
+ search_label=resolution.search_label,
250
+ )
251
+
252
+
253
+ def format_untrusted_project_notice(paths: tuple[Path, ...]) -> str:
254
+ """Build the CLI-style hint string for skipped untrusted project configs.
255
+
256
+ Args:
257
+ paths: Project configs that were skipped during resolution.
258
+
259
+ Returns:
260
+ A single-line user-facing string. Empty when `paths` is empty.
261
+ """
262
+ if not paths:
263
+ return ""
264
+ skipped = ", ".join(str(path) for path in paths)
265
+ return (
266
+ "Skipping untrusted project MCP config "
267
+ f"(not yet approved or config changed): {skipped}. "
268
+ "Approve it by running `dcode` in this project, or "
269
+ "pass --mcp-config <path> to use it explicitly."
270
+ )
271
+
272
+
273
+ __all__ = [
274
+ "ConfigErrorKind",
275
+ "ConfigResolution",
276
+ "ConfigResolutionError",
277
+ "ServerSelection",
278
+ "format_untrusted_project_notice",
279
+ "resolve_mcp_config",
280
+ "select_server",
281
+ ]