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,3985 @@
1
+ """Main entry point and loop."""
2
+
3
+ # ruff: noqa: E402
4
+ # Imports placed after warning filters to suppress deprecation warnings
5
+
6
+ # Suppress deprecation warnings from langchain_core (e.g., Pydantic V1 on Python 3.14+)
7
+ import warnings
8
+
9
+ warnings.filterwarnings("ignore", module="langchain_core._api.deprecation")
10
+
11
+ import argparse
12
+ import asyncio
13
+ import contextlib
14
+ import importlib.util
15
+ import json
16
+ import logging
17
+ import os
18
+ import shlex
19
+ import shutil
20
+ import signal
21
+ import sys
22
+ import traceback
23
+ from collections.abc import Callable, Sequence
24
+ from pathlib import Path
25
+ from typing import TYPE_CHECKING, Any, NoReturn
26
+
27
+ if TYPE_CHECKING:
28
+ from rich.console import Console
29
+
30
+ from deepagents_code.app import AppResult
31
+ from deepagents_code.mcp_tools import MCPServerInfo
32
+ from deepagents_code.notifications import PendingNotification
33
+
34
+ # Suppress Pydantic v1 compatibility warnings from langchain on Python 3.14+
35
+ warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarning)
36
+
37
+ from deepagents_code._version import DISTRIBUTION_NAME, __version__
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ _SANDBOX_DEFAULT_SENTINEL = "\x00default"
42
+ """Marker stored by `--sandbox` with no value, resolved to `[sandboxes].default`."""
43
+
44
+ _UNPERSISTED_AUTO_UPDATE_FAILURE_NOTE = (
45
+ "\n[yellow]Note:[/yellow] this failure could not be recorded, so dcode will "
46
+ "retry this update on the next launch until the state directory becomes writable."
47
+ )
48
+
49
+
50
+ def _handle_termination_signal(signum: int, _frame: object) -> NoReturn:
51
+ """Unwind dcode on a terminating signal so owned resources are cleaned up.
52
+
53
+ Args:
54
+ signum: Received signal number.
55
+ _frame: Interrupted stack frame, unused.
56
+
57
+ Raises:
58
+ SystemExit: Always, using the conventional signal-derived exit code.
59
+
60
+ Note:
61
+ The `SystemExit` is raised at an arbitrary point in the main thread, so
62
+ it can interrupt server teardown mid-escalation (e.g. between SIGTERM and
63
+ SIGKILL). This is safe because teardown is re-entrant: the process-group
64
+ `except` clauses catch only `ProcessLookupError`/`OSError` (never a
65
+ `BaseException` like `SystemExit`), and the app's cleanup `finally` block
66
+ re-invokes `stop()` as the exception unwinds, resuming the teardown.
67
+ """
68
+ # Tag the shutdown as interrupted before we raise. `SystemExit` bypasses
69
+ # `sys.excepthook`, so this is the only place we can attribute a signal to
70
+ # the reason classifier used by `session_end_summary._finalize`.
71
+ try:
72
+ from deepagents_code import session_end_summary
73
+
74
+ session_end_summary.mark_signal(signum)
75
+ except Exception:
76
+ pass
77
+ raise SystemExit(128 + signum)
78
+
79
+
80
+ def _install_termination_signal_handlers() -> None:
81
+ """Install graceful terminating-signal handling on POSIX."""
82
+ if sys.platform != "win32":
83
+ for signum in (signal.SIGHUP, signal.SIGTERM, signal.SIGQUIT):
84
+ signal.signal(signum, _handle_termination_signal)
85
+
86
+
87
+ def _tail_log_command(log_path: Path | str) -> str:
88
+ """Return a copy-pasteable command for following a log file."""
89
+ return f"tail -f {shlex.quote(str(log_path))}"
90
+
91
+
92
+ def build_version_text() -> str:
93
+ """Build the plain-text output for the `--version` CLI flag.
94
+
95
+ Includes the CLI and SDK versions and any installed optional
96
+ dependencies. For editable installs it also reports the source path and
97
+ the resolved versions of the core LangChain-ecosystem dependencies.
98
+
99
+ Reports the same version facts as the `/version` slash command, in the
100
+ same section order (versions, editable path, core dependencies, optional
101
+ dependencies), but omits its network-dependent release-age suffixes and
102
+ update-available hint so `--version` stays offline.
103
+
104
+ Returns:
105
+ Multi-line version string suitable for stdout.
106
+ """
107
+ from deepagents_code.extras_info import resolve_sdk_version
108
+
109
+ sdk_version_value, status = resolve_sdk_version()
110
+ sdk_version = sdk_version_value if status == "resolved" else "unknown"
111
+
112
+ text = f"{DISTRIBUTION_NAME} {__version__}\ndeepagents (SDK) {sdk_version}"
113
+
114
+ editable = False
115
+ try:
116
+ from deepagents_code.config import (
117
+ _get_editable_install_path,
118
+ _is_editable_install,
119
+ )
120
+
121
+ editable = _is_editable_install()
122
+ if editable:
123
+ path = _get_editable_install_path()
124
+ text += f"\nEditable install: {path}" if path else "\nEditable install"
125
+ except Exception:
126
+ logger.warning("Unexpected error detecting editable install", exc_info=True)
127
+
128
+ # Core dependencies precede optional dependencies to match the section
129
+ # order of the `/version` slash command (see `_handle_version_command`).
130
+ if editable:
131
+ try:
132
+ from deepagents_code.extras_info import format_core_dependencies_plain
133
+
134
+ core_text = format_core_dependencies_plain()
135
+ except Exception:
136
+ logger.warning("Failed to collect core dependency versions", exc_info=True)
137
+ core_text = ""
138
+ if core_text:
139
+ text = f"{text}\n\n{core_text}"
140
+
141
+ try:
142
+ from deepagents_code.extras_info import (
143
+ format_extras_status_plain,
144
+ get_extras_status,
145
+ )
146
+
147
+ extras_text = format_extras_status_plain(get_extras_status())
148
+ except Exception:
149
+ logger.warning("Unexpected error collecting optional deps", exc_info=True)
150
+ extras_text = ""
151
+ if extras_text:
152
+ text = f"{text}\n\n{extras_text}"
153
+
154
+ return text
155
+
156
+
157
+ def _restart_current_process() -> NoReturn:
158
+ """Replace the current process with a fresh `deepagents_code` invocation.
159
+
160
+ Raises:
161
+ RuntimeError: If process replacement unexpectedly returns.
162
+ """
163
+ argv = [sys.executable, "-m", "deepagents_code", *sys.argv[1:]]
164
+ # Re-exec the trusted interpreter with the user's own argv verbatim; the
165
+ # only "input" is the command the user already ran, so S606's concern
166
+ # (untrusted/unsanitized args to a spawned executable) does not apply.
167
+ os.execv(sys.executable, argv) # noqa: S606
168
+ msg = "os.execv returned unexpectedly"
169
+ raise RuntimeError(msg)
170
+
171
+
172
+ def _terminal_row_count(console: "Console", text: str) -> int:
173
+ """Return how many terminal rows Rich renders for `text`.
174
+
175
+ Args:
176
+ console: The Rich console whose current width determines wrapping.
177
+ text: The string to measure, rendered with no markup.
178
+
179
+ Returns:
180
+ The number of visual rows Rich wraps `text` into, at least 1.
181
+ """
182
+ from rich.text import Text
183
+
184
+ return max(1, len(console.render_lines(Text(text), console.options)))
185
+
186
+
187
+ def _should_check_teardown_thread(
188
+ thread_id: str | None,
189
+ *,
190
+ request_count: int,
191
+ resume_thread: str | None,
192
+ ) -> bool:
193
+ """Return whether teardown should query for checkpointed thread content.
194
+
195
+ Any session that owns a thread may have persisted a checkpoint, so the only
196
+ gate is whether a thread exists. `request_count` and `resume_thread` are
197
+ accepted and ignored: an interrupted first turn can checkpoint before any
198
+ usage metadata is recorded, so they are not a reliable proxy. They remain in
199
+ the signature so callers need not change if the gate later grows selective.
200
+ """
201
+ del request_count, resume_thread
202
+ return bool(thread_id)
203
+
204
+
205
+ def _render_teardown_thread_hints(
206
+ console: "Console",
207
+ thread_id: str,
208
+ *,
209
+ return_code: int,
210
+ ) -> None:
211
+ """Print the LangSmith link and resume hint for a checkpointed thread.
212
+
213
+ Both hints share a single `thread_exists` lookup to avoid spinning up a
214
+ second event loop and aiosqlite connection during teardown. Every failure is
215
+ logged at debug and swallowed: teardown convenience output must never crash
216
+ the exit path.
217
+
218
+ Args:
219
+ console: Console to print the hints to.
220
+ thread_id: Thread whose checkpoints back the hints.
221
+ return_code: Process exit code; the resume hint is shown only on a clean
222
+ exit (`0`).
223
+ """
224
+ from rich.style import Style
225
+ from rich.text import Text
226
+
227
+ from deepagents_code.config import build_langsmith_thread_url
228
+ from deepagents_code.sessions import thread_exists
229
+
230
+ try:
231
+ thread_has_checkpoints = asyncio.run(thread_exists(thread_id))
232
+ except Exception:
233
+ logger.debug(
234
+ "Could not check thread existence on teardown",
235
+ exc_info=True,
236
+ )
237
+ return
238
+
239
+ if not thread_has_checkpoints:
240
+ return
241
+
242
+ try:
243
+ thread_url = build_langsmith_thread_url(thread_id)
244
+ if thread_url:
245
+ console.print()
246
+ ls_hint = Text("View this thread in LangSmith: ", style="dim")
247
+ ls_hint.append(thread_url, style=Style(dim=True, link=thread_url))
248
+ console.print(ls_hint)
249
+ except Exception:
250
+ logger.debug(
251
+ "Could not display LangSmith thread URL on teardown",
252
+ exc_info=True,
253
+ )
254
+
255
+ if return_code == 0:
256
+ console.print()
257
+ console.print("[dim]Resume this thread with:[/dim]")
258
+ hint = Text("dcode -r ", style="cyan")
259
+ hint.append(str(thread_id), style="cyan")
260
+ console.print(hint)
261
+
262
+
263
+ def _confirm_update_after_restart(console: "Console", version: str) -> None:
264
+ """Rewrite the pre-restart `Launching...` line as a stable update status.
265
+
266
+ The `Updated to v{version}. Launching...` line is printed by the previous
267
+ generation right before `os.execv`; this runs in the re-exec'd process to
268
+ clear the transient action once the new version is actually running.
269
+
270
+ The in-place rewrite is attempted only on a real terminal: `os.execv` does
271
+ nothing between that print and this process's first output, so the cursor
272
+ is parked on the line directly below it. On non-terminals the escape codes
273
+ would corrupt redirected output, so the line is left as-is.
274
+
275
+ The row count is recomputed against the current terminal width, so a resize
276
+ during the upgrade/re-exec window could make the erase loop clear the wrong
277
+ number of wrapped rows. This is a benign visual glitch (no exception), and
278
+ is rare enough not to warrant defending against here.
279
+
280
+ Args:
281
+ console: The Rich console used for startup output.
282
+ version: The version now running, used in the confirmation line.
283
+ """
284
+ if not console.is_terminal:
285
+ return
286
+ from rich.control import Control
287
+ from rich.segment import ControlType
288
+
289
+ launch_status = f"Updated to v{version}. Launching..."
290
+ launch_rows = _terminal_row_count(console, launch_status)
291
+ # Move up to the bottom row of the old status and erase each rendered row.
292
+ # This preserves the rewrite when Rich wrapped the status in a narrow pane.
293
+ for _ in range(launch_rows):
294
+ console.control(
295
+ Control(
296
+ (ControlType.CURSOR_UP, 1),
297
+ (ControlType.CURSOR_MOVE_TO_COLUMN, 0),
298
+ (ControlType.ERASE_IN_LINE, 2),
299
+ )
300
+ )
301
+ console.print(f"[green]Updated to v{version}.[/green]", highlight=False)
302
+
303
+
304
+ def _run_startup_auto_update(console: "Console") -> None:
305
+ """Apply enabled auto-updates before the TUI and server start.
306
+
307
+ On a successful upgrade the process is re-exec'd so the new version is
308
+ loaded. Any failure is fail-soft: the installed version is launched and
309
+ the error is surfaced, never blocking startup.
310
+
311
+ Raises:
312
+ SystemExit: Re-raised rather than suppressed by the fail-soft handler,
313
+ so a process-exit request is never swallowed (the `os.execv`
314
+ re-exec is simulated this way under test).
315
+ """
316
+ from rich.markup import escape
317
+
318
+ from deepagents_code._env_vars import DEBUG_UPDATE, RESTARTED_AFTER_UPDATE
319
+ from deepagents_code._version import __version__ as cli_version
320
+ from deepagents_code.config import _is_editable_install
321
+ from deepagents_code.update_check import (
322
+ clear_startup_auto_update_failure,
323
+ create_update_log_path,
324
+ detect_shadowed_dcode_safe,
325
+ format_release_age_parenthetical,
326
+ format_shadowed_dcode_warning,
327
+ get_cached_update_available,
328
+ is_auto_update_enabled,
329
+ is_installed_version_at_least,
330
+ is_update_check_enabled,
331
+ mark_auto_update_default_acknowledged,
332
+ mark_startup_auto_update_failed,
333
+ perform_upgrade,
334
+ release_requires_prereleases,
335
+ should_announce_auto_update_default,
336
+ should_skip_startup_auto_update_after_failure,
337
+ upgrade_command,
338
+ )
339
+
340
+ # Set to the target version while an upgrade attempt is in flight, and
341
+ # cleared on success. If `perform_upgrade` *raises* instead of returning a
342
+ # failure, the fail-soft handler below records the cooldown from this so the
343
+ # same broken target is not retried — and re-stalled — on every launch.
344
+ pending_failure_version: str | None = None
345
+ try:
346
+ if (
347
+ _is_editable_install()
348
+ or not is_update_check_enabled()
349
+ or not is_auto_update_enabled()
350
+ ):
351
+ return
352
+ # Consume the re-exec sentinel recorded before the previous restart.
353
+ restarted_for = os.environ.pop(RESTARTED_AFTER_UPDATE, None)
354
+ if restarted_for is not None and is_installed_version_at_least(restarted_for):
355
+ # The re-exec landed on the upgraded version, so the prior
356
+ # "Launching..." line can be replaced with a stable completed status.
357
+ try:
358
+ _confirm_update_after_restart(console, restarted_for)
359
+ except Exception:
360
+ # The upgrade already succeeded; this rewrite is purely
361
+ # cosmetic. Swallow rendering glitches with their own guard so
362
+ # the outer fail-soft handler does not misreport a successful
363
+ # upgrade as "Auto-update failed". The prior "Launching..."
364
+ # line simply stays.
365
+ logger.debug("Post-restart update confirmation failed", exc_info=True)
366
+ available, latest = get_cached_update_available()
367
+ if not available or latest is None:
368
+ return
369
+ if is_installed_version_at_least(latest):
370
+ # The on-disk install already satisfies `latest` (e.g. the user ran
371
+ # `/update` in-session). `get_cached_update_available` compares the
372
+ # baked-in `__version__`, which lags an in-session upgrade, so
373
+ # re-running the upgrade here would be a redundant no-op and restart.
374
+ return
375
+ if restarted_for == latest:
376
+ # Already restarted after upgrading to this version, yet it still
377
+ # reports as available: the install did not change the running
378
+ # version. Bail out instead of upgrading and restarting forever
379
+ # (this runs before the TUI, so there is no in-app way to stop it).
380
+ update_needs_prereleases = release_requires_prereleases(latest)
381
+ cmd = upgrade_command(
382
+ include_prereleases=True if update_needs_prereleases else None,
383
+ version=latest if update_needs_prereleases else None,
384
+ )
385
+ console.print(
386
+ f"[bold yellow]Warning:[/bold yellow] v{latest} still reports as "
387
+ "available after an automatic update; skipping auto-update to "
388
+ f"avoid a restart loop. Update manually: [cyan]{cmd}[/cyan]\n"
389
+ f"Continuing with v{cli_version}.",
390
+ highlight=False,
391
+ )
392
+ return
393
+ if should_skip_startup_auto_update_after_failure(latest):
394
+ # A same-version upgrade failed recently; retrying it here would
395
+ # very likely fail again and re-stall every launch (this runs before
396
+ # the TUI). Skip for the cooldown window and point at a manual
397
+ # command instead.
398
+ update_needs_prereleases = release_requires_prereleases(latest)
399
+ cmd = upgrade_command(
400
+ include_prereleases=True if update_needs_prereleases else None,
401
+ version=latest if update_needs_prereleases else None,
402
+ )
403
+ console.print(
404
+ f"[bold yellow]Warning:[/bold yellow] Skipping automatic update to "
405
+ f"v{latest} after a recent failed attempt. Update manually: "
406
+ f"[cyan]{cmd}[/cyan]\nContinuing with v{cli_version}.",
407
+ highlight=False,
408
+ )
409
+ return
410
+ if should_announce_auto_update_default():
411
+ # First-run consent/migration: auto-update is on only because of the
412
+ # opt-out default, not an explicit choice. Announce it once and skip
413
+ # this install so the user can opt out before anything runs.
414
+ #
415
+ # Mark *before* printing so a `console.print` failure cannot leave
416
+ # the notice un-acknowledged and re-firing forever. The inverse risk
417
+ # (mark succeeds, print fails, user never sees it) requires a broken
418
+ # console and is the lesser evil versus an unbounded re-nag.
419
+ acknowledged = mark_auto_update_default_acknowledged()
420
+ message = (
421
+ "[bold]dcode now updates automatically by default.[/bold] "
422
+ f"v{latest} will be installed on the next launch.\n"
423
+ "To opt out, set [cyan][update].auto_update = false[/cyan] in "
424
+ "config.toml or [cyan]DEEPAGENTS_CODE_AUTO_UPDATE=0[/cyan] "
425
+ "(or run [cyan]dcode --auto-update[/cyan] to toggle it off now).\n"
426
+ f"Continuing with v{cli_version} for now."
427
+ )
428
+ if not acknowledged:
429
+ # The acknowledgement could not be persisted (e.g. a read-only
430
+ # state dir). Without this note the identical notice would
431
+ # reappear every launch with no explanation.
432
+ message += (
433
+ "\n[yellow]Note:[/yellow] this acknowledgement could not be "
434
+ "saved, so this message may appear again until you opt out "
435
+ "or the state directory becomes writable."
436
+ )
437
+ console.print(message, highlight=False)
438
+ return
439
+ release_age = format_release_age_parenthetical(latest)
440
+ console.print(
441
+ f"Updating dcode from v{cli_version} to v{latest}{release_age}..."
442
+ )
443
+ if os.environ.get(DEBUG_UPDATE):
444
+ console.print("Skipped update install (debug mode).", style="dim")
445
+ return
446
+ log_path = create_update_log_path()
447
+ console.print(
448
+ f"Update log: {_tail_log_command(log_path)}",
449
+ style="dim",
450
+ highlight=False,
451
+ markup=False,
452
+ )
453
+ pending_failure_version = latest
454
+ success, output = asyncio.run(
455
+ perform_upgrade(log_path=log_path, target_version=latest)
456
+ )
457
+ if success:
458
+ pending_failure_version = None
459
+ clear_startup_auto_update_failure(latest)
460
+ # If a stale `dcode` is earlier on PATH, the auto-restart would
461
+ # re-exec into the old binary and the user would silently keep
462
+ # running the pre-upgrade version. Detect that *before* the
463
+ # re-exec so the warning isn't immediately wiped by the new
464
+ # process's startup, and skip the restart — re-exec'ing into
465
+ # an unchanged version would also trip the `restarted_for`
466
+ # no-op loop guard on the next launch. Use the never-raises
467
+ # wrapper so a detector defect can't crash startup after an
468
+ # otherwise-successful upgrade.
469
+ shadow = detect_shadowed_dcode_safe()
470
+ if shadow is not None:
471
+ # The warning embeds filesystem paths from `shutil.which`,
472
+ # which can legally contain `[` (macOS/Linux). With
473
+ # `markup=True` those would be parsed as Rich style tags,
474
+ # so escape the warning before interpolation; the sibling
475
+ # auto-update failure branch at the bottom of this function
476
+ # escapes its uv output the same way.
477
+ warning = format_shadowed_dcode_warning(shadow)
478
+ console.print(
479
+ f"[bold yellow]Warning:[/bold yellow] {escape(warning)}\n"
480
+ f"Continuing with v{cli_version}.",
481
+ highlight=False,
482
+ markup=True,
483
+ )
484
+ return
485
+ console.print(
486
+ f"[green]Updated to v{latest}. Launching...[/green]",
487
+ highlight=False,
488
+ )
489
+ # Record the target version so the re-exec'd process can detect a
490
+ # no-op upgrade and break the loop (see the `restarted_for` guard).
491
+ os.environ[RESTARTED_AFTER_UPDATE] = latest
492
+ try:
493
+ _restart_current_process()
494
+ except (OSError, RuntimeError):
495
+ # Upgrade succeeded but the re-exec did not happen (`os.execv`
496
+ # raised, or returned unexpectedly). Drop the sentinel and
497
+ # continue on the old in-memory code; the user must restart
498
+ # manually to load the new version.
499
+ os.environ.pop(RESTARTED_AFTER_UPDATE, None)
500
+ logger.warning("Restart after update failed", exc_info=True)
501
+ console.print(
502
+ f"[bold yellow]Warning:[/bold yellow] Updated to v{latest} but "
503
+ "the automatic restart failed. Restart dcode manually to use "
504
+ "the new version.",
505
+ highlight=False,
506
+ )
507
+ return
508
+ persisted = mark_startup_auto_update_failed(latest)
509
+ update_needs_prereleases = release_requires_prereleases(latest)
510
+ cmd = upgrade_command(
511
+ include_prereleases=True if update_needs_prereleases else None,
512
+ version=latest if update_needs_prereleases else None,
513
+ )
514
+ detail = f": {escape(output[:200])}" if output else ""
515
+ message = (
516
+ f"[bold red]Auto-update failed{detail}[/bold red]\n"
517
+ f"Run manually: [cyan]{cmd}[/cyan]\n"
518
+ f"Continuing with v{cli_version}."
519
+ )
520
+ if not persisted:
521
+ # The cooldown marker could not be saved (e.g. a read-only state
522
+ # dir), so this same failing upgrade would otherwise be retried on
523
+ # every launch. Surface it rather than silently re-stalling, the
524
+ # way the consent-announce path surfaces its un-persisted state.
525
+ message += _UNPERSISTED_AUTO_UPDATE_FAILURE_NOTE
526
+ console.print(message, markup=True, highlight=False)
527
+ except SystemExit:
528
+ # Process replacement (and test doubles that simulate it) must not be
529
+ # swallowed by the fail-soft handler below.
530
+ raise
531
+ except Exception:
532
+ logger.warning("Startup auto-update failed", exc_info=True)
533
+ message = (
534
+ "[bold yellow]Warning:[/bold yellow] Auto-update failed before startup; "
535
+ "continuing with the installed version."
536
+ )
537
+ if pending_failure_version is not None:
538
+ # An exception escaped the upgrade attempt itself (not a returned
539
+ # failure), so the returned-failure branch never marked it. Record
540
+ # the cooldown here so the same target is not retried every launch.
541
+ persisted = mark_startup_auto_update_failed(pending_failure_version)
542
+ if not persisted:
543
+ message += _UNPERSISTED_AUTO_UPDATE_FAILURE_NOTE
544
+ console.print(message, markup=True, highlight=False)
545
+
546
+
547
+ def _resolve_agent_arg(args: argparse.Namespace) -> str:
548
+ """Resolve the final agent identifier from parsed CLI args.
549
+
550
+ Precedence, highest first:
551
+
552
+ 1. Explicit `-a <name>` (stored as `args.agent` by argparse).
553
+ 2. `-r <thread>` is present → use `DEFAULT_AGENT_NAME`. The real agent is
554
+ inferred later by `_resolve_resume_thread` via thread metadata
555
+ (`get_thread_agent`), so we must NOT pre-seed a stored agent here or
556
+ it would suppress that inference.
557
+ 3. `[agents].default` from config — the user's intentional sticky
558
+ default (set via Ctrl+S in the `/agents` picker).
559
+ 4. `[agents].recent` from config — the most recently switched-to agent.
560
+ 5. `DEFAULT_AGENT_NAME` as the final fallback.
561
+
562
+ Both `default` and `recent` are gated by `_recent_agent_is_valid` so a
563
+ stale entry pointing at a deleted agent directory is ignored.
564
+
565
+ Extracted from the `cli_main` body so it's unit-testable without
566
+ constructing the full arg tree.
567
+
568
+ Args:
569
+ args: Parsed argparse namespace from `parse_args()`.
570
+
571
+ Returns:
572
+ The agent identifier to hand downstream.
573
+ """
574
+ from deepagents_code._constants import DEFAULT_AGENT_NAME
575
+
576
+ if args.agent is not None:
577
+ return args.agent
578
+ if getattr(args, "resume_thread", None) is not None:
579
+ return DEFAULT_AGENT_NAME
580
+
581
+ from deepagents_code.model_config import load_default_agent, load_recent_agent
582
+
583
+ default = load_default_agent()
584
+ if default and _recent_agent_is_valid(default):
585
+ return default
586
+
587
+ recent = load_recent_agent()
588
+ if recent and _recent_agent_is_valid(recent):
589
+ return recent
590
+ return DEFAULT_AGENT_NAME
591
+
592
+
593
+ def _normalize_cwd_filter(cwd: str | None) -> str | None:
594
+ """Normalize the `threads list --cwd` filter for metadata matching.
595
+
596
+ Storage uses `str(Path.cwd())` (absolute, no symlink resolution — see
597
+ `build_run_metadata`). We mirror that here with lexical normalization
598
+ rather than `.resolve()` so a user invoking from a symlinked path doesn't
599
+ get an empty result set due to symlink normalization.
600
+
601
+ Args:
602
+ cwd: Parsed `--cwd` value. An empty string means the flag was passed
603
+ without a value and should use the current working directory.
604
+
605
+ Returns:
606
+ Absolute path string for filtering, or `None` when no filter was
607
+ requested. Returns `None` if the current working directory cannot
608
+ be determined (bare `--cwd` only).
609
+ """
610
+ if cwd is None:
611
+ return None
612
+ if cwd == "": # noqa: PLC1901
613
+ try:
614
+ return str(Path.cwd())
615
+ except OSError:
616
+ logger.warning(
617
+ "Could not determine working directory for --cwd; "
618
+ "no cwd filter will be applied",
619
+ exc_info=True,
620
+ )
621
+ return None
622
+ return os.path.normpath(str(Path(cwd).expanduser().absolute()))
623
+
624
+
625
+ def _parse_interpreter_tools_flag(
626
+ raw: str | None,
627
+ ) -> str | list[str] | None:
628
+ """Parse the `--interpreter-tools` argument into the PTC option shape.
629
+
630
+ Args:
631
+ raw: Argparse value: `None` (flag absent), `"safe"`, `"all"`, or a
632
+ comma-separated list of tool names.
633
+
634
+ Returns:
635
+ `None` when the flag is absent, the literal string `"safe"`/`"all"`,
636
+ or a list of trimmed tool names. The list may contain `"safe"` as an
637
+ expandable preset (e.g. `"safe,task"` → `["safe", "task"]`).
638
+
639
+ Calls `sys.exit(2)` when the value is empty, contains only blank
640
+ tokens, or includes `"all"` inside a list — the CLI treats those as
641
+ usage errors.
642
+ """
643
+ if raw is None:
644
+ return None
645
+ text = raw.strip()
646
+ if not text:
647
+ sys.stderr.write(
648
+ "Error: --interpreter-tools requires a value: 'safe', 'all', or a "
649
+ "comma-separated list of tool names.\n"
650
+ )
651
+ sys.exit(2)
652
+ normalized = text.lower()
653
+ if normalized in {"safe", "all"}:
654
+ return normalized
655
+ names = [token.strip() for token in text.split(",") if token.strip()]
656
+ if not names:
657
+ sys.stderr.write(
658
+ "Error: --interpreter-tools list must contain at least one "
659
+ "non-empty tool name.\n"
660
+ )
661
+ sys.exit(2)
662
+ if any(name.lower() == "all" for name in names):
663
+ sys.stderr.write(
664
+ "Error: --interpreter-tools 'all' cannot be combined with other "
665
+ "tools; use 'all' on its own or list explicit tool names "
666
+ "(optionally with the 'safe' preset).\n"
667
+ )
668
+ sys.exit(2)
669
+ return names
670
+
671
+
672
+ def _resolve_interpreter_enabled(args: argparse.Namespace) -> bool:
673
+ """Return whether the JS interpreter should run for these CLI args.
674
+
675
+ Delegates to `_resolve_enable_interpreter` so the CLI pre-flight gate and the
676
+ stored `ServerConfig` share one resolution rule and cannot drift. The default
677
+ comes from `[interpreter].enable_interpreter` in local mode and is disabled
678
+ for remote sandboxes, where `CodeInterpreterMiddleware` is unsupported;
679
+ explicit `--interpreter`/`--no-interpreter` overrides the default.
680
+
681
+ `args.sandbox` is already normalized by `parse_args` (the bare-flag sentinel
682
+ is resolved to a provider name), so the resolver sees the concrete value.
683
+ """
684
+ from deepagents_code._server_config import _resolve_enable_interpreter
685
+
686
+ return _resolve_enable_interpreter(args.interpreter, args.sandbox)
687
+
688
+
689
+ def _resolve_auto_approve(args: argparse.Namespace) -> bool:
690
+ """Return whether the interactive TUI should auto-approve tool calls.
691
+
692
+ Headless mode uses `--shell-allow-list` instead and never calls this resolver.
693
+ An explicit `-y`/`--auto-approve` wins; when the flag is omitted
694
+ (`args.auto_approve is None`), the persistent `[startup].mode` config
695
+ default decides — `dangerously-auto` enables auto-approval, anything else
696
+ (including missing/invalid config) keeps human-in-the-loop approvals on.
697
+
698
+ Extracted from the `cli_main` body so it is unit-testable without
699
+ constructing the full arg tree, matching `_resolve_interpreter_enabled`.
700
+ """
701
+ if args.auto_approve is not None:
702
+ return args.auto_approve
703
+ from deepagents_code.model_config import (
704
+ STARTUP_MODE_DANGEROUSLY_AUTO,
705
+ load_startup_mode,
706
+ )
707
+
708
+ return load_startup_mode() == STARTUP_MODE_DANGEROUSLY_AUTO
709
+
710
+
711
+ def _warn_if_interpreter_disabled_by_sandbox(args: argparse.Namespace) -> None:
712
+ """Warn that a remote sandbox suppressed the otherwise-default interpreter.
713
+
714
+ With `js_eval` on by default in local mode, a `--sandbox` run silently drops
715
+ it (the middleware is unsupported under a remote sandbox). This prints to
716
+ stderr on the non-interactive (`-n`) path; the interactive TUI surfaces the
717
+ same advisory as a startup notification (see
718
+ `DeepAgentsApp._notify_interpreter_disabled_by_sandbox`).
719
+
720
+ Keyed on the raw `args.interpreter` tri-state so an explicit
721
+ `--no-interpreter` opt-out stays silent (the predicate only fires for the
722
+ unset default).
723
+ """
724
+ from deepagents_code._server_config import _interpreter_suppressed_by_sandbox
725
+ from deepagents_code.config import settings
726
+
727
+ if not _interpreter_suppressed_by_sandbox(
728
+ enable_interpreter=args.interpreter,
729
+ sandbox_type=args.sandbox,
730
+ local_default=settings.enable_interpreter,
731
+ ):
732
+ return
733
+ from rich.console import Console as _Console
734
+
735
+ _Console(stderr=True).print(
736
+ "[yellow]Warning:[/yellow] JS interpreter (`js_eval`) is unavailable "
737
+ "under a remote sandbox; it runs in local mode only."
738
+ )
739
+
740
+
741
+ def _resolve_rubric_text(rubric: str | None) -> str | None:
742
+ """Resolve the rubric from `--rubric` into one string.
743
+
744
+ `--rubric` accepts literal text, or `@path` to read a file. File paths
745
+ may be absolute, relative to the `dcode` process working directory, or
746
+ `~`-expanded home paths.
747
+
748
+ Args:
749
+ rubric: Value of `--rubric` (literal text or `@path`), or `None`.
750
+
751
+ Returns:
752
+ The resolved rubric text, or `None` when the flag was not supplied.
753
+
754
+ Raises:
755
+ ValueError: If the rubric is empty, or a referenced file is missing,
756
+ unreadable, or empty.
757
+ """
758
+ if rubric is None:
759
+ return None
760
+
761
+ # An `@`-prefixed value is always read as a file path. The path may be
762
+ # absolute, relative to the `dcode` process working directory, or `~`-based.
763
+ # There is no way to pass a literal rubric that begins with `@` (put such
764
+ # text in a file).
765
+ if rubric.startswith("@"):
766
+ path = rubric[1:]
767
+ try:
768
+ text = Path(path).expanduser().read_text(encoding="utf-8")
769
+ except (OSError, UnicodeError) as exc:
770
+ # `UnicodeError` (e.g. `UnicodeDecodeError`) subclasses `ValueError`,
771
+ # not `OSError`. Catch it here so a binary/non-UTF-8 file yields the
772
+ # framed "Could not read rubric file" message instead of a raw codec
773
+ # error.
774
+ msg = f"Could not read rubric file {path!r}: {exc}."
775
+ raise ValueError(msg) from exc
776
+ if not text.strip():
777
+ msg = f"Rubric file {path!r} is empty."
778
+ raise ValueError(msg)
779
+ return text.strip()
780
+
781
+ if not rubric.strip():
782
+ msg = "--rubric must not be empty."
783
+ raise ValueError(msg)
784
+ return rubric.strip()
785
+
786
+
787
+ def _warn_if_interpreter_tools_without_interpreter(
788
+ args: argparse.Namespace, *, enable_interpreter: bool
789
+ ) -> None:
790
+ """Warn that `--interpreter-tools` is a no-op without the interpreter.
791
+
792
+ This drives the non-interactive (`-n`) path and prints to stderr. The
793
+ interactive TUI surfaces the same advisory as a startup notification (see
794
+ `DeepAgentsApp._notify_interpreter_tools_without_interpreter`).
795
+
796
+ Attributes are accessed directly (not via `getattr` defaults) so an argparse
797
+ `dest` rename fails loudly in tests rather than silently disabling the warning.
798
+ """
799
+ if args.interpreter_tools is None:
800
+ return
801
+ if enable_interpreter:
802
+ return
803
+ from rich.console import Console as _Console
804
+
805
+ _Console(stderr=True).print(
806
+ "[yellow]Warning:[/yellow] --interpreter-tools has no effect "
807
+ "when the interpreter is disabled."
808
+ )
809
+
810
+
811
+ def _recent_agent_is_valid(name: str) -> bool:
812
+ """Return `True` when `~/.deepagents/<name>/` still exists on disk.
813
+
814
+ Used to guard against a stale `[agents].recent` entry pointing at an
815
+ agent the user has since deleted — in that case we silently fall back
816
+ to the hard-coded default instead of failing at server start.
817
+
818
+ Path is rebuilt from `Path.home()` rather than `settings.user_deepagents_dir`
819
+ because `settings` is intentionally imported *after* argparse in `cli_main`
820
+ (per the startup-hot-path guidance there), and pulling it in here would
821
+ undo that deferral.
822
+
823
+ `is_dir()` is wrapped in `try/except OSError` so permission errors on
824
+ `~/.deepagents` (symlink loops, EACCES) don't crash the launch — we
825
+ treat them the same as "not valid" and fall back to the default.
826
+ """
827
+ from pathlib import Path as _Path
828
+
829
+ try:
830
+ return (_Path.home() / ".zjcode" / name).is_dir()
831
+ except OSError:
832
+ logger.warning(
833
+ "Could not validate recent agent %r; falling back to default",
834
+ name,
835
+ exc_info=True,
836
+ )
837
+ return False
838
+
839
+
840
+ def check_cli_dependencies() -> None:
841
+ """Check if optional dependencies are installed."""
842
+ missing = []
843
+
844
+ if importlib.util.find_spec("requests") is None:
845
+ missing.append("requests")
846
+
847
+ if importlib.util.find_spec("dotenv") is None:
848
+ missing.append("python-dotenv")
849
+
850
+ if importlib.util.find_spec("tavily") is None:
851
+ missing.append("tavily-python")
852
+
853
+ if importlib.util.find_spec("textual") is None:
854
+ missing.append("textual")
855
+
856
+ if missing:
857
+ print("\nMissing required dependencies!") # noqa: T201 # App output for missing dependencies
858
+ print("\nThe following packages are required to use dcode:") # noqa: T201 # App output for missing dependencies
859
+ for pkg in missing:
860
+ print(f" - {pkg}") # noqa: T201 # CLI output for missing dependencies
861
+ print("\nReinstall dcode with the recommended installer:") # noqa: T201 # CLI output for missing dependencies
862
+ print(" curl -LsSf https://langch.in/dcode | bash") # noqa: T201 # CLI output for missing dependencies
863
+ print("\nOr install the tool directly via uv:") # noqa: T201 # CLI output for missing dependencies
864
+ print(f" uv tool install -U {DISTRIBUTION_NAME}") # noqa: T201 # CLI output for missing dependencies
865
+ sys.exit(1)
866
+
867
+
868
+ _RIPGREP_URL = "https://github.com/BurntSushi/ripgrep#installation"
869
+ """Fallback installation URL when no platform package manager is detected."""
870
+
871
+ _SUPPRESS_HINT_CLI = (
872
+ 'To suppress, edit ~/.deepagents/config.toml:\n\\[warnings]\nsuppress = \\["<key>"]'
873
+ )
874
+ """Suppression hint for non-interactive output.
875
+
876
+ Contains a `<key>` placeholder that callers replace with the warning key
877
+ (e.g. `"ripgrep"`, `"tavily"`).
878
+ """
879
+
880
+
881
+ def _ripgrep_install_hint() -> str:
882
+ """Return a platform-specific install command for ripgrep.
883
+
884
+ Falls back to the GitHub URL when the platform isn't recognized.
885
+ """
886
+ plat = sys.platform
887
+ if plat == "darwin":
888
+ if shutil.which("brew"):
889
+ return "brew install ripgrep"
890
+ if shutil.which("port"):
891
+ return "sudo port install ripgrep"
892
+ elif plat == "linux":
893
+ if shutil.which("apt-get"):
894
+ return "sudo apt-get install ripgrep"
895
+ if shutil.which("dnf"):
896
+ return "sudo dnf install ripgrep"
897
+ if shutil.which("pacman"):
898
+ return "sudo pacman -S ripgrep"
899
+ if shutil.which("zypper"):
900
+ return "sudo zypper install ripgrep"
901
+ if shutil.which("apk"):
902
+ return "sudo apk add ripgrep"
903
+ if shutil.which("nix-env"):
904
+ return "nix-env -iA nixpkgs.ripgrep"
905
+ elif plat == "win32":
906
+ if shutil.which("choco"):
907
+ return "choco install ripgrep"
908
+ if shutil.which("scoop"):
909
+ return "scoop install ripgrep"
910
+ if shutil.which("winget"):
911
+ return "winget install BurntSushi.ripgrep"
912
+ # Cross-platform fallbacks
913
+ if shutil.which("cargo"):
914
+ return "cargo install ripgrep"
915
+ if shutil.which("conda"):
916
+ return "conda install -c conda-forge ripgrep"
917
+ return _RIPGREP_URL
918
+
919
+
920
+ def _is_managed_ripgrep_path(path: str | None) -> bool:
921
+ """Return whether `path` points at the managed `rg` binary."""
922
+ if path is None:
923
+ return False
924
+
925
+ from deepagents_code.managed_tools import managed_rg_path
926
+
927
+ managed = managed_rg_path()
928
+ return os.path.normcase(str(Path(path).resolve())) == os.path.normcase(
929
+ str(managed.resolve())
930
+ )
931
+
932
+
933
+ def _should_ensure_managed_ripgrep() -> bool:
934
+ """Return whether startup should validate or install managed ripgrep."""
935
+ rg_path = shutil.which("rg")
936
+ return rg_path is None or _is_managed_ripgrep_path(rg_path)
937
+
938
+
939
+ def check_optional_tools(*, config_path: Path | None = None) -> list[str]:
940
+ """Check for recommended external tools and return missing tool names.
941
+
942
+ Skips tools that the user has suppressed via
943
+ `[warnings].suppress` in `config.toml`.
944
+
945
+ Args:
946
+ config_path: Path to config file.
947
+
948
+ Defaults to `~/.deepagents/config.toml`.
949
+
950
+ Returns:
951
+ List of missing tool names (e.g. `["ripgrep"]`).
952
+ """
953
+ from deepagents_code.model_config import is_warning_suppressed
954
+
955
+ missing: list[str] = []
956
+ if _should_ensure_managed_ripgrep() and not is_warning_suppressed(
957
+ "ripgrep", config_path
958
+ ):
959
+ missing.append("ripgrep")
960
+
961
+ from deepagents_code.config import settings
962
+
963
+ if not settings.has_tavily and not is_warning_suppressed("tavily", config_path):
964
+ missing.append("tavily")
965
+
966
+ return missing
967
+
968
+
969
+ def _auto_install_ripgrep_cli(
970
+ warn_console: "Console", missing_tools: list[str]
971
+ ) -> list[str]:
972
+ """Attempt the one-shot managed `rg` install for the headless CLI path.
973
+
974
+ Mirrors the interactive `DeepAgentsApp._ensure_managed_ripgrep` flow for
975
+ the non-interactive launch, where there is no Textual app to surface
976
+ notices through. A checksum mismatch is reported loudly and a generic
977
+ failure as a warning; both leave `"ripgrep"` in the returned list so the
978
+ caller still prints the standard missing-tool notice and the slow Python
979
+ fallback is used.
980
+
981
+ Args:
982
+ warn_console: `rich` console bound to stderr for user-facing notices.
983
+ missing_tools: Tool names reported missing by `check_optional_tools`.
984
+
985
+ Returns:
986
+ `missing_tools` with `"ripgrep"` removed once a usable `rg` is
987
+ resolved — the managed binary (with `BIN_DIR` prepended to `PATH`) or a
988
+ system `rg` already on `PATH` — otherwise the list unchanged.
989
+ """
990
+ from deepagents_code.managed_tools import (
991
+ ChecksumMismatchError,
992
+ ManagedToolUnavailableError,
993
+ ensure_ripgrep,
994
+ managed_rg_path,
995
+ prepend_managed_bin_to_path,
996
+ )
997
+
998
+ warn_console.print("Installing ripgrep...")
999
+ try:
1000
+ installed = asyncio.run(ensure_ripgrep())
1001
+ except ChecksumMismatchError:
1002
+ logger.exception(
1003
+ "ripgrep auto-install aborted: SHA-256 mismatch on downloaded archive"
1004
+ )
1005
+ warn_console.print(
1006
+ "[bold red]Error:[/bold red] ripgrep auto-install aborted: downloaded "
1007
+ "archive failed SHA-256 verification. Refusing to install."
1008
+ )
1009
+ return missing_tools
1010
+ except ManagedToolUnavailableError as exc:
1011
+ logger.info("ripgrep auto-install unavailable: %s", exc.reason)
1012
+ warn_console.print(f"[yellow]Warning:[/yellow] {exc.message}")
1013
+ return missing_tools
1014
+ except Exception:
1015
+ logger.warning("ripgrep auto-install failed unexpectedly", exc_info=True)
1016
+ warn_console.print(
1017
+ "[yellow]Warning:[/yellow] ripgrep auto-install failed unexpectedly "
1018
+ "— see logs."
1019
+ )
1020
+ return missing_tools
1021
+
1022
+ if installed is None:
1023
+ return missing_tools
1024
+
1025
+ if installed == managed_rg_path():
1026
+ prepend_managed_bin_to_path()
1027
+ return [tool for tool in missing_tools if tool != "ripgrep"]
1028
+
1029
+
1030
+ def build_missing_tool_notification(tool: str) -> "PendingNotification":
1031
+ """Build a `PendingNotification` for a missing optional tool.
1032
+
1033
+ The returned entry carries the install hint (or URL) in a typed payload so
1034
+ the notification center action handler can copy it / open it without
1035
+ re-running platform detection.
1036
+
1037
+ Args:
1038
+ tool: Name of the missing tool (e.g. `"ripgrep"`, `"tavily"`).
1039
+
1040
+ Returns:
1041
+ A registry entry ready for `NotificationRegistry.add`.
1042
+ """
1043
+ # Deferred import: keeps `--version` and other hot-path commands off the
1044
+ # `deepagents_code.notifications` -> `dataclasses`/`logging` chain.
1045
+ from deepagents_code.notifications import (
1046
+ ActionId,
1047
+ MissingDepPayload,
1048
+ NotificationAction,
1049
+ PendingNotification,
1050
+ )
1051
+
1052
+ suppress_action = NotificationAction(
1053
+ ActionId.SUPPRESS, "Don't show notification again"
1054
+ )
1055
+ if tool == "ripgrep":
1056
+ hint = _ripgrep_install_hint()
1057
+ if hint.startswith("http"):
1058
+ actions: tuple[NotificationAction, ...] = (
1059
+ NotificationAction(
1060
+ ActionId.OPEN_WEBSITE, "Open installation guide", primary=True
1061
+ ),
1062
+ suppress_action,
1063
+ )
1064
+ payload = MissingDepPayload(tool="ripgrep", url=hint)
1065
+ else:
1066
+ actions = (
1067
+ NotificationAction(
1068
+ ActionId.COPY_INSTALL, "Copy install command", primary=True
1069
+ ),
1070
+ NotificationAction(ActionId.OPEN_WEBSITE, "Open installation guide"),
1071
+ suppress_action,
1072
+ )
1073
+ payload = MissingDepPayload(
1074
+ tool="ripgrep", install_command=hint, url=_RIPGREP_URL
1075
+ )
1076
+ body = (
1077
+ "ripgrep is not installed; the grep tool will use a slower fallback.\n\n"
1078
+ f"Install: {hint}"
1079
+ )
1080
+ return PendingNotification(
1081
+ key="dep:ripgrep",
1082
+ title="ripgrep is not installed",
1083
+ body=body,
1084
+ actions=actions,
1085
+ payload=payload,
1086
+ )
1087
+ if tool == "tavily":
1088
+ return PendingNotification(
1089
+ key="dep:tavily",
1090
+ title="Web search disabled",
1091
+ body=("Add a Tavily API key to enable web search."),
1092
+ actions=(
1093
+ NotificationAction(
1094
+ ActionId.ENTER_API_KEY, "Enter API key", primary=True
1095
+ ),
1096
+ NotificationAction(ActionId.OPEN_WEBSITE, "Open tavily.com"),
1097
+ suppress_action,
1098
+ ),
1099
+ payload=MissingDepPayload(tool="tavily", url="https://tavily.com"),
1100
+ )
1101
+ logger.warning("No install hint configured for tool %r", tool)
1102
+ return PendingNotification(
1103
+ key=f"dep:{tool}",
1104
+ title=f"{tool} is not installed",
1105
+ body=f"{tool} is not installed.",
1106
+ actions=(
1107
+ NotificationAction(
1108
+ ActionId.SUPPRESS, "Don't show notification again", primary=True
1109
+ ),
1110
+ ),
1111
+ payload=MissingDepPayload(tool=tool),
1112
+ )
1113
+
1114
+
1115
+ def format_tool_warning_cli(tool: str) -> str:
1116
+ """Format a missing-tool warning for non-interactive console output.
1117
+
1118
+ Args:
1119
+ tool: Name of the missing tool.
1120
+
1121
+ Returns:
1122
+ Warning string suitable for `console.print`.
1123
+ """
1124
+ if tool == "ripgrep":
1125
+ hint = _ripgrep_install_hint()
1126
+ if hint.startswith("http"):
1127
+ hint = f"[link={hint}]{hint}[/link]"
1128
+ suppress = _SUPPRESS_HINT_CLI.replace("<key>", "ripgrep")
1129
+ return (
1130
+ "ripgrep is not installed; the grep tool will use a slower fallback.\n"
1131
+ f"Install: {hint}\n\n"
1132
+ f"{suppress}\n"
1133
+ )
1134
+ if tool == "tavily":
1135
+ url = "https://tavily.com"
1136
+ suppress = _SUPPRESS_HINT_CLI.replace("<key>", "tavily")
1137
+ return (
1138
+ "Web search is disabled \u2014 TAVILY_API_KEY is not set.\n"
1139
+ f"Get a key at [link={url}]{url}[/link]\n\n"
1140
+ f"{suppress}\n"
1141
+ )
1142
+ return f"{tool} is not installed."
1143
+
1144
+
1145
+ async def _preload_session_mcp_server_info(
1146
+ *,
1147
+ mcp_config_path: str | None,
1148
+ no_mcp: bool,
1149
+ trust_project_mcp: bool | None,
1150
+ ) -> list["MCPServerInfo"] | None:
1151
+ """Load MCP metadata for the interactive TUI in server mode.
1152
+
1153
+ In server mode the actual MCP tools are created inside the LangGraph server
1154
+ process, but the local Textual app still needs MCP metadata for the welcome
1155
+ banner and `/mcp` viewer. This preloads the metadata in the app process and
1156
+ immediately cleans up any temporary MCP sessions it opened.
1157
+
1158
+ Args:
1159
+ mcp_config_path: Optional explicit MCP config path.
1160
+ no_mcp: Whether MCP loading is disabled.
1161
+ trust_project_mcp: Project-level MCP trust decision.
1162
+
1163
+ Returns:
1164
+ MCP server metadata for the TUI, or `None` when MCP is disabled.
1165
+ """
1166
+ if no_mcp:
1167
+ return None
1168
+
1169
+ from deepagents_code.mcp_tools import resolve_and_load_mcp_tools
1170
+ from deepagents_code.project_utils import ProjectContext
1171
+
1172
+ session_manager = None
1173
+ try:
1174
+ try:
1175
+ project_context = ProjectContext.from_user_cwd(Path.cwd())
1176
+ except OSError:
1177
+ logger.warning("Could not determine working directory for MCP preload")
1178
+ project_context = None
1179
+ _tools, session_manager, server_info = await resolve_and_load_mcp_tools(
1180
+ explicit_config_path=mcp_config_path,
1181
+ no_mcp=no_mcp,
1182
+ trust_project_mcp=trust_project_mcp,
1183
+ project_context=project_context,
1184
+ )
1185
+ return server_info
1186
+ finally:
1187
+ if session_manager is not None:
1188
+ try:
1189
+ await session_manager.cleanup()
1190
+ except Exception:
1191
+ logger.warning(
1192
+ "MCP metadata preload cleanup failed",
1193
+ exc_info=True,
1194
+ )
1195
+
1196
+
1197
+ _HELP_SPECS: dict[str, tuple[str | None, str]] = {
1198
+ "help": (None, "show_help"),
1199
+ "agents": ("agents_command", "show_agents_help"),
1200
+ "skills": ("skills_command", "show_skills_help"),
1201
+ "threads": ("threads_command", "show_threads_help"),
1202
+ "mcp": ("mcp_command", "show_mcp_help"),
1203
+ "config": ("config_command", "show_config_help"),
1204
+ "auth": ("auth_command", "show_auth_help"),
1205
+ "tools": ("tools_command", "show_tools_help"),
1206
+ }
1207
+ """Maps top-level command names to their startup-fast-path help dispatch.
1208
+
1209
+ Each value is `(subcommand_dest, ui_help_fn_name)`:
1210
+
1211
+ - `subcommand_dest` is the argparse `dest=` for the group's sub-subparsers,
1212
+ or `None` for leaf commands like `help`. When non-`None` and the parsed
1213
+ namespace has a value at that attribute, a real subcommand was given and
1214
+ the fast path declines.
1215
+ - `ui_help_fn_name` is the attribute on `deepagents_code.ui` invoked to
1216
+ render the help screen.
1217
+
1218
+ When adding a new top-level command group with sub-subparsers, register it
1219
+ here and add a corresponding `show_<group>_help` to `ui.py`. The drift
1220
+ test in `tests/unit_tests/test_startup_fast_paths.py` enforces this.
1221
+ """
1222
+
1223
+
1224
+ def _show_bare_command_group_help(args: argparse.Namespace) -> bool:
1225
+ """Render help for `help` and bare command groups before the heavy bootstrap.
1226
+
1227
+ Short-circuits before `console`/`settings` are imported so help-only
1228
+ invocations stay snappy. Mirrors the dispatch in `cli_main` for the
1229
+ `help`, `agents`, `skills`, `threads`, `mcp`, `config`, `auth`, and `tools`
1230
+ commands when no subcommand was given.
1231
+
1232
+ Args:
1233
+ args: Namespace from `parse_args()`. Only `command` and the per-group
1234
+ `<group>_command` attributes are read; both may be absent.
1235
+
1236
+ Returns:
1237
+ `True` when help was rendered and the caller should exit; `False`
1238
+ when the command requires the full runtime path.
1239
+ """
1240
+ command = getattr(args, "command", None)
1241
+ if not isinstance(command, str):
1242
+ return False
1243
+ spec = _HELP_SPECS.get(command)
1244
+ if spec is None:
1245
+ return False
1246
+
1247
+ command_attr, help_fn_name = spec
1248
+ if command_attr is not None and getattr(args, command_attr, None) is not None:
1249
+ return False
1250
+
1251
+ from deepagents_code import ui
1252
+
1253
+ # 2-arg `getattr` is intentional: a missing/renamed `show_*_help` in
1254
+ # `ui.py` is a developer bug and should raise `AttributeError` loudly
1255
+ # rather than fall through to a silent no-op.
1256
+ getattr(ui, help_fn_name)()
1257
+ return True
1258
+
1259
+
1260
+ def parse_args() -> argparse.Namespace:
1261
+ """Parse command line arguments.
1262
+
1263
+ Returns:
1264
+ Parsed arguments namespace.
1265
+ """
1266
+ from deepagents_code._constants import DEFAULT_AGENT_NAME
1267
+ from deepagents_code.client.commands.auth import setup_auth_parser
1268
+ from deepagents_code.client.commands.config import setup_config_parser
1269
+ from deepagents_code.client.commands.mcp import setup_mcp_parsers
1270
+ from deepagents_code.output import add_json_output_arg
1271
+ from deepagents_code.skills import setup_skills_parser
1272
+
1273
+ # Factory that builds an argparse Action whose __call__ invokes the
1274
+ # supplied *help_fn* instead of argparse's default help text. Each
1275
+ # subcommand can pass its own Rich-formatted help screen so that
1276
+ # `deepagents <subcommand> -h` shows context-specific help.
1277
+ def _make_help_action(
1278
+ help_fn: Callable[[], None],
1279
+ ) -> type[argparse.Action]:
1280
+ """Create an argparse Action that displays *help_fn* and exits.
1281
+
1282
+ argparse requires a *class* (not a callable) for custom actions.
1283
+ This factory uses a closure: the returned `_ShowHelp` class captures
1284
+ *help_fn* from the enclosing scope so that each subcommand can wire `-h`
1285
+ to its own Rich help screen.
1286
+
1287
+ Args:
1288
+ help_fn: Callable that prints help text to the console.
1289
+
1290
+ Returns:
1291
+ An argparse Action class wired to the given help function.
1292
+ """
1293
+
1294
+ class _ShowHelp(argparse.Action):
1295
+ def __init__(
1296
+ self,
1297
+ option_strings: list[str],
1298
+ dest: str = argparse.SUPPRESS,
1299
+ default: str = argparse.SUPPRESS,
1300
+ **kwargs: Any,
1301
+ ) -> None:
1302
+ super().__init__(
1303
+ option_strings=option_strings,
1304
+ dest=dest,
1305
+ default=default,
1306
+ nargs=0,
1307
+ **kwargs,
1308
+ )
1309
+
1310
+ def __call__(
1311
+ self,
1312
+ parser: argparse.ArgumentParser,
1313
+ namespace: argparse.Namespace, # noqa: ARG002 # Required by argparse Action interface
1314
+ values: str | Sequence[Any] | None, # noqa: ARG002 # Required by argparse Action interface
1315
+ option_string: str | None = None, # noqa: ARG002 # Required by argparse Action interface
1316
+ ) -> None:
1317
+ with contextlib.suppress(BrokenPipeError):
1318
+ help_fn()
1319
+ parser.exit()
1320
+
1321
+ return _ShowHelp
1322
+
1323
+ # Lazy wrapper: defers `ui` import until the help action fires (i.e.,
1324
+ # only when the user passes `-h`). This avoids pulling in Rich and config at
1325
+ # parse time for the common non-help path.
1326
+ def _lazy_help(fn_name: str) -> Callable[[], None]:
1327
+ def _show() -> None:
1328
+ from deepagents_code import ui
1329
+
1330
+ getattr(ui, fn_name)()
1331
+
1332
+ return _show
1333
+
1334
+ def help_parent(help_fn: Callable[[], None]) -> list[argparse.ArgumentParser]:
1335
+ parent = argparse.ArgumentParser(add_help=False)
1336
+ parent.add_argument("-h", "--help", action=_make_help_action(help_fn))
1337
+ return [parent]
1338
+
1339
+ parser = argparse.ArgumentParser(
1340
+ description=("Deep Agents - AI Coding Assistant"),
1341
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1342
+ add_help=False,
1343
+ )
1344
+ subparsers = parser.add_subparsers(dest="command", help="Command to run")
1345
+
1346
+ subparsers.add_parser(
1347
+ "help",
1348
+ help="Show help information",
1349
+ add_help=False,
1350
+ parents=help_parent(_lazy_help("show_help")),
1351
+ )
1352
+
1353
+ agents_parser = subparsers.add_parser(
1354
+ "agents",
1355
+ help="Manage agents",
1356
+ add_help=False,
1357
+ parents=help_parent(_lazy_help("show_agents_help")),
1358
+ )
1359
+ add_json_output_arg(agents_parser)
1360
+ agents_sub = agents_parser.add_subparsers(dest="agents_command")
1361
+
1362
+ agents_list = agents_sub.add_parser(
1363
+ "list",
1364
+ aliases=["ls"],
1365
+ help="List all agents",
1366
+ add_help=False,
1367
+ parents=help_parent(_lazy_help("show_list_help")),
1368
+ )
1369
+ add_json_output_arg(agents_list)
1370
+
1371
+ agents_reset = agents_sub.add_parser(
1372
+ "reset",
1373
+ help="Reset an agent's prompt to default",
1374
+ add_help=False,
1375
+ parents=help_parent(_lazy_help("show_reset_help")),
1376
+ )
1377
+ add_json_output_arg(agents_reset)
1378
+ agents_reset.add_argument("--agent", required=True, help="Name of agent to reset")
1379
+ agents_reset.add_argument(
1380
+ "--target", dest="source_agent", help="Copy prompt from another agent"
1381
+ )
1382
+ agents_reset.add_argument(
1383
+ "--dry-run",
1384
+ action="store_true",
1385
+ help="Show what would happen without making changes",
1386
+ )
1387
+
1388
+ setup_skills_parser(
1389
+ subparsers,
1390
+ make_help_action=_make_help_action,
1391
+ add_output_args=add_json_output_arg,
1392
+ )
1393
+
1394
+ setup_mcp_parsers(
1395
+ subparsers,
1396
+ make_help_action=_make_help_action,
1397
+ )
1398
+
1399
+ setup_config_parser(
1400
+ subparsers,
1401
+ make_help_action=_make_help_action,
1402
+ add_output_args=add_json_output_arg,
1403
+ )
1404
+
1405
+ setup_auth_parser(
1406
+ subparsers,
1407
+ make_help_action=_make_help_action,
1408
+ )
1409
+
1410
+ threads_parser = subparsers.add_parser(
1411
+ "threads",
1412
+ help="Manage conversation threads",
1413
+ add_help=False,
1414
+ parents=help_parent(_lazy_help("show_threads_help")),
1415
+ )
1416
+ add_json_output_arg(threads_parser)
1417
+ threads_sub = threads_parser.add_subparsers(dest="threads_command")
1418
+
1419
+ threads_list = threads_sub.add_parser(
1420
+ "list",
1421
+ aliases=["ls"],
1422
+ help="List threads",
1423
+ add_help=False,
1424
+ parents=help_parent(_lazy_help("show_threads_list_help")),
1425
+ )
1426
+ add_json_output_arg(threads_list)
1427
+ threads_list.add_argument(
1428
+ "--agent", default=None, help="Filter by agent name (default: show all)"
1429
+ )
1430
+ threads_list.add_argument(
1431
+ "-n",
1432
+ "--limit",
1433
+ type=int,
1434
+ default=None,
1435
+ help="Max number of threads to display (default: 20)",
1436
+ )
1437
+ threads_list.add_argument(
1438
+ "--sort",
1439
+ choices=["created", "updated"],
1440
+ default=None,
1441
+ help="Sort threads by timestamp (default: from config, or updated)",
1442
+ )
1443
+ threads_list.add_argument(
1444
+ "--branch",
1445
+ default=None,
1446
+ help="Filter by git branch name",
1447
+ )
1448
+ threads_list.add_argument(
1449
+ "--cwd",
1450
+ nargs="?",
1451
+ const="",
1452
+ default=None,
1453
+ help=(
1454
+ "Filter by working directory. With no value, uses the current "
1455
+ "directory; pass a path to filter by that directory instead."
1456
+ ),
1457
+ )
1458
+ threads_list.add_argument(
1459
+ "-v",
1460
+ "--verbose",
1461
+ action="store_true",
1462
+ default=False,
1463
+ help="Show all columns (branch, created, prompt)",
1464
+ )
1465
+ threads_list.add_argument(
1466
+ "-r",
1467
+ "--relative",
1468
+ action=argparse.BooleanOptionalAction,
1469
+ default=None,
1470
+ help="Show timestamps as relative time (default: from config, or absolute)",
1471
+ )
1472
+ threads_delete = threads_sub.add_parser(
1473
+ "delete",
1474
+ help="Delete a thread",
1475
+ add_help=False,
1476
+ parents=help_parent(_lazy_help("show_threads_delete_help")),
1477
+ )
1478
+ add_json_output_arg(threads_delete)
1479
+ threads_delete.add_argument("thread_id", help="Thread ID to delete")
1480
+ threads_delete.add_argument(
1481
+ "--dry-run",
1482
+ action="store_true",
1483
+ help="Show what would happen without making changes",
1484
+ )
1485
+
1486
+ update_parser = subparsers.add_parser(
1487
+ "update",
1488
+ help="Check for and install updates",
1489
+ add_help=False,
1490
+ parents=help_parent(_lazy_help("show_update_help")),
1491
+ )
1492
+ update_parser.add_argument(
1493
+ "--prerelease",
1494
+ action="store_true",
1495
+ default=argparse.SUPPRESS,
1496
+ help="Include alpha/beta/rc releases when checking for updates",
1497
+ )
1498
+ add_json_output_arg(update_parser)
1499
+
1500
+ doctor_parser = subparsers.add_parser(
1501
+ "doctor",
1502
+ help="Print install health and diagnostics",
1503
+ add_help=False,
1504
+ parents=help_parent(_lazy_help("show_doctor_help")),
1505
+ )
1506
+ add_json_output_arg(doctor_parser)
1507
+
1508
+ tools_parser = subparsers.add_parser(
1509
+ "tools",
1510
+ help="Manage managed external tools (e.g. ripgrep)",
1511
+ add_help=False,
1512
+ parents=help_parent(_lazy_help("show_tools_help")),
1513
+ )
1514
+ add_json_output_arg(tools_parser)
1515
+ tools_sub = tools_parser.add_subparsers(dest="tools_command")
1516
+
1517
+ tools_install = tools_sub.add_parser(
1518
+ "install",
1519
+ help="Install or repair the managed ripgrep binary",
1520
+ add_help=False,
1521
+ parents=help_parent(_lazy_help("show_tools_install_help")),
1522
+ )
1523
+ add_json_output_arg(tools_install)
1524
+
1525
+ tools_list = tools_sub.add_parser(
1526
+ "list",
1527
+ help="List the tools available to the agent",
1528
+ add_help=False,
1529
+ parents=help_parent(_lazy_help("show_tools_list_help")),
1530
+ )
1531
+ add_json_output_arg(tools_list)
1532
+
1533
+ # Default interactive mode — argument order here determines the
1534
+ # usage line printed by argparse; keep in sync with ui.show_help().
1535
+ parser.add_argument(
1536
+ "-r",
1537
+ "--resume",
1538
+ dest="resume_thread",
1539
+ nargs="?",
1540
+ const="__MOST_RECENT__",
1541
+ default=None,
1542
+ metavar="ID",
1543
+ help="Resume thread: -r for most recent, -r <ID> for specific thread",
1544
+ )
1545
+
1546
+ parser.add_argument(
1547
+ "-a",
1548
+ "--agent",
1549
+ default=None,
1550
+ metavar="NAME",
1551
+ help=(
1552
+ "Agent to use. "
1553
+ "If omitted, falls back to [agents].default, then "
1554
+ "[agents].recent, then "
1555
+ f"the '{DEFAULT_AGENT_NAME}' built-in default."
1556
+ ),
1557
+ )
1558
+
1559
+ parser.add_argument(
1560
+ "-M",
1561
+ "--model",
1562
+ metavar="MODEL",
1563
+ help="Model to use (e.g., claude-opus-4-7, gpt-5.5). "
1564
+ "Provider is auto-detected from model name.",
1565
+ )
1566
+
1567
+ parser.add_argument(
1568
+ "--model-params",
1569
+ metavar="JSON",
1570
+ help="Extra kwargs to pass to the model as a JSON string "
1571
+ '(e.g., \'{"temperature": 0.7, "max_tokens": 4096}\'). '
1572
+ "These take priority, overriding config file values.",
1573
+ )
1574
+
1575
+ from deepagents_code.ui import non_negative_int, positive_int
1576
+
1577
+ parser.add_argument(
1578
+ "--max-retries",
1579
+ type=non_negative_int,
1580
+ default=None,
1581
+ metavar="N",
1582
+ help="Override max retries for transient model errors.",
1583
+ )
1584
+
1585
+ parser.add_argument(
1586
+ "--profile-override",
1587
+ metavar="JSON",
1588
+ help="Override model profile fields as a JSON string "
1589
+ "(e.g., '{\"max_input_tokens\": 4096}'). "
1590
+ "Merged on top of config file profile overrides.",
1591
+ )
1592
+
1593
+ parser.add_argument(
1594
+ "--default-model",
1595
+ metavar="MODEL",
1596
+ nargs="?",
1597
+ const="__SHOW__",
1598
+ default=None,
1599
+ help="Set the default model for future launches "
1600
+ "(e.g., anthropic:claude-opus-4-6). "
1601
+ "Use --default-model with no argument to show the current default. "
1602
+ "Use --clear-default-model to remove it.",
1603
+ )
1604
+
1605
+ parser.add_argument(
1606
+ "--clear-default-model",
1607
+ action="store_true",
1608
+ help="Clear the default model, falling back to recent model "
1609
+ "or environment auto-detection.",
1610
+ )
1611
+
1612
+ parser.add_argument(
1613
+ "-m",
1614
+ "--message",
1615
+ dest="initial_prompt",
1616
+ metavar="TEXT",
1617
+ help="Initial prompt to auto-submit when session starts",
1618
+ )
1619
+
1620
+ parser.add_argument(
1621
+ "-s",
1622
+ "--skill",
1623
+ dest="initial_skill",
1624
+ metavar="NAME",
1625
+ help="Invoke a skill when the interactive session starts",
1626
+ )
1627
+
1628
+ parser.add_argument(
1629
+ "--startup-cmd",
1630
+ dest="startup_cmd",
1631
+ metavar="CMD",
1632
+ help="Shell command to run at startup, before the first prompt "
1633
+ "(output shown, non-zero exit warns but does not abort)",
1634
+ )
1635
+
1636
+ parser.add_argument(
1637
+ "-n",
1638
+ "--non-interactive",
1639
+ dest="non_interactive_message",
1640
+ metavar="TEXT",
1641
+ help="Run a single task non-interactively and exit "
1642
+ "(shell disabled unless --shell-allow-list is set)",
1643
+ )
1644
+
1645
+ parser.add_argument(
1646
+ "-q",
1647
+ "--quiet",
1648
+ action="store_true",
1649
+ help="Clean output for piping — only the agent's response "
1650
+ "goes to stdout. Requires -n or piped stdin.",
1651
+ )
1652
+
1653
+ parser.add_argument(
1654
+ "--no-stream",
1655
+ dest="no_stream",
1656
+ action="store_true",
1657
+ help="Buffer the full response and write it to stdout at once "
1658
+ "instead of streaming token-by-token. Requires -n or piped stdin.",
1659
+ )
1660
+
1661
+ parser.add_argument(
1662
+ "--max-turns",
1663
+ dest="max_turns",
1664
+ type=positive_int,
1665
+ metavar="N",
1666
+ help="Maximum number of agentic turns before stopping (must be >= 1). "
1667
+ "Overrides the internal safety default. Useful for CI/CD pipelines "
1668
+ "to prevent runaway agents. Requires -n or piped stdin.",
1669
+ )
1670
+
1671
+ parser.add_argument(
1672
+ "--timeout",
1673
+ dest="timeout",
1674
+ type=positive_int,
1675
+ metavar="SECONDS",
1676
+ help="Hard wall-clock timeout in seconds. The agent is cancelled and "
1677
+ "the process exits with code 124 if the timeout is reached. "
1678
+ "Complements --max-turns (turn count) with a time-based limit; both "
1679
+ "use exit code 124 on expiry. Requires -n or piped stdin.",
1680
+ )
1681
+
1682
+ parser.add_argument(
1683
+ "--goal",
1684
+ dest="goal",
1685
+ metavar="TEXT",
1686
+ help="Goal objective to turn into acceptance criteria. Opens a review "
1687
+ "prompt on interactive launch, then runs the accepted goal as the first "
1688
+ "task.",
1689
+ )
1690
+ parser.add_argument(
1691
+ "--rubric",
1692
+ dest="rubric",
1693
+ metavar="TEXT|@PATH",
1694
+ help="Acceptance criteria the agent self-evaluates against, looping "
1695
+ "until satisfied. Accepts literal text or '@path' to read a file "
1696
+ "(relative to the current working directory; '~' supported). "
1697
+ "Requires -n or piped stdin.",
1698
+ )
1699
+ parser.add_argument(
1700
+ "--rubric-model",
1701
+ dest="rubric_model",
1702
+ metavar="MODEL",
1703
+ help="Model the rubric grader uses (e.g. anthropic:claude-sonnet-4-6). "
1704
+ "Defaults to the main agent model.",
1705
+ )
1706
+ parser.add_argument(
1707
+ "--rubric-max-iterations",
1708
+ dest="rubric_max_iterations",
1709
+ type=positive_int,
1710
+ metavar="N",
1711
+ help="Override grader iterations per rubric attempt before stopping "
1712
+ "(must be >= 1; defaults to the SDK setting).",
1713
+ )
1714
+
1715
+ parser.add_argument(
1716
+ "--stdin",
1717
+ action="store_true",
1718
+ help="Read input from stdin explicitly (instead of auto-detection)",
1719
+ )
1720
+
1721
+ add_json_output_arg(parser, default="text")
1722
+
1723
+ parser.add_argument(
1724
+ "-y",
1725
+ "--auto-approve",
1726
+ action="store_true",
1727
+ default=None,
1728
+ help=(
1729
+ "Interactive mode only: auto-approve all tool calls without prompting "
1730
+ "(disables human-in-the-loop). Affected tools: shell execution, file "
1731
+ "writes/edits, web search, and URL fetch. Headless mode approves "
1732
+ "non-shell tools; shell is disabled unless allowed via "
1733
+ "--shell-allow-list. "
1734
+ "Use with caution — the agent can execute arbitrary commands. When "
1735
+ "omitted, the launch default comes from [startup].mode in "
1736
+ "~/.deepagents/config.toml ('manual' or 'dangerously-auto')."
1737
+ ),
1738
+ )
1739
+
1740
+ parser.add_argument(
1741
+ "--sandbox",
1742
+ nargs="?",
1743
+ const=_SANDBOX_DEFAULT_SENTINEL,
1744
+ default="none",
1745
+ metavar="TYPE",
1746
+ help=(
1747
+ "Remote sandbox for code execution (default: none - local only). "
1748
+ "Built-ins: agentcore, daytona, langsmith, modal, runloop, vercel. "
1749
+ "Third-party and config-declared providers are also accepted. "
1750
+ "Pass --sandbox with no value to use [sandboxes].default from "
1751
+ "config (keep the bare form last on the command line so a "
1752
+ "following subcommand isn't read as its value). langsmith is "
1753
+ "bundled; others require installing an extra or package."
1754
+ ),
1755
+ )
1756
+
1757
+ parser.add_argument(
1758
+ "--sandbox-id",
1759
+ metavar="ID",
1760
+ help="Existing sandbox ID to attach to",
1761
+ )
1762
+
1763
+ parser.add_argument(
1764
+ "--sandbox-snapshot-name",
1765
+ metavar="NAME",
1766
+ help="Snapshot (langsmith) or blueprint (runloop) name to use or create",
1767
+ )
1768
+
1769
+ parser.add_argument(
1770
+ "--sandbox-setup",
1771
+ metavar="PATH",
1772
+ help="Path to setup script to run in sandbox after creation",
1773
+ )
1774
+ parser.add_argument(
1775
+ "-S",
1776
+ "--shell-allow-list",
1777
+ metavar="LIST",
1778
+ help="Comma-separated list of shell commands to auto-approve, "
1779
+ "'recommended' for safe defaults, or 'all' to allow any command. "
1780
+ "Applies to both -n and interactive modes.",
1781
+ )
1782
+ parser.add_argument(
1783
+ "--mcp-config",
1784
+ help="Path to MCP servers JSON configuration file (Claude Desktop format). "
1785
+ "Merged on top of auto-discovered configs (highest precedence). "
1786
+ "Run `dcode mcp config` to see discovery paths.",
1787
+ )
1788
+ parser.add_argument(
1789
+ "--no-mcp",
1790
+ action="store_true",
1791
+ help="Disable all MCP tool loading (skip auto-discovery and explicit config)",
1792
+ )
1793
+ parser.add_argument(
1794
+ "--trust-project-mcp",
1795
+ action="store_true",
1796
+ help="Trust project-level MCP configs with stdio servers "
1797
+ "(skip interactive approval prompt)",
1798
+ )
1799
+ parser.add_argument(
1800
+ "--interpreter",
1801
+ action=argparse.BooleanOptionalAction,
1802
+ default=None,
1803
+ help="Enable the JS interpreter (`js_eval`) middleware on the main agent. "
1804
+ "Enabled by default when not using a sandbox; use --no-interpreter to disable.",
1805
+ )
1806
+ parser.add_argument(
1807
+ "--interpreter-tools",
1808
+ dest="interpreter_tools",
1809
+ metavar="VALUE",
1810
+ help="PTC allowlist for `js_eval`: 'safe', 'all', or a comma-separated "
1811
+ "list of tool names (which may include the 'safe' preset, e.g. "
1812
+ "'safe,task'). Default is 'safe' (read-only file tools).",
1813
+ )
1814
+
1815
+ parser.add_argument(
1816
+ "--update",
1817
+ action="store_true",
1818
+ help="Check for and install updates, then exit",
1819
+ )
1820
+ parser.add_argument(
1821
+ "--prerelease",
1822
+ action="store_true",
1823
+ help="With --update, include alpha/beta/rc releases",
1824
+ )
1825
+ parser.add_argument(
1826
+ "--auto-update",
1827
+ action="store_true",
1828
+ help="Toggle automatic updates on or off, then exit",
1829
+ )
1830
+ parser.add_argument(
1831
+ "--install",
1832
+ metavar="NAME",
1833
+ help="Install an optional extra (e.g. daytona, fireworks), then exit",
1834
+ )
1835
+ parser.add_argument(
1836
+ "--package",
1837
+ action="store_true",
1838
+ help=(
1839
+ "With --install, treat NAME as a package added via `uv --with` "
1840
+ "(for a custom provider package), not a deepagents-code extra"
1841
+ ),
1842
+ )
1843
+ parser.add_argument(
1844
+ "--yes",
1845
+ action="store_true",
1846
+ help="Skip interactive confirmation prompts (e.g., for --install)",
1847
+ )
1848
+ parser.add_argument(
1849
+ "--acp",
1850
+ action="store_true",
1851
+ help="Run as an ACP server over stdio instead of launching the Textual UI",
1852
+ )
1853
+ parser.add_argument(
1854
+ "--no-mouse",
1855
+ action="store_true",
1856
+ help=(
1857
+ "Disable Textual mouse tracking. Use for web terminals (1Panel, "
1858
+ "ttyd, wetty) that leak garbled mouse-report sequences into input."
1859
+ ),
1860
+ )
1861
+
1862
+ # `parse_args` runs on every invocation; keep the import-heavy metadata
1863
+ # scan off the hot path unless the user explicitly asked for --version.
1864
+ if any(arg in {"-v", "--version"} for arg in sys.argv[1:]):
1865
+ version_text = build_version_text()
1866
+ else:
1867
+ # Never surfaced: argparse only emits `version=` when the flag is
1868
+ # actually passed, which takes the `build_version_text()` branch above.
1869
+ # This placeholder only exists because `version=` requires a value.
1870
+ version_text = f"{DISTRIBUTION_NAME} {__version__}"
1871
+ parser.add_argument(
1872
+ "-v",
1873
+ "--version",
1874
+ action="version",
1875
+ version=version_text,
1876
+ )
1877
+ parser.add_argument(
1878
+ "-h",
1879
+ "--help",
1880
+ action=_make_help_action(_lazy_help("show_help")),
1881
+ )
1882
+
1883
+ args = parser.parse_args()
1884
+ _resolve_and_validate_sandbox(args, parser)
1885
+ return args
1886
+
1887
+
1888
+ def _resolve_and_validate_sandbox(
1889
+ args: argparse.Namespace,
1890
+ parser: argparse.ArgumentParser,
1891
+ ) -> None:
1892
+ """Resolve `--sandbox` against the registry and validate related flags.
1893
+
1894
+ Handles the bare `--sandbox` form (resolve `[sandboxes].default`), unknown
1895
+ providers (with install/config guidance), and the `--sandbox-snapshot-name`
1896
+ / `--sandbox-id` flags whose support is driven by provider metadata. Calls
1897
+ `parser.error` (which exits) on invalid input.
1898
+
1899
+ Because `--sandbox` takes an optional value (`nargs="?"`), placing it
1900
+ immediately before a subcommand (e.g. `dcode --sandbox agents`) makes
1901
+ argparse consume the subcommand as the flag's value. Pass an explicit
1902
+ provider (`--sandbox daytona`) or keep the bare form last on the command
1903
+ line.
1904
+
1905
+ Args:
1906
+ args: Parsed namespace; `args.sandbox` is normalized in place.
1907
+ parser: The parser, used to emit errors.
1908
+ """
1909
+ if args.sandbox in {"none", None}:
1910
+ if args.sandbox_snapshot_name is not None:
1911
+ parser.error("--sandbox-snapshot-name requires a --sandbox provider")
1912
+ if args.sandbox_id is not None:
1913
+ parser.error("--sandbox-id requires a --sandbox provider")
1914
+ return
1915
+
1916
+ from deepagents_code.integrations.sandbox_registry import SandboxRegistry
1917
+
1918
+ registry = SandboxRegistry.load()
1919
+
1920
+ def _config_note() -> str:
1921
+ """Build a breadcrumb when the config file failed to parse.
1922
+
1923
+ Returns:
1924
+ A note to append to an error message, or an empty string when the
1925
+ config parsed cleanly.
1926
+ """
1927
+ if registry.config_error:
1928
+ return (
1929
+ f"\n\nNote: ~/.deepagents/config.toml could not be used "
1930
+ f"({registry.config_error}); any providers or default it "
1931
+ "declares were ignored."
1932
+ )
1933
+ return ""
1934
+
1935
+ if args.sandbox == _SANDBOX_DEFAULT_SENTINEL:
1936
+ default = registry.default
1937
+ if not default:
1938
+ parser.error(
1939
+ "--sandbox was given with no value but no [sandboxes].default "
1940
+ "is configured in ~/.deepagents/config.toml. Pass a provider "
1941
+ "name explicitly or set [sandboxes].default." + _config_note()
1942
+ )
1943
+ args.sandbox = default
1944
+
1945
+ if not registry.is_available(args.sandbox):
1946
+ available = ", ".join(registry.available_providers())
1947
+ parser.error(
1948
+ f"Unknown sandbox provider '{args.sandbox}'.\n"
1949
+ f"Available providers: {available}.\n\n"
1950
+ "If this is a third-party provider, install the package that "
1951
+ "publishes it and re-run:\n"
1952
+ " /install <package-name> --package\n"
1953
+ f"or declare [sandboxes.providers.{args.sandbox}] in "
1954
+ "~/.deepagents/config.toml." + _config_note()
1955
+ )
1956
+
1957
+ metadata = registry.get_metadata(args.sandbox)
1958
+ if args.sandbox_snapshot_name is not None and (
1959
+ metadata is None or not metadata.supports_snapshot_name
1960
+ ):
1961
+ parser.error(
1962
+ f"--sandbox-snapshot-name is not supported by provider '{args.sandbox}'"
1963
+ )
1964
+ if (
1965
+ args.sandbox_id is not None
1966
+ and metadata is not None
1967
+ and not metadata.supports_sandbox_id
1968
+ ):
1969
+ parser.error(f"--sandbox-id is not supported by provider '{args.sandbox}'")
1970
+
1971
+
1972
+ async def run_textual_cli_async(
1973
+ assistant_id: str,
1974
+ *,
1975
+ auto_approve: bool = False,
1976
+ sandbox_type: str = "none", # str (not None) to match argparse choices
1977
+ sandbox_id: str | None = None,
1978
+ sandbox_snapshot_name: str | None = None,
1979
+ sandbox_setup: str | None = None,
1980
+ model_name: str | None = None,
1981
+ model_params: dict[str, Any] | None = None,
1982
+ profile_override: dict[str, Any] | None = None,
1983
+ thread_id: str | None = None,
1984
+ resume_thread: str | None = None,
1985
+ initial_prompt: str | None = None,
1986
+ initial_skill: str | None = None,
1987
+ initial_goal: str | None = None,
1988
+ startup_cmd: str | None = None,
1989
+ mcp_config_path: str | None = None,
1990
+ no_mcp: bool = False,
1991
+ trust_project_mcp: bool | None = None,
1992
+ enable_interpreter: bool | None = None,
1993
+ interpreter_arg: bool | None = None,
1994
+ interpreter_ptc: str | list[str] | None = None,
1995
+ interpreter_ptc_acknowledge_unsafe: bool = False,
1996
+ mouse: bool = True,
1997
+ ) -> "AppResult":
1998
+ """Run the Textual TUI interface (async version).
1999
+
2000
+ Starts a LangGraph server in a subprocess and connects the TUI to it via the
2001
+ `langgraph-sdk` client.
2002
+
2003
+ Args:
2004
+ assistant_id: Agent identifier for memory storage
2005
+ auto_approve: Whether to auto-approve tool usage
2006
+ sandbox_type: Type of sandbox
2007
+ ("none", "agentcore", "modal", "runloop", "daytona", "langsmith")
2008
+ sandbox_id: Optional existing sandbox ID to reuse.
2009
+ sandbox_snapshot_name: Snapshot (langsmith) or blueprint (runloop) name.
2010
+ sandbox_setup: Optional path to setup script to run in the sandbox
2011
+ after creation.
2012
+ model_name: Optional model name to use
2013
+ model_params: Extra kwargs from `--model-params` to pass to the model.
2014
+
2015
+ These override config file values.
2016
+ profile_override: Extra profile fields from `--profile-override`.
2017
+
2018
+ Merged on top of config file profile overrides.
2019
+ thread_id: Thread ID for the session.
2020
+
2021
+ `None` when `resume_thread` is provided (the TUI resolves the final
2022
+ ID asynchronously).
2023
+ resume_thread: Raw resume intent from `-r` flag.
2024
+
2025
+ `'__MOST_RECENT__'` for bare `-r`, a thread ID string for `-r <id>`,
2026
+ or `None` for new sessions.
2027
+
2028
+ Resolved asynchronously inside the TUI.
2029
+ initial_prompt: Optional prompt to auto-submit when session starts
2030
+ initial_skill: Optional skill name to invoke when the session starts.
2031
+ initial_goal: Optional goal objective to draft criteria for when the
2032
+ session starts.
2033
+ startup_cmd: Shell command to run at startup before the first prompt.
2034
+
2035
+ Output is rendered in the transcript; non-zero exits warn but
2036
+ do not abort the session.
2037
+ mcp_config_path: Optional path to MCP servers JSON configuration file.
2038
+
2039
+ Merged on top of auto-discovered configs (highest precedence).
2040
+ no_mcp: Disable all MCP tool loading.
2041
+ trust_project_mcp: Controls project-level server trust (stdio and
2042
+ remote alike).
2043
+
2044
+ `True` to allow, `False` to deny, `None` to check trust store.
2045
+ enable_interpreter: Enable `CodeInterpreterMiddleware` (`js_eval`) on
2046
+ the main agent. `None` defers to the sandbox-aware/config default.
2047
+ interpreter_arg: The raw `--interpreter`/`--no-interpreter` tri-state,
2048
+ forwarded so the app can tell an explicit opt-out from a
2049
+ sandbox-suppressed default when surfacing the disabled-by-sandbox
2050
+ advisory.
2051
+ interpreter_ptc: Override for `settings.interpreter_ptc` (PTC allowlist
2052
+ for `js_eval`).
2053
+ interpreter_ptc_acknowledge_unsafe: Explicit acknowledgement for
2054
+ `interpreter_ptc="all"` outside of `auto_approve`.
2055
+ mouse: Whether to enable Textual mouse tracking. Set to `False` for web
2056
+ terminals (1Panel, ttyd, wetty) that leak garbled mouse-report
2057
+ sequences into input.
2058
+
2059
+ Returns:
2060
+ An `AppResult` with the return code and final thread ID.
2061
+ """
2062
+ from rich.text import Text
2063
+
2064
+ from deepagents_code.app import AppResult, run_textual_app
2065
+ from deepagents_code.config import (
2066
+ _get_default_model_spec,
2067
+ detect_provider,
2068
+ settings,
2069
+ )
2070
+ from deepagents_code.model_config import (
2071
+ ModelConfigError,
2072
+ ModelSpec,
2073
+ NoCredentialsConfiguredError,
2074
+ )
2075
+ from deepagents_code.onboarding import should_run_onboarding
2076
+
2077
+ # Resolve display-name cheaply (<1ms, no langchain) so the status
2078
+ # bar can show the model on first paint. The expensive create_model()
2079
+ # (~560ms) is deferred to a background worker.
2080
+
2081
+ defer_server_start = False
2082
+ try:
2083
+ resolved_spec = model_name or _get_default_model_spec()
2084
+ except NoCredentialsConfiguredError:
2085
+ resolved_spec = ""
2086
+ defer_server_start = True
2087
+ except ModelConfigError as e:
2088
+ from rich.markup import escape
2089
+
2090
+ from deepagents_code.config import console
2091
+
2092
+ console.print(f"[bold red]Error:[/bold red] {escape(str(e))}", highlight=False)
2093
+ return AppResult(return_code=1, thread_id=None)
2094
+
2095
+ if resolved_spec:
2096
+ parsed = ModelSpec.try_parse(resolved_spec)
2097
+ if parsed:
2098
+ settings.model_provider = parsed.provider
2099
+ settings.model_name = parsed.model
2100
+ else:
2101
+ settings.model_name = resolved_spec
2102
+ settings.model_provider = detect_provider(resolved_spec) or ""
2103
+ else:
2104
+ settings.model_provider = ""
2105
+ settings.model_name = ""
2106
+
2107
+ model_kwargs: dict[str, Any] | None = None
2108
+ if not defer_server_start:
2109
+ model_kwargs = {
2110
+ "model_spec": model_name or resolved_spec,
2111
+ "extra_kwargs": model_params,
2112
+ "profile_overrides": profile_override,
2113
+ }
2114
+
2115
+ # Build kwargs for deferred server startup (runs inside the TUI).
2116
+ # Never pass auto_approve to the server — the interactive server must
2117
+ # always configure full HITL interrupts so that Shift+Tab can toggle
2118
+ # approval mode mid-session. The -y flag is handled client-side via
2119
+ # session_state.auto_approve in `tui.textual_adapter`.
2120
+ server_kwargs: dict[str, Any] = {
2121
+ "assistant_id": assistant_id,
2122
+ "model_name": model_name or resolved_spec or None,
2123
+ "model_params": model_params,
2124
+ "sandbox_type": sandbox_type,
2125
+ "sandbox_id": sandbox_id,
2126
+ "sandbox_snapshot_name": sandbox_snapshot_name,
2127
+ "sandbox_setup": sandbox_setup,
2128
+ "enable_ask_user": True,
2129
+ "enable_interpreter": enable_interpreter,
2130
+ "interpreter_ptc": interpreter_ptc,
2131
+ "interpreter_ptc_acknowledge_unsafe": interpreter_ptc_acknowledge_unsafe,
2132
+ "mcp_config_path": mcp_config_path,
2133
+ "no_mcp": no_mcp,
2134
+ "trust_project_mcp": trust_project_mcp,
2135
+ "interactive": True,
2136
+ }
2137
+
2138
+ mcp_preload_kwargs: dict[str, Any] | None = None
2139
+ if not no_mcp:
2140
+ mcp_preload_kwargs = {
2141
+ "mcp_config_path": mcp_config_path,
2142
+ "no_mcp": no_mcp,
2143
+ "trust_project_mcp": trust_project_mcp,
2144
+ }
2145
+
2146
+ try:
2147
+ result = await run_textual_app(
2148
+ assistant_id=assistant_id,
2149
+ backend=None,
2150
+ auto_approve=auto_approve,
2151
+ cwd=Path.cwd(),
2152
+ thread_id=thread_id,
2153
+ resume_thread=resume_thread,
2154
+ initial_prompt=initial_prompt,
2155
+ initial_skill=initial_skill,
2156
+ initial_goal=initial_goal,
2157
+ startup_cmd=startup_cmd,
2158
+ launch_init=should_run_onboarding(),
2159
+ profile_override=profile_override,
2160
+ server_kwargs=server_kwargs,
2161
+ mcp_preload_kwargs=mcp_preload_kwargs,
2162
+ model_kwargs=model_kwargs,
2163
+ model_explicitly_set=model_name is not None,
2164
+ interpreter_arg=interpreter_arg,
2165
+ defer_server_start=defer_server_start,
2166
+ mouse=mouse,
2167
+ )
2168
+ except Exception as e:
2169
+ logger.debug("App error", exc_info=True)
2170
+ from deepagents_code.config import console
2171
+
2172
+ error_text = Text("Application error: ", style="red")
2173
+ error_text.append(str(e))
2174
+ console.print(error_text)
2175
+ if logger.isEnabledFor(logging.DEBUG):
2176
+ console.print(Text(traceback.format_exc(), style="dim"))
2177
+ return AppResult(return_code=1, thread_id=None)
2178
+
2179
+ return result
2180
+
2181
+
2182
+ async def _run_acp_cli_async(
2183
+ assistant_id: str,
2184
+ *,
2185
+ run_acp_agent: Callable[[Any], Any],
2186
+ agent_server_cls: type[Any],
2187
+ model_name: str | None = None,
2188
+ model_params: dict[str, Any] | None = None,
2189
+ profile_override: dict[str, Any] | None = None,
2190
+ mcp_config_path: str | None = None,
2191
+ no_mcp: bool = False,
2192
+ trust_project_mcp: bool | None = None,
2193
+ ) -> int:
2194
+ """Run ACP server mode and return a process exit code.
2195
+
2196
+ Args:
2197
+ assistant_id: Agent identifier to initialize.
2198
+ run_acp_agent: ACP server runner function.
2199
+ agent_server_cls: ACP server class constructor.
2200
+ model_name: Optional model name to use.
2201
+ model_params: Extra kwargs from `--model-params` to pass to the model.
2202
+ profile_override: Extra profile fields from `--profile-override`.
2203
+ mcp_config_path: Optional path to MCP servers JSON configuration file.
2204
+ no_mcp: Disable all MCP tool loading.
2205
+ trust_project_mcp: Controls project-level server trust (stdio and
2206
+ remote alike).
2207
+
2208
+ Returns:
2209
+ Exit code for ACP mode.
2210
+ """
2211
+ from deepagents_code.agent import create_cli_agent, load_async_subagents
2212
+ from deepagents_code.config import (
2213
+ create_model,
2214
+ is_memory_auto_save_enabled,
2215
+ settings,
2216
+ )
2217
+ from deepagents_code.model_config import (
2218
+ ModelConfigError,
2219
+ save_recent_model,
2220
+ touch_recent_model,
2221
+ )
2222
+ from deepagents_code.tools import fetch_url, get_current_thread_id, web_search
2223
+
2224
+ try:
2225
+ model_result = create_model(
2226
+ model_name,
2227
+ extra_kwargs=model_params,
2228
+ profile_overrides=profile_override,
2229
+ )
2230
+ except ModelConfigError as exc:
2231
+ sys.stderr.write(f"Error: {exc}\n")
2232
+ sys.stderr.flush()
2233
+ return 1
2234
+ model_result.apply_to_settings()
2235
+
2236
+ # Persist the resolved model so [models].recent is always populated.
2237
+ resolved_spec = f"{model_result.provider}:{model_result.model_name}"
2238
+ save_recent_model(resolved_spec)
2239
+ touch_recent_model(resolved_spec)
2240
+
2241
+ tools: list[Any] = [fetch_url, get_current_thread_id]
2242
+ if settings.has_tavily:
2243
+ tools.append(web_search)
2244
+
2245
+ mcp_session_manager = None
2246
+ mcp_server_info = None
2247
+ try:
2248
+ from deepagents_code.mcp_tools import resolve_and_load_mcp_tools
2249
+
2250
+ (
2251
+ mcp_tools,
2252
+ mcp_session_manager,
2253
+ mcp_server_info,
2254
+ ) = await resolve_and_load_mcp_tools(
2255
+ explicit_config_path=mcp_config_path,
2256
+ no_mcp=no_mcp,
2257
+ trust_project_mcp=trust_project_mcp,
2258
+ )
2259
+ tools.extend(mcp_tools)
2260
+ except FileNotFoundError as exc:
2261
+ msg = f"Error: MCP config file not found: {exc}\n"
2262
+ sys.stderr.write(msg)
2263
+ sys.stderr.flush()
2264
+ return 1
2265
+ except RuntimeError as exc:
2266
+ msg = f"Error: Failed to load MCP tools: {exc}\n"
2267
+ sys.stderr.write(msg)
2268
+ sys.stderr.flush()
2269
+ return 1
2270
+
2271
+ async_subagents = load_async_subagents() or None
2272
+
2273
+ try:
2274
+ from langgraph.checkpoint.memory import InMemorySaver
2275
+
2276
+ agent_graph, _backend = create_cli_agent(
2277
+ model=model_result.model,
2278
+ assistant_id=assistant_id,
2279
+ tools=tools,
2280
+ mcp_server_info=mcp_server_info,
2281
+ checkpointer=InMemorySaver(),
2282
+ async_subagents=async_subagents,
2283
+ memory_auto_save=is_memory_auto_save_enabled(),
2284
+ )
2285
+ except Exception as exc:
2286
+ sys.stderr.write(f"Error: failed to create agent: {exc}\n")
2287
+ sys.stderr.flush()
2288
+ logger.debug("ACP agent creation failed", exc_info=True)
2289
+ return 1
2290
+
2291
+ server = agent_server_cls(agent_graph) # Pregel is a CompiledStateGraph at runtime
2292
+ exit_code = 0
2293
+ try:
2294
+ await run_acp_agent(server)
2295
+ except KeyboardInterrupt:
2296
+ pass
2297
+ except Exception as exc:
2298
+ sys.stderr.write(f"Error: ACP server failed: {exc}\n")
2299
+ sys.stderr.flush()
2300
+ logger.exception("ACP server crashed")
2301
+ exit_code = 1
2302
+ finally:
2303
+ if mcp_session_manager is not None:
2304
+ try:
2305
+ await mcp_session_manager.cleanup()
2306
+ except Exception:
2307
+ logger.warning("MCP session cleanup failed", exc_info=True)
2308
+ return exit_code
2309
+
2310
+
2311
+ def apply_stdin_pipe(args: argparse.Namespace) -> None:
2312
+ r"""Read piped stdin and merge it into the parsed CLI arguments.
2313
+
2314
+ When stdin is not a TTY (i.e. input is piped), reads all available text
2315
+ and applies it to the argument namespace. If stdin is a TTY or the piped
2316
+ input is empty/whitespace-only, the function returns without modifying
2317
+ `args`. Leading and trailing whitespace is stripped from piped input.
2318
+
2319
+ - If `non_interactive_message` is already set (`-n`), prepends the
2320
+ piped text to it (the CLI still runs non-interactively):
2321
+
2322
+ ```bash
2323
+ cat context.txt | dcode -n "summarize this"
2324
+ # non_interactive_message = "{contents of context.txt}\n\nsummarize this"
2325
+ ```
2326
+
2327
+ - If `initial_prompt` is already set (`-m`, but not `-n`), prepends
2328
+ the piped text to it (the CLI still runs interactively):
2329
+
2330
+ ```bash
2331
+ cat error.log | dcode -m "explain this"
2332
+ # initial_prompt = "{contents of error.log}\n\nexplain this"
2333
+ ```
2334
+
2335
+ - If `initial_skill` is already set (`--skill`, but not `-n`/`-m`) and the
2336
+ pipe was auto-detected (no explicit `--stdin`), stores the piped text in
2337
+ `initial_prompt` so the skill receives it as the seed for the
2338
+ interactive TUI:
2339
+
2340
+ ```bash
2341
+ cat diff.txt | dcode --skill code-review
2342
+ # initial_prompt = "{contents of diff.txt}"
2343
+ ```
2344
+
2345
+ When `--stdin` is passed explicitly, this convenience is skipped: the
2346
+ piped text falls through to `non_interactive_message` so the skill runs
2347
+ headless (see below):
2348
+
2349
+ ```bash
2350
+ cat diff.txt | dcode --skill code-review --stdin
2351
+ # non_interactive_message = "{contents of diff.txt}"
2352
+ ```
2353
+
2354
+ - Otherwise, sets `non_interactive_message` to the piped text, causing
2355
+ the CLI to run non-interactively with it as the prompt:
2356
+
2357
+ ```bash
2358
+ echo "fix the typo in README.md" | dcode
2359
+ # non_interactive_message = "fix the typo in README.md"
2360
+ ```
2361
+
2362
+ Args:
2363
+ args: The parsed argument namespace (mutated in place).
2364
+ """
2365
+ from deepagents_code.config import console
2366
+
2367
+ explicit_stdin = args.stdin
2368
+
2369
+ if sys.stdin is None:
2370
+ if explicit_stdin:
2371
+ console.print(
2372
+ "[bold red]Error:[/bold red] --stdin was passed but stdin "
2373
+ "is not available."
2374
+ )
2375
+ sys.exit(1)
2376
+ return
2377
+
2378
+ try:
2379
+ is_tty = sys.stdin.isatty()
2380
+ except (ValueError, OSError):
2381
+ if explicit_stdin:
2382
+ console.print(
2383
+ "[bold red]Error:[/bold red] --stdin was passed but stdin "
2384
+ "state could not be determined."
2385
+ )
2386
+ sys.exit(1)
2387
+ return
2388
+
2389
+ if is_tty:
2390
+ if explicit_stdin:
2391
+ console.print(
2392
+ "[bold red]Error:[/bold red] --stdin was passed but stdin "
2393
+ "is a terminal. Pipe input or use -n instead.\n"
2394
+ " cat prompt.txt | dcode --stdin -q"
2395
+ )
2396
+ sys.exit(1)
2397
+ return
2398
+
2399
+ max_stdin_bytes = 10 * 1024 * 1024 # 10 MiB
2400
+
2401
+ try:
2402
+ stdin_text = sys.stdin.read(max_stdin_bytes + 1)
2403
+ except UnicodeDecodeError:
2404
+ msg = "Could not read piped input — ensure the input is valid text"
2405
+ console.print(f"[bold red]Error:[/bold red] {msg}")
2406
+ sys.exit(1)
2407
+ except (OSError, ValueError) as exc:
2408
+ from rich.markup import escape
2409
+
2410
+ console.print(
2411
+ f"[bold red]Error:[/bold red] Failed to read piped input: "
2412
+ f"{escape(str(exc))}"
2413
+ )
2414
+ sys.exit(1)
2415
+
2416
+ if len(stdin_text) > max_stdin_bytes:
2417
+ msg = (
2418
+ f"Piped input exceeds {max_stdin_bytes // (1024 * 1024)} MiB limit. "
2419
+ "Consider writing the content to a file and referencing it instead."
2420
+ )
2421
+ console.print(f"[bold red]Error:[/bold red] {msg}")
2422
+ sys.exit(1)
2423
+
2424
+ stdin_text = stdin_text.strip()
2425
+
2426
+ if not stdin_text:
2427
+ return
2428
+
2429
+ # Priority: -n message > -m prompt > --skill (no -m, no explicit --stdin)
2430
+ # > fallback to -n.
2431
+ # The initial_prompt branch uses `is not None` (not truthiness) so that
2432
+ # `-m ""` is distinguished from "no -m at all", allowing stdin to land
2433
+ # in initial_prompt even when the explicit value is empty. The --skill
2434
+ # branch only fires when -m was NOT provided; when both -m and --skill
2435
+ # are set, stdin merges with the -m value (previous branch).
2436
+ #
2437
+ # The --skill -> interactive `initial_prompt` routing applies only to
2438
+ # auto-detected pipes (no explicit `--stdin`), where seeding an interactive
2439
+ # TUI is a deliberate convenience. When the user passes `--stdin`
2440
+ # explicitly, that signals non-interactive intent, so we skip this branch
2441
+ # and fall through to `non_interactive_message` (headless), which also
2442
+ # supports `--skill`.
2443
+ if args.non_interactive_message:
2444
+ args.non_interactive_message = f"{stdin_text}\n\n{args.non_interactive_message}"
2445
+ elif args.initial_prompt is not None:
2446
+ if args.initial_prompt:
2447
+ args.initial_prompt = f"{stdin_text}\n\n{args.initial_prompt}"
2448
+ else:
2449
+ args.initial_prompt = stdin_text
2450
+ elif getattr(args, "initial_skill", None) and not explicit_stdin:
2451
+ args.initial_prompt = stdin_text
2452
+ else:
2453
+ args.non_interactive_message = stdin_text
2454
+
2455
+ # Restore stdin from the real terminal so the interactive Textual app
2456
+ # (used by the -m path) can read keyboard/mouse input normally.
2457
+ # Textual's driver reads from file descriptor 0 directly (not sys.stdin),
2458
+ # so we must replace the underlying fd with /dev/tty using os.dup2.
2459
+ try:
2460
+ tty_fd = os.open("/dev/tty", os.O_RDONLY)
2461
+ except OSError:
2462
+ # No controlling terminal (CI, Docker, headless). Non-interactive
2463
+ # path still works; interactive -m path will fail later with a
2464
+ # clear "not a terminal" error from Textual.
2465
+ return
2466
+
2467
+ try:
2468
+ os.dup2(tty_fd, 0)
2469
+ os.close(tty_fd)
2470
+ sys.stdin = open(0, encoding="utf-8", closefd=False) # noqa: SIM115 # fd 0 requires open() for TTY restoration
2471
+ except OSError:
2472
+ console.print(
2473
+ "[yellow]Warning:[/yellow] TTY restoration failed. "
2474
+ "Interactive mode (-m) may not work correctly."
2475
+ )
2476
+ logger.warning(
2477
+ "TTY restoration failed after opening /dev/tty",
2478
+ exc_info=True,
2479
+ )
2480
+ try:
2481
+ os.close(tty_fd)
2482
+ except OSError:
2483
+ logger.warning(
2484
+ "Failed to close TTY fd %d during cleanup",
2485
+ tty_fd,
2486
+ exc_info=True,
2487
+ )
2488
+
2489
+
2490
+ def _print_session_stats(stats: Any, console: Any) -> None: # noqa: ANN401
2491
+ """Print a session-level usage stats table to the console on TUI exit.
2492
+
2493
+ Args:
2494
+ stats: The cumulative session stats from the Textual app.
2495
+ console: Rich console for output.
2496
+ """
2497
+ from deepagents_code._session_stats import SessionStats, print_usage_table
2498
+
2499
+ if not isinstance(stats, SessionStats):
2500
+ return
2501
+ print_usage_table(stats, stats.wall_time_seconds, console)
2502
+
2503
+
2504
+ def _debug_mcp_project_trust_enabled() -> bool:
2505
+ """Return whether the project MCP approval prompt debug path is enabled."""
2506
+ from deepagents_code._env_vars import DEBUG_MCP_PROJECT_TRUST, is_env_truthy
2507
+
2508
+ return is_env_truthy(DEBUG_MCP_PROJECT_TRUST)
2509
+
2510
+
2511
+ def _resolve_no_mouse(args: argparse.Namespace) -> bool:
2512
+ """Return whether Textual mouse tracking should be disabled.
2513
+
2514
+ True when the user passed `--no-mouse` on the command line, or set
2515
+ `DEEPAGENTS_CODE_NO_MOUSE` to a truthy value. Web-based terminals
2516
+ (1Panel, ttyd, wetty) commonly need this because they forward mouse
2517
+ events but strip the ESC prefix from SGR mouse-report sequences,
2518
+ causing garbled input like `[<35;36;33M...` to leak into the input.
2519
+
2520
+ Args:
2521
+ args: Parsed argparse namespace.
2522
+
2523
+ Returns:
2524
+ `True` when mouse tracking should be disabled.
2525
+ """
2526
+ from deepagents_code._env_vars import NO_MOUSE, is_env_truthy
2527
+
2528
+ if getattr(args, "no_mouse", False):
2529
+ return True
2530
+ return is_env_truthy(NO_MOUSE)
2531
+
2532
+
2533
+ def _check_mcp_project_trust(*, trust_flag: bool = False) -> bool | None:
2534
+ """Check whether project-level MCP servers should be trusted.
2535
+
2536
+ Both stdio and remote (http/sse) project entries require approval —
2537
+ remote entries from an attacker-controlled `.mcp.json` can SSRF or
2538
+ exfiltrate environment variables via `${VAR}` interpolation in their
2539
+ `headers`, so they are gated identically to stdio commands.
2540
+
2541
+ When the project has no servers in project-level configs, returns
2542
+ `None` (no gate needed). When `--trust-project-mcp` was passed,
2543
+ returns `True`. Otherwise checks the persistent trust store; if
2544
+ untrusted, shows an interactive approval prompt.
2545
+
2546
+ Servers already resolved by the user's `enabled_project_servers` /
2547
+ `disabled_project_servers` lists are shown for transparency but not
2548
+ prompted for (enabled ones load regardless; disabled ones never load).
2549
+ `None` is returned when that leaves nothing to decide. If the user's own
2550
+ allow/deny policy cannot be read, returns `False` (fail closed) rather than
2551
+ prompting under an unknown deny list.
2552
+
2553
+ Args:
2554
+ trust_flag: Whether `--trust-project-mcp` was passed.
2555
+
2556
+ Returns:
2557
+ `True` to allow project servers, `False` to deny (including when the
2558
+ user's trust policy could not be read), or `None` when there are no
2559
+ project servers whose fate this prompt decides.
2560
+ """
2561
+ from deepagents_code.mcp_tools import (
2562
+ classify_discovered_configs,
2563
+ discover_mcp_configs,
2564
+ extract_project_server_summaries,
2565
+ load_mcp_config_lenient,
2566
+ merge_mcp_configs,
2567
+ )
2568
+ from deepagents_code.project_utils import ProjectContext
2569
+
2570
+ debug_prompt = _debug_mcp_project_trust_enabled()
2571
+
2572
+ try:
2573
+ project_context = ProjectContext.from_user_cwd(Path.cwd())
2574
+ config_paths = discover_mcp_configs(project_context=project_context)
2575
+ except (OSError, RuntimeError):
2576
+ return None
2577
+
2578
+ _, project_configs = classify_discovered_configs(config_paths)
2579
+ if not project_configs and not debug_prompt:
2580
+ return None
2581
+
2582
+ # Merge configs by server name (last wins, matching the loader) so that
2583
+ # a server defined in multiple project configs (for example,
2584
+ # `.deepagents/.mcp.json` and higher-precedence `.mcp.json`) only shows
2585
+ # up once in the prompt.
2586
+ loaded_configs = [
2587
+ cfg
2588
+ for cfg in (load_mcp_config_lenient(path) for path in project_configs)
2589
+ if cfg is not None
2590
+ ]
2591
+ merged_config = merge_mcp_configs(loaded_configs)
2592
+ all_servers = extract_project_server_summaries(merged_config)
2593
+
2594
+ if not all_servers and debug_prompt:
2595
+ all_servers = [
2596
+ (
2597
+ "debug-project-mcp",
2598
+ "stdio",
2599
+ "uvx deepagents-debug-mcp --sample-project-server",
2600
+ )
2601
+ ]
2602
+
2603
+ if not all_servers:
2604
+ return None
2605
+
2606
+ if trust_flag:
2607
+ return True
2608
+
2609
+ # Check trust store
2610
+ from deepagents_code.mcp_trust import (
2611
+ compute_config_fingerprint,
2612
+ is_project_mcp_trusted,
2613
+ trust_project_mcp,
2614
+ )
2615
+
2616
+ project_root = str(
2617
+ (project_context.project_root or project_context.user_cwd).resolve()
2618
+ )
2619
+ fingerprint = compute_config_fingerprint(project_configs)
2620
+
2621
+ if not debug_prompt and is_project_mcp_trusted(project_root, fingerprint):
2622
+ return True
2623
+
2624
+ # Partition by the user's own allow/deny lists (read only from home config,
2625
+ # never the repo — the same boundary the loader enforces). Enabled servers
2626
+ # load regardless of the answer here and disabled ones never load, so the
2627
+ # prompt must not *ask* about them. It still *shows* them, though, so the
2628
+ # user sees what their config decided — notably a repo redefining an
2629
+ # allowlisted name, which binds by name rather than by fingerprint.
2630
+ from deepagents_code.model_config import load_mcp_server_trust_lists
2631
+
2632
+ trust_lists = load_mcp_server_trust_lists()
2633
+ from rich.console import Console as _Console
2634
+ from rich.markup import escape
2635
+
2636
+ prompt_console = _Console(stderr=True)
2637
+ prompt_servers: list[tuple[str, str, str]] = []
2638
+ preapproved: list[tuple[str, str, str]] = []
2639
+ blocked: list[tuple[str, str, str]] = []
2640
+ for name, kind, summary in all_servers:
2641
+ # Disabled first: reject precedence (a name in both lists is disabled).
2642
+ if name in trust_lists.disabled:
2643
+ blocked.append((name, kind, summary))
2644
+ elif name in trust_lists.enabled:
2645
+ preapproved.append((name, kind, summary))
2646
+ else:
2647
+ prompt_servers.append((name, kind, summary))
2648
+
2649
+ def _print_auto_resolved() -> None:
2650
+ """List servers the config already decided, without asking about them."""
2651
+ if not preapproved and not blocked:
2652
+ return
2653
+ prompt_console.print()
2654
+ prompt_console.print(
2655
+ "[dim]Resolved by your config (not prompted):[/dim]", highlight=False
2656
+ )
2657
+ for name, kind, summary in preapproved:
2658
+ prompt_console.print(
2659
+ f' [green]"{escape(name)}"[/green] ({escape(kind)}): pre-approved '
2660
+ f"(enabled_project_servers): {escape(summary)}",
2661
+ highlight=False,
2662
+ )
2663
+ for name, kind, summary in blocked:
2664
+ prompt_console.print(
2665
+ f' [red]"{escape(name)}"[/red] ({escape(kind)}): blocked '
2666
+ f"(disabled_project_servers): {escape(summary)}",
2667
+ highlight=False,
2668
+ )
2669
+
2670
+ if trust_lists.read_error is not None:
2671
+ # The user's allow/deny policy could not be read. Fail closed here too
2672
+ # (matching the loader, which forces the config untrusted) instead of
2673
+ # prompting and possibly persisting fingerprint trust under an unknown
2674
+ # deny list. Any env-enabled names still load — the loader re-applies the
2675
+ # lists downstream — but nothing is approved via this prompt.
2676
+ prompt_console.print(
2677
+ f"[yellow]Warning: {escape(trust_lists.read_error)}; treating "
2678
+ "project MCP servers as untrusted.[/yellow]",
2679
+ highlight=False,
2680
+ )
2681
+ _print_auto_resolved()
2682
+ return False
2683
+
2684
+ if not prompt_servers:
2685
+ # Nothing left to decide interactively, but surface what the lists
2686
+ # resolved so a load driven purely by config is never fully silent.
2687
+ _print_auto_resolved()
2688
+ return None
2689
+
2690
+ docs_url = (
2691
+ "https://docs.langchain.com/oss/python/deepagents/code/"
2692
+ "mcp-tools#project-level-trust"
2693
+ )
2694
+ prompt_console.print()
2695
+ prompt_console.print(
2696
+ "[bold yellow]Project MCP servers require approval:[/bold yellow]"
2697
+ )
2698
+ for name, kind, summary in prompt_servers:
2699
+ prompt_console.print(
2700
+ f' [bold]"{escape(name)}"[/bold] ({escape(kind)}): {escape(summary)}'
2701
+ )
2702
+ _print_auto_resolved()
2703
+ prompt_console.print()
2704
+ prompt_console.print(
2705
+ f"[dim]Learn more: [link={docs_url}]{docs_url}[/link][/dim]",
2706
+ highlight=False,
2707
+ )
2708
+ prompt_console.print()
2709
+
2710
+ try:
2711
+ answer = input("Allow? [y/N]: ").strip().lower()
2712
+ except (EOFError, KeyboardInterrupt):
2713
+ answer = ""
2714
+
2715
+ if answer == "y":
2716
+ if not debug_prompt and not trust_project_mcp(project_root, fingerprint):
2717
+ prompt_console.print(
2718
+ "[yellow]Approved for this session, but the decision could not be "
2719
+ "saved — you'll be asked again next time.[/yellow]",
2720
+ highlight=False,
2721
+ )
2722
+ return True
2723
+ return False
2724
+
2725
+
2726
+ def _verify_interpreter_or_exit() -> None:
2727
+ """Run the interpreter pre-flight check; print and exit on failure.
2728
+
2729
+ Called before spawning the langgraph dev server subprocess so a missing
2730
+ `langchain-quickjs` dependency surfaces a one-line, actionable hint instead
2731
+ of an opaque "Server process exited with code N" downstream. Gated on the
2732
+ resolved interpreter state (`_resolve_interpreter_enabled`), not the
2733
+ `--interpreter` flag alone, since the interpreter is now on by default.
2734
+ """
2735
+ from deepagents_code.extras_info import verify_interpreter_deps
2736
+
2737
+ try:
2738
+ verify_interpreter_deps()
2739
+ except ImportError as exc:
2740
+ from rich.markup import escape
2741
+
2742
+ from deepagents_code.config import console
2743
+
2744
+ console.print(f"[bold red]Error:[/bold red] {escape(str(exc))}")
2745
+ sys.exit(1)
2746
+
2747
+
2748
+ def cli_main() -> None:
2749
+ """Entry point for console script."""
2750
+ # Fix for gRPC fork issue on macOS
2751
+ # https://github.com/grpc/grpc/issues/37642
2752
+ if sys.platform == "darwin":
2753
+ os.environ["GRPC_ENABLE_FORK_SUPPORT"] = "0"
2754
+
2755
+ # Note: LANGSMITH_PROJECT override is handled lazily by config.py's
2756
+ # _ensure_bootstrap() (triggered on first access of `settings`).
2757
+ # This ensures agent traces use DEEPAGENTS_CODE_LANGSMITH_PROJECT while
2758
+ # shell commands use the user's original LANGSMITH_PROJECT.
2759
+
2760
+ # Fast path: print version without loading heavy dependencies
2761
+ if len(sys.argv) == 2 and sys.argv[1] in {"-v", "--version"}: # noqa: PLR2004 # argv length check for fast-path
2762
+ print(build_version_text()) # noqa: T201 # Version output
2763
+ sys.exit(0)
2764
+
2765
+ # Arm session-end summary AFTER the trivial --version fast path so a
2766
+ # `dcode --version` invocation prints only the version. All heavier
2767
+ # sessions (headless run, interactive TUI, doctor, deploy, etc.) get the
2768
+ # exit-reason + duration summary at shutdown. Kept in a try so a bug in
2769
+ # the summary module can never break startup.
2770
+ try:
2771
+ from deepagents_code import session_end_summary
2772
+
2773
+ session_end_summary.install()
2774
+ except Exception:
2775
+ logger.debug("Failed to install session_end_summary", exc_info=True)
2776
+
2777
+ # ACP mode does not require Textual, so skip UI dependency checks when
2778
+ # the flag is present in raw argv.
2779
+ if "--acp" not in sys.argv[1:]:
2780
+ check_cli_dependencies()
2781
+
2782
+ # The app-owned server runs in a detached session so terminal job-control
2783
+ # signals do not suspend or kill it. Replace terminating signals' immediate
2784
+ # default behavior with an exception so the app/server cleanup finally
2785
+ # blocks run when dcode's process group is stopped.
2786
+ _install_termination_signal_handlers()
2787
+
2788
+ try:
2789
+ args = parse_args()
2790
+
2791
+ if _show_bare_command_group_help(args):
2792
+ return
2793
+
2794
+ # Keep self-contained commands that do not need global settings here, before
2795
+ # state migration and settings bootstrap. If a future command only reads
2796
+ # local files or delegates bootstrap to specific subcommands, dispatch it here
2797
+ # so lightweight diagnostic paths stay fast.
2798
+ # Use `getattr` because this fast-path block is for optional top-level
2799
+ # subcommands only. ACP/root-mode invocations may not define `command`,
2800
+ # and should fall through to the later handlers instead of raising here.
2801
+ command = getattr(args, "command", None)
2802
+ if command == "config":
2803
+ from deepagents_code.client.commands.config import run_config_command
2804
+
2805
+ sys.exit(run_config_command(args))
2806
+
2807
+ if command == "auth" and getattr(args, "auth_command", None) == "path":
2808
+ from deepagents_code.client.commands.auth import run_auth_command
2809
+
2810
+ sys.exit(run_auth_command(args))
2811
+
2812
+ if command == "doctor":
2813
+ from deepagents_code.doctor import run_doctor_command
2814
+
2815
+ sys.exit(run_doctor_command(args))
2816
+
2817
+ if command == "tools":
2818
+ from deepagents_code.client.commands.tools import run_tools_command
2819
+
2820
+ sys.exit(run_tools_command(args))
2821
+
2822
+ # Best-effort, idempotent migration. Placed after parse_args and the
2823
+ # bare-help fast path so --help / --version / `deepagents <group>`
2824
+ # exit before any I/O. Wrapped broadly so an unexpected non-OSError
2825
+ # (e.g., RuntimeError from `Path.home()` when $HOME is unset on a CI
2826
+ # runner) cannot crash startup — state migration has zero functional
2827
+ # value vs. failing-soft.
2828
+ try:
2829
+ from deepagents_code.state_migration import migrate_legacy_state
2830
+
2831
+ migrate_legacy_state()
2832
+ except Exception:
2833
+ logger.warning(
2834
+ "Legacy state migration failed unexpectedly; continuing.",
2835
+ exc_info=True,
2836
+ )
2837
+
2838
+ # Import console/settings AFTER arg parsing and after the bare-help
2839
+ # fast path so neither argparse's `--help`/`-h` exit nor
2840
+ # `deepagents <group>` pays the settings bootstrap cost.
2841
+ from deepagents_code.config import console, settings
2842
+
2843
+ if command == "auth":
2844
+ from deepagents_code.client.commands.auth import run_auth_command
2845
+
2846
+ sys.exit(run_auth_command(args))
2847
+
2848
+ model_params: dict[str, Any] | None = None
2849
+ raw_kwargs = getattr(args, "model_params", None)
2850
+ if raw_kwargs:
2851
+ try:
2852
+ model_params = json.loads(raw_kwargs)
2853
+ except json.JSONDecodeError as e:
2854
+ console.print(
2855
+ f"[bold red]Error:[/bold red] --model-params is not valid JSON: {e}"
2856
+ )
2857
+ sys.exit(1)
2858
+ if not isinstance(model_params, dict):
2859
+ console.print(
2860
+ "[bold red]Error:[/bold red] --model-params must be a JSON object"
2861
+ )
2862
+ sys.exit(1)
2863
+
2864
+ max_retries = getattr(args, "max_retries", None)
2865
+ if max_retries is not None:
2866
+ from deepagents_code.config import CLI_MAX_RETRIES_KEY
2867
+
2868
+ if model_params is None:
2869
+ model_params = {}
2870
+ # Carry the flag value under an internal key; `create_model` folds it
2871
+ # under the resolved provider's retry-param name (which may not be
2872
+ # `max_retries` for custom providers) with top precedence.
2873
+ model_params[CLI_MAX_RETRIES_KEY] = max_retries
2874
+
2875
+ profile_override: dict[str, Any] | None = None
2876
+ raw_profile = getattr(args, "profile_override", None)
2877
+ if raw_profile:
2878
+ try:
2879
+ profile_override = json.loads(raw_profile)
2880
+ except json.JSONDecodeError as e:
2881
+ console.print(
2882
+ "[bold red]Error:[/bold red] "
2883
+ f"--profile-override is not valid JSON: {e}"
2884
+ )
2885
+ sys.exit(1)
2886
+ if not isinstance(profile_override, dict):
2887
+ console.print(
2888
+ "[bold red]Error:[/bold red] "
2889
+ "--profile-override must be a JSON object"
2890
+ )
2891
+ sys.exit(1)
2892
+
2893
+ if getattr(args, "acp", False):
2894
+ assistant_id = _resolve_agent_arg(args)
2895
+ try:
2896
+ from acp import run_agent as run_acp_agent
2897
+ from deepagents_acp.server import AgentServerACP
2898
+ except ImportError as exc:
2899
+ msg = (
2900
+ f"ACP dependencies not available: {exc}\n"
2901
+ f"Install with: uv tool install --reinstall -U {DISTRIBUTION_NAME} "
2902
+ "--with deepagents-acp\n"
2903
+ )
2904
+ sys.stderr.write(msg)
2905
+ sys.stderr.flush()
2906
+ sys.exit(1)
2907
+
2908
+ if getattr(args, "no_mcp", False) and getattr(args, "mcp_config", None):
2909
+ msg = (
2910
+ "Error: --no-mcp and --mcp-config are mutually exclusive."
2911
+ " Use one or the other.\n"
2912
+ " dcode --mcp-config path/to/config.json\n"
2913
+ " dcode --no-mcp\n"
2914
+ )
2915
+ sys.stderr.write(msg)
2916
+ sys.stderr.flush()
2917
+ sys.exit(2)
2918
+
2919
+ exit_code = asyncio.run(
2920
+ _run_acp_cli_async(
2921
+ assistant_id=assistant_id,
2922
+ run_acp_agent=run_acp_agent,
2923
+ agent_server_cls=AgentServerACP,
2924
+ model_name=getattr(args, "model", None),
2925
+ model_params=model_params,
2926
+ profile_override=profile_override,
2927
+ mcp_config_path=getattr(args, "mcp_config", None),
2928
+ no_mcp=getattr(args, "no_mcp", False),
2929
+ trust_project_mcp=getattr(args, "trust_project_mcp", False),
2930
+ )
2931
+ )
2932
+ sys.exit(exit_code)
2933
+
2934
+ # Apply shell-allow-list from command line if provided (overrides env var)
2935
+ if args.shell_allow_list:
2936
+ from deepagents_code.config import parse_shell_allow_list
2937
+
2938
+ settings.shell_allow_list = parse_shell_allow_list(args.shell_allow_list)
2939
+
2940
+ apply_stdin_pipe(args)
2941
+
2942
+ # Validated here, before mode dispatch and any heavy session setup:
2943
+ # `apply_stdin_pipe` has finalized `non_interactive_message` (the same
2944
+ # predicate that selects the headless branch below), so this reliably
2945
+ # rejects `--auto-approve` on both the `-n` and piped-stdin paths while
2946
+ # leaving interactive launches untouched.
2947
+ if args.auto_approve and args.non_interactive_message:
2948
+ from rich.console import Console as _Console
2949
+
2950
+ _Console(stderr=True).print(
2951
+ "[bold red]Error:[/bold red] --auto-approve is only supported in "
2952
+ "interactive mode. Headless mode already approves non-shell tools; "
2953
+ "use --shell-allow-list to control shell access."
2954
+ )
2955
+ sys.exit(2)
2956
+
2957
+ if getattr(args, "no_mcp", False) and getattr(args, "mcp_config", None):
2958
+ from rich.console import Console as _Console
2959
+
2960
+ _Console(stderr=True).print(
2961
+ "[bold red]Error:[/bold red] --no-mcp and --mcp-config "
2962
+ "are mutually exclusive. Use one or the other.\n"
2963
+ " dcode --mcp-config path/to/config.json\n"
2964
+ " dcode --no-mcp"
2965
+ )
2966
+ sys.exit(2)
2967
+
2968
+ if (
2969
+ getattr(args, "initial_skill", None)
2970
+ and not args.non_interactive_message
2971
+ and (args.quiet or args.no_stream)
2972
+ ):
2973
+ from rich.console import Console as _Console
2974
+
2975
+ _Console(stderr=True).print(
2976
+ "[bold red]Error:[/bold red] --skill requires "
2977
+ "--non-interactive (-n) when combined with --quiet or "
2978
+ "--no-stream.\n"
2979
+ " dcode --skill code-review -m 'review this patch'\n"
2980
+ " dcode --skill code-review -n 'review this patch'"
2981
+ )
2982
+ sys.exit(2)
2983
+
2984
+ max_turns_set = getattr(args, "max_turns", None) is not None
2985
+ if max_turns_set and not args.non_interactive_message:
2986
+ from rich.console import Console as _Console
2987
+
2988
+ _Console(stderr=True).print(
2989
+ "[bold red]Error:[/bold red] --max-turns requires "
2990
+ "--non-interactive (-n) or piped stdin\n"
2991
+ " dcode -n 'refactor auth module' --max-turns 5"
2992
+ )
2993
+ sys.exit(2)
2994
+
2995
+ timeout_set = getattr(args, "timeout", None) is not None
2996
+ if timeout_set and not args.non_interactive_message:
2997
+ from rich.console import Console as _Console
2998
+
2999
+ _Console(stderr=True).print(
3000
+ "[bold red]Error:[/bold red] --timeout requires "
3001
+ "--non-interactive (-n) or piped stdin\n"
3002
+ " dcode -n 'run the test suite' --timeout 120"
3003
+ )
3004
+ sys.exit(2)
3005
+
3006
+ # `--goal` conflicts with every rubric flag, not just `--rubric`.
3007
+ # `--rubric-model`/`--rubric-max-iterations` also require `-n` (see the
3008
+ # non-interactive guard below), so without this check `--goal
3009
+ # --rubric-model X` would slip past here and hit a contradictory "add
3010
+ # -n" error — and adding `-n` then trips the interactive-only `--goal`
3011
+ # guard. Reject the combination up front instead.
3012
+ if getattr(args, "goal", None) is not None and any(
3013
+ getattr(args, attr, None) is not None
3014
+ for attr in ("rubric", "rubric_model", "rubric_max_iterations")
3015
+ ):
3016
+ from rich.console import Console as _Console
3017
+
3018
+ _Console(stderr=True).print(
3019
+ "[bold red]Error:[/bold red] --goal is mutually exclusive with "
3020
+ "--rubric/--rubric-model/--rubric-max-iterations. Use --goal to "
3021
+ "generate criteria interactively, or --rubric (with -n) to "
3022
+ "provide them directly."
3023
+ )
3024
+ sys.exit(2)
3025
+
3026
+ goal_text = getattr(args, "goal", None)
3027
+ if goal_text is not None and not goal_text.strip():
3028
+ from rich.console import Console as _Console
3029
+
3030
+ _Console(stderr=True).print(
3031
+ "[bold red]Error:[/bold red] --goal must not be empty."
3032
+ )
3033
+ sys.exit(2)
3034
+ if goal_text is not None and args.non_interactive_message:
3035
+ from rich.console import Console as _Console
3036
+
3037
+ _Console(stderr=True).print(
3038
+ "[bold red]Error:[/bold red] --goal is only supported in "
3039
+ "interactive mode for now.\n"
3040
+ " dcode --goal 'add OAuth refresh handling'"
3041
+ )
3042
+ sys.exit(2)
3043
+ if goal_text is not None and (
3044
+ getattr(args, "initial_prompt", None) is not None
3045
+ or getattr(args, "initial_skill", None)
3046
+ ):
3047
+ from rich.console import Console as _Console
3048
+
3049
+ _Console(stderr=True).print(
3050
+ "[bold red]Error:[/bold red] --goal cannot be combined with "
3051
+ "-m/--message or --skill.\n"
3052
+ " dcode --goal 'add OAuth refresh handling'"
3053
+ )
3054
+ sys.exit(2)
3055
+
3056
+ non_interactive_rubric_set = any(
3057
+ getattr(args, attr, None) is not None
3058
+ for attr in (
3059
+ "rubric",
3060
+ "rubric_model",
3061
+ "rubric_max_iterations",
3062
+ )
3063
+ )
3064
+ if non_interactive_rubric_set and not args.non_interactive_message:
3065
+ from rich.console import Console as _Console
3066
+
3067
+ _Console(stderr=True).print(
3068
+ "[bold red]Error:[/bold red] --rubric/--rubric-model/"
3069
+ "--rubric-max-iterations require "
3070
+ "--non-interactive (-n) or piped stdin\n"
3071
+ " dcode -n 'implement X' --rubric 'tests pass'"
3072
+ )
3073
+ sys.exit(2)
3074
+
3075
+ if (args.quiet or args.no_stream) and not args.non_interactive_message:
3076
+ # Print to stderr (not the module-level stdout console) and exit
3077
+ # with code 2 to match the POSIX convention for usage errors, as
3078
+ # argparse's parser.error() would.
3079
+ from rich.console import Console as _Console
3080
+
3081
+ flags = []
3082
+ if args.quiet:
3083
+ flags.append("--quiet")
3084
+ if args.no_stream:
3085
+ flags.append("--no-stream")
3086
+ flag = " and ".join(flags)
3087
+ _Console(stderr=True).print(
3088
+ f"[bold red]Error:[/bold red] {flag} requires "
3089
+ "--non-interactive (-n) or piped stdin\n"
3090
+ " dcode -n 'summarize README.md' --quiet"
3091
+ )
3092
+ sys.exit(2)
3093
+
3094
+ if args.prerelease and not (args.update or args.command == "update"):
3095
+ from rich.console import Console as _Console
3096
+
3097
+ _Console(stderr=True).print(
3098
+ "[bold red]Error:[/bold red] --prerelease requires --update "
3099
+ "or the update subcommand"
3100
+ )
3101
+ sys.exit(2)
3102
+
3103
+ # Handle --update flag or `update` subcommand (headless, no session)
3104
+ if args.update or args.command == "update":
3105
+ try:
3106
+ from rich.markup import escape
3107
+
3108
+ from deepagents_code._env_vars import DEBUG_UPDATE
3109
+ from deepagents_code._version import __version__ as cli_version
3110
+ from deepagents_code.config import _is_editable_install
3111
+ from deepagents_code.update_check import (
3112
+ _PRERELEASE_UNSUPPORTED_MESSAGE,
3113
+ create_update_log_path,
3114
+ format_age_suffix,
3115
+ format_installed_age_suffix,
3116
+ format_release_age_parenthetical,
3117
+ is_update_available,
3118
+ perform_upgrade,
3119
+ prerelease_upgrade_supported,
3120
+ release_requires_prereleases,
3121
+ upgrade_command,
3122
+ )
3123
+
3124
+ if _is_editable_install():
3125
+ age_suffix = format_age_suffix(cli_version)
3126
+ console.print(
3127
+ "[bold yellow]Warning:[/bold yellow] "
3128
+ "Updates are not available for editable installs. "
3129
+ f"Currently on v{cli_version}{age_suffix}."
3130
+ )
3131
+ sys.exit(0)
3132
+
3133
+ include_prereleases = True if args.prerelease else None
3134
+
3135
+ # Refuse pre-release upgrades the install method can't honor
3136
+ # before promising an upgrade or hitting PyPI.
3137
+ if args.prerelease:
3138
+ supported, reason = prerelease_upgrade_supported()
3139
+ if not supported:
3140
+ console.print(
3141
+ "[bold red]Error:[/bold red] "
3142
+ f"{reason or _PRERELEASE_UNSUPPORTED_MESSAGE}"
3143
+ )
3144
+ sys.exit(1)
3145
+
3146
+ console.print("Checking for updates...", style="dim")
3147
+ available, latest = is_update_available(
3148
+ bypass_cache=True,
3149
+ include_prereleases=include_prereleases,
3150
+ )
3151
+ if latest is None:
3152
+ console.print(
3153
+ "[bold yellow]Warning:[/bold yellow] Could not "
3154
+ "determine the latest version. Check your network "
3155
+ "and try again."
3156
+ )
3157
+ sys.exit(1)
3158
+ if not available:
3159
+ age_suffix = format_age_suffix(cli_version)
3160
+ console.print(
3161
+ f"Already on the latest version (v{cli_version}{age_suffix})."
3162
+ )
3163
+ sys.exit(0)
3164
+
3165
+ upgrade_include_prereleases = include_prereleases
3166
+ pin_upgrade_version: str | None = None
3167
+ if include_prereleases is None and release_requires_prereleases(latest):
3168
+ upgrade_include_prereleases = True
3169
+ pin_upgrade_version = latest
3170
+ if upgrade_include_prereleases is True:
3171
+ supported, reason = prerelease_upgrade_supported()
3172
+ if not supported:
3173
+ console.print(
3174
+ "[bold red]Error:[/bold red] "
3175
+ f"{reason or _PRERELEASE_UNSUPPORTED_MESSAGE}"
3176
+ )
3177
+ sys.exit(1)
3178
+
3179
+ release_age = format_release_age_parenthetical(latest)
3180
+ installed_age = format_installed_age_suffix(cli_version)
3181
+ console.print(
3182
+ f"Update available: v{latest}{release_age}. "
3183
+ f"Currently installed: {cli_version}{installed_age}. "
3184
+ "Upgrading..."
3185
+ )
3186
+ if os.environ.get(DEBUG_UPDATE):
3187
+ console.print("Skipped update install (debug mode).", style="dim")
3188
+ sys.exit(0)
3189
+ log_path = create_update_log_path()
3190
+ console.print(
3191
+ f"Update log: {_tail_log_command(log_path)}",
3192
+ style="dim",
3193
+ highlight=False,
3194
+ markup=False,
3195
+ )
3196
+ success, output = asyncio.run(
3197
+ perform_upgrade(
3198
+ log_path=log_path,
3199
+ include_prereleases=include_prereleases,
3200
+ target_version=latest,
3201
+ )
3202
+ )
3203
+ if success:
3204
+ console.print(f"[green]Updated to v{latest}.[/green]")
3205
+ else:
3206
+ cmd = upgrade_command(
3207
+ include_prereleases=upgrade_include_prereleases,
3208
+ version=pin_upgrade_version,
3209
+ )
3210
+ detail = f": {escape(output[:200])}" if output else ""
3211
+ console.print(
3212
+ f"[bold red]Auto-update failed{detail}[/bold red]\n"
3213
+ f"Run manually: [cyan]{cmd}[/cyan]"
3214
+ )
3215
+ sys.exit(1)
3216
+ sys.exit(0)
3217
+ except Exception:
3218
+ logger.warning("--update failed", exc_info=True)
3219
+ # Preserve the user's pre-release intent in the manual fallback:
3220
+ # a `--prerelease` request that crashes unexpectedly must not
3221
+ # suggest a stable-only command, which would silently downgrade
3222
+ # the channel. Both are module-level string constants, so this
3223
+ # import can't fail inside the last-resort handler.
3224
+ from deepagents_code.update_check import (
3225
+ _UV_PRERELEASE_UPGRADE_COMMAND,
3226
+ FALLBACK_UPGRADE_COMMAND,
3227
+ )
3228
+
3229
+ manual_cmd = (
3230
+ _UV_PRERELEASE_UPGRADE_COMMAND
3231
+ if args.prerelease
3232
+ else FALLBACK_UPGRADE_COMMAND
3233
+ )
3234
+ console.print(
3235
+ "[bold red]Error:[/bold red] Update failed.\n"
3236
+ f"Run manually: [cyan]{manual_cmd}[/cyan]"
3237
+ )
3238
+ sys.exit(1)
3239
+
3240
+ if args.package and not args.install:
3241
+ console.print(
3242
+ "[bold red]Error:[/bold red] --package requires --install <package>.",
3243
+ )
3244
+ sys.exit(2)
3245
+
3246
+ # Handle --install <package> --package flag (headless, no session).
3247
+ # Installs an arbitrary package via `uv --with` for a custom provider,
3248
+ # rather than a deepagents-code extra. Always exits.
3249
+ if args.install and args.package:
3250
+ from rich.markup import escape
3251
+
3252
+ from deepagents_code.config import _is_editable_install
3253
+ from deepagents_code.update_check import (
3254
+ create_update_log_path,
3255
+ editable_package_hint,
3256
+ is_valid_package_name,
3257
+ perform_install_package,
3258
+ )
3259
+
3260
+ package: str = args.install
3261
+ pkg_log_path: Path | None = None
3262
+ try:
3263
+ if not is_valid_package_name(package):
3264
+ # Defense in depth — the package is interpolated into a
3265
+ # shell command. Reject malformed names before any prompt
3266
+ # or uv call, even with --yes.
3267
+ console.print(
3268
+ f"[bold red]Error:[/bold red] "
3269
+ f"Invalid package name '{escape(package)}'. "
3270
+ "Package names must be alphanumeric with `-`, `_`, "
3271
+ "or `.` (PEP 508).",
3272
+ highlight=False,
3273
+ )
3274
+ sys.exit(2)
3275
+ if _is_editable_install():
3276
+ console.print(
3277
+ "[bold yellow]Warning:[/bold yellow] "
3278
+ "--install --package is not supported on editable "
3279
+ "installs.\n" + escape(editable_package_hint(package)),
3280
+ highlight=False,
3281
+ )
3282
+ sys.exit(1)
3283
+
3284
+ # Arbitrary packages have no curated allowlist to vet against,
3285
+ # so confirm before pulling third-party code into the tool env.
3286
+ console.print(
3287
+ f"This will install the package '{escape(package)}' into "
3288
+ "the dcode environment (this runs third-party "
3289
+ "code).",
3290
+ highlight=False,
3291
+ )
3292
+ if not args.yes:
3293
+ if not sys.stdin.isatty():
3294
+ console.print(
3295
+ "[bold red]Error:[/bold red] "
3296
+ "Refusing package install in non-interactive mode. "
3297
+ "Pass --yes to proceed."
3298
+ )
3299
+ sys.exit(2)
3300
+ try:
3301
+ reply = input(f"Install package '{package}'? [y/N] ")
3302
+ except EOFError:
3303
+ console.print("\nAborted.", style="dim")
3304
+ sys.exit(130)
3305
+ if reply.strip().lower() not in {"y", "yes"}:
3306
+ console.print("Aborted.", style="dim")
3307
+ sys.exit(1)
3308
+
3309
+ console.print(f"Installing package '{package}'...")
3310
+ pkg_log_path = create_update_log_path()
3311
+ console.print(
3312
+ f"Install log: {_tail_log_command(pkg_log_path)}",
3313
+ style="dim",
3314
+ highlight=False,
3315
+ markup=False,
3316
+ )
3317
+ success, output = asyncio.run(
3318
+ perform_install_package(package, log_path=pkg_log_path)
3319
+ )
3320
+ if success:
3321
+ console.print(f"[green]Installed package '{package}'.[/green]")
3322
+ sys.exit(0)
3323
+ # Tail the last 200 chars — uv prints the resolved error at the
3324
+ # end. The full output is in the log.
3325
+ detail = f": {output[-200:]}" if output else ""
3326
+ console.print(
3327
+ f"[bold red]Install failed[/bold red]{escape(detail)}\n"
3328
+ f"Log: {pkg_log_path}",
3329
+ markup=True,
3330
+ highlight=False,
3331
+ )
3332
+ sys.exit(1)
3333
+ except KeyboardInterrupt:
3334
+ console.print("\nAborted.", style="dim")
3335
+ sys.exit(130)
3336
+ except Exception as exc:
3337
+ logger.warning("--install --package failed", exc_info=True)
3338
+ log_line = f"\nLog: {pkg_log_path}" if pkg_log_path else ""
3339
+ console.print(
3340
+ f"[bold red]Error:[/bold red] "
3341
+ f"{type(exc).__name__}: {escape(str(exc))}"
3342
+ f"{escape(log_line)}",
3343
+ markup=True,
3344
+ highlight=False,
3345
+ )
3346
+ sys.exit(1)
3347
+
3348
+ # Handle --install <extra> flag (headless, no session)
3349
+ if args.install:
3350
+ from rich.markup import escape
3351
+
3352
+ from deepagents_code.config import _is_editable_install
3353
+ from deepagents_code.extras_info import (
3354
+ KNOWN_EXTRAS,
3355
+ ExtrasIntrospectionError,
3356
+ )
3357
+ from deepagents_code.update_check import (
3358
+ ToolRequirementIntrospectionError,
3359
+ create_update_log_path,
3360
+ editable_extra_hint,
3361
+ install_extra_command,
3362
+ install_extra_recovery_command,
3363
+ install_extras_command,
3364
+ is_valid_extra_name,
3365
+ perform_install_extra,
3366
+ )
3367
+
3368
+ extra: str = args.install
3369
+ log_path: Path | None = None
3370
+ manual_cmd: str | None = None
3371
+ try:
3372
+ if not is_valid_extra_name(extra):
3373
+ # Defense in depth — the extra is interpolated into a
3374
+ # shell command. Reject malformed names before any
3375
+ # confirmation prompt, even with --yes.
3376
+ console.print(
3377
+ f"[bold red]Error:[/bold red] "
3378
+ f"Invalid extra name '{escape(extra)}'. "
3379
+ "Extra names must be alphanumeric with `-`, `_`, "
3380
+ "or `.` (PEP 508).",
3381
+ highlight=False,
3382
+ )
3383
+ sys.exit(2)
3384
+ if _is_editable_install():
3385
+ console.print(
3386
+ "[bold yellow]Warning:[/bold yellow] "
3387
+ "--install is not supported on editable installs.\n"
3388
+ + escape(editable_extra_hint(extra)),
3389
+ highlight=False,
3390
+ )
3391
+ sys.exit(1)
3392
+
3393
+ manual_cmd = install_extra_command(extra)
3394
+ # KNOWN_EXTRAS is a curated "did you mean" list, not the
3395
+ # authoritative set (that's pyproject, resolved by uv): warn and
3396
+ # confirm rather than refuse, since valid-but-unlisted names
3397
+ # exist (e.g. all-providers). Malformed names blocked above.
3398
+ if extra not in KNOWN_EXTRAS:
3399
+ known = ", ".join(sorted(KNOWN_EXTRAS))
3400
+ console.print(
3401
+ f"[bold yellow]Warning:[/bold yellow] "
3402
+ f"'{extra}' is not a known extra.\n"
3403
+ f"Known extras: {known}",
3404
+ highlight=False,
3405
+ )
3406
+ if not args.yes:
3407
+ if not sys.stdin.isatty():
3408
+ console.print(
3409
+ "[bold red]Error:[/bold red] "
3410
+ "Refusing unknown extra in non-interactive "
3411
+ "mode. Pass --yes to override."
3412
+ )
3413
+ sys.exit(2)
3414
+ reply = input("Continue anyway? [y/N] ").strip().lower()
3415
+ if reply not in {"y", "yes"}:
3416
+ console.print("Aborted.", style="dim")
3417
+ sys.exit(1)
3418
+
3419
+ console.print(f"Installing extra '{extra}'...")
3420
+ log_path = create_update_log_path()
3421
+ console.print(
3422
+ f"Install log: {_tail_log_command(log_path)}",
3423
+ style="dim",
3424
+ highlight=False,
3425
+ markup=False,
3426
+ )
3427
+ success, output = asyncio.run(
3428
+ perform_install_extra(extra, log_path=log_path)
3429
+ )
3430
+ if success:
3431
+ console.print(f"[green]Installed extra '{extra}'.[/green]")
3432
+ sys.exit(0)
3433
+ # Tail the last 200 chars — uv resolver prints the resolved
3434
+ # error at the end, not the beginning.
3435
+ detail = f": {output[-200:]}" if output else ""
3436
+ try:
3437
+ manual_cmd = install_extra_recovery_command(extra)
3438
+ except (
3439
+ ExtrasIntrospectionError,
3440
+ ToolRequirementIntrospectionError,
3441
+ ValueError,
3442
+ ):
3443
+ logger.warning(
3444
+ "--install recovery command failed (install reported failure)",
3445
+ exc_info=True,
3446
+ )
3447
+ # Keep the install-script command bound above; fall back to a
3448
+ # bare extras command only if that was never set.
3449
+ manual_cmd = manual_cmd or install_extras_command((extra,))
3450
+ console.print(
3451
+ f"[bold red]Install failed[/bold red]{escape(detail)}\n"
3452
+ f"Log: {log_path}\n"
3453
+ f"Run manually: [cyan]{escape(manual_cmd)}[/cyan]",
3454
+ markup=True,
3455
+ highlight=False,
3456
+ )
3457
+ sys.exit(1)
3458
+ except KeyboardInterrupt:
3459
+ console.print("\nAborted.", style="dim")
3460
+ sys.exit(130)
3461
+ except Exception as exc:
3462
+ logger.warning("--install failed", exc_info=True)
3463
+ log_line = f"\nLog: {log_path}" if log_path else ""
3464
+ # This is the catch-all for any unexpected install failure, so
3465
+ # the recovery-hint guard is intentionally broad too: it must
3466
+ # never raise a second error over the original one. `manual_cmd`
3467
+ # may be unset here (the failure could predate its assignment),
3468
+ # so fall back to a bare extras command.
3469
+ try:
3470
+ fallback_cmd = install_extra_recovery_command(extra)
3471
+ except Exception: # best-effort hint, never re-raise here
3472
+ logger.warning(
3473
+ "--install recovery command failed (unexpected error)",
3474
+ exc_info=True,
3475
+ )
3476
+ fallback_cmd = manual_cmd or install_extras_command((extra,))
3477
+ console.print(
3478
+ f"[bold red]Error:[/bold red] "
3479
+ f"{type(exc).__name__}: {escape(str(exc))}"
3480
+ f"{escape(log_line)}\n"
3481
+ f"Run manually: [cyan]{escape(fallback_cmd)}[/cyan]",
3482
+ markup=True,
3483
+ highlight=False,
3484
+ )
3485
+ sys.exit(1)
3486
+
3487
+ # Handle --auto-update flag (headless toggle: reads current state
3488
+ # and inverts it, no session)
3489
+ if args.auto_update:
3490
+ try:
3491
+ from deepagents_code.config import _is_editable_install
3492
+ from deepagents_code.update_check import (
3493
+ is_auto_update_enabled,
3494
+ set_auto_update,
3495
+ )
3496
+
3497
+ if _is_editable_install():
3498
+ console.print(
3499
+ "[bold yellow]Warning:[/bold yellow] "
3500
+ "Auto-updates are not available for editable installs."
3501
+ )
3502
+ sys.exit(1)
3503
+
3504
+ currently_enabled = is_auto_update_enabled()
3505
+ new_state = not currently_enabled
3506
+ set_auto_update(new_state)
3507
+ label = "enabled" if new_state else "disabled"
3508
+ console.print(f"Auto-updates {label}.")
3509
+ except OSError:
3510
+ logger.warning("--auto-update failed: filesystem error", exc_info=True)
3511
+ console.print(
3512
+ "[bold red]Error:[/bold red] Failed to toggle auto-updates. "
3513
+ "Check permissions for ~/.deepagents/"
3514
+ )
3515
+ sys.exit(1)
3516
+ except Exception:
3517
+ logger.warning("--auto-update failed", exc_info=True)
3518
+ console.print(
3519
+ "[bold red]Error:[/bold red] Failed to toggle auto-updates."
3520
+ )
3521
+ sys.exit(1)
3522
+ sys.exit(0)
3523
+
3524
+ # Handle --default-model / --clear-default-model (headless, no session)
3525
+ if args.clear_default_model:
3526
+ from deepagents_code.model_config import clear_default_model
3527
+
3528
+ if clear_default_model():
3529
+ console.print("Default model cleared.")
3530
+ else:
3531
+ console.print(
3532
+ "[bold red]Error:[/bold red] Could not clear default model. "
3533
+ "Check permissions for ~/.deepagents/"
3534
+ )
3535
+ sys.exit(1)
3536
+ sys.exit(0)
3537
+
3538
+ if args.default_model is not None:
3539
+ from deepagents_code.model_config import (
3540
+ ModelConfig,
3541
+ save_default_model,
3542
+ )
3543
+
3544
+ if args.default_model == "__SHOW__":
3545
+ config = ModelConfig.load()
3546
+ if config.default_model:
3547
+ console.print(f"Default model: {config.default_model}")
3548
+ else:
3549
+ console.print("No default model set.")
3550
+ sys.exit(0)
3551
+
3552
+ model_spec = args.default_model
3553
+ # Auto-detect provider for bare model names
3554
+ from deepagents_code.config import detect_provider
3555
+ from deepagents_code.model_config import ModelSpec
3556
+
3557
+ parsed = ModelSpec.try_parse(model_spec)
3558
+ if not parsed:
3559
+ provider = detect_provider(model_spec)
3560
+ if provider:
3561
+ model_spec = f"{provider}:{model_spec}"
3562
+
3563
+ if save_default_model(model_spec):
3564
+ console.print(f"Default model set to {model_spec}")
3565
+ else:
3566
+ console.print(
3567
+ "[bold red]Error:[/bold red] Could not save default model. "
3568
+ "Check permissions for ~/.deepagents/"
3569
+ )
3570
+ sys.exit(1)
3571
+ sys.exit(0)
3572
+
3573
+ output_format = getattr(args, "output_format", "text")
3574
+
3575
+ if args.command == "help":
3576
+ from deepagents_code.ui import show_help
3577
+
3578
+ show_help()
3579
+ elif args.command == "agents":
3580
+ from deepagents_code.agent import list_agents, reset_agent
3581
+ from deepagents_code.ui import show_agents_help
3582
+
3583
+ # "ls" is an argparse alias for "list"
3584
+ if args.agents_command in {"list", "ls"}:
3585
+ list_agents(output_format=output_format)
3586
+ elif args.agents_command == "reset":
3587
+ reset_agent(
3588
+ args.agent,
3589
+ args.source_agent,
3590
+ dry_run=args.dry_run,
3591
+ output_format=output_format,
3592
+ )
3593
+ else:
3594
+ show_agents_help()
3595
+ elif args.command == "skills":
3596
+ from deepagents_code.skills import execute_skills_command
3597
+
3598
+ execute_skills_command(args)
3599
+ elif args.command == "mcp":
3600
+ from deepagents_code.client.commands.mcp import (
3601
+ run_mcp_config,
3602
+ run_mcp_login,
3603
+ )
3604
+ from deepagents_code.ui import show_mcp_help
3605
+
3606
+ if args.mcp_command == "login":
3607
+ config_path = args.config_path or args.mcp_config
3608
+ if config_path and not args.config_path:
3609
+ print( # noqa: T201
3610
+ f"Using --mcp-config from top-level: {config_path}",
3611
+ file=sys.stderr,
3612
+ )
3613
+ sys.exit(
3614
+ asyncio.run(
3615
+ run_mcp_login(
3616
+ server=args.server,
3617
+ config_path=config_path,
3618
+ )
3619
+ )
3620
+ )
3621
+ if args.mcp_command == "config":
3622
+ sys.exit(run_mcp_config())
3623
+ show_mcp_help()
3624
+ elif args.command == "threads":
3625
+ from deepagents_code.sessions import (
3626
+ delete_thread_command,
3627
+ list_threads_command,
3628
+ )
3629
+ from deepagents_code.ui import show_threads_help
3630
+
3631
+ # "ls" is an argparse alias for "list" — argparse stores the
3632
+ # alias as-is in the namespace, so we must match both values.
3633
+ if args.threads_command in {"list", "ls"}:
3634
+ raw_cwd = getattr(args, "cwd", None)
3635
+ cwd_filter = _normalize_cwd_filter(raw_cwd)
3636
+ # Warn (but still query) when the user passed an explicit
3637
+ # `--cwd <path>` that does not exist on disk — otherwise a
3638
+ # typo would silently return "No threads found" with no hint.
3639
+ # Skip the check for the bare-flag (empty string) form, where
3640
+ # the path was generated from `Path.cwd()` and is known to
3641
+ # exist.
3642
+ if (
3643
+ raw_cwd not in {None, ""}
3644
+ and cwd_filter is not None
3645
+ and not Path(cwd_filter).exists()
3646
+ ):
3647
+ print( # noqa: T201
3648
+ f"Warning: --cwd path {cwd_filter!r} does not exist; "
3649
+ "filtering by stored metadata anyway.",
3650
+ file=sys.stderr,
3651
+ )
3652
+ asyncio.run(
3653
+ list_threads_command(
3654
+ agent_name=getattr(args, "agent", None),
3655
+ limit=getattr(args, "limit", None),
3656
+ sort_by=getattr(args, "sort", None),
3657
+ branch=getattr(args, "branch", None),
3658
+ cwd=cwd_filter,
3659
+ verbose=getattr(args, "verbose", False),
3660
+ relative=getattr(args, "relative", None),
3661
+ output_format=output_format,
3662
+ )
3663
+ )
3664
+ elif args.threads_command == "delete":
3665
+ asyncio.run(
3666
+ delete_thread_command(
3667
+ args.thread_id,
3668
+ dry_run=args.dry_run,
3669
+ output_format=output_format,
3670
+ )
3671
+ )
3672
+ else:
3673
+ # No subcommand provided, show threads help screen
3674
+ show_threads_help()
3675
+ elif args.non_interactive_message:
3676
+ # Resolve recent-agent fallback only for actual session launches.
3677
+ assistant_id = _resolve_agent_arg(args)
3678
+ # Check for optional tools before running agent (stderr so
3679
+ # --quiet piped output stays clean)
3680
+ try:
3681
+ from rich.console import Console as _Console
3682
+ except ImportError:
3683
+ logger.warning(
3684
+ "Could not import rich.console; skipping tool warnings",
3685
+ exc_info=True,
3686
+ )
3687
+ else:
3688
+ warn_console = None
3689
+ try:
3690
+ warn_console = _Console(stderr=True)
3691
+ missing_tools = check_optional_tools()
3692
+ if _should_ensure_managed_ripgrep():
3693
+ missing_tools = _auto_install_ripgrep_cli(
3694
+ warn_console, missing_tools
3695
+ )
3696
+ for tool in missing_tools:
3697
+ warn_console.print(
3698
+ f"[yellow]Warning:[/yellow] {format_tool_warning_cli(tool)}"
3699
+ )
3700
+ except Exception:
3701
+ logger.warning(
3702
+ "Optional-tools check failed unexpectedly", exc_info=True
3703
+ )
3704
+ # A swallowed failure here must not be fully silent: surface
3705
+ # one stderr line so a degraded grep is at least signposted.
3706
+ if warn_console is not None:
3707
+ with contextlib.suppress(Exception):
3708
+ warn_console.print(
3709
+ "[dim]Tool availability check skipped — see logs.[/dim]"
3710
+ )
3711
+ # Validate sandbox provider deps before spawning server subprocess
3712
+ if args.sandbox and args.sandbox != "none":
3713
+ from deepagents_code.integrations.sandbox_factory import (
3714
+ verify_sandbox_deps,
3715
+ )
3716
+
3717
+ try:
3718
+ verify_sandbox_deps(args.sandbox)
3719
+ except ImportError as exc:
3720
+ from rich.markup import escape
3721
+
3722
+ console.print(f"[bold red]Error:[/bold red] {escape(str(exc))}")
3723
+ sys.exit(1)
3724
+
3725
+ enable_interpreter = _resolve_interpreter_enabled(args)
3726
+ if enable_interpreter:
3727
+ _verify_interpreter_or_exit()
3728
+
3729
+ # Non-interactive mode - execute single task and exit
3730
+ from deepagents_code.client.non_interactive import run_non_interactive
3731
+
3732
+ interpreter_ptc = _parse_interpreter_tools_flag(
3733
+ getattr(args, "interpreter_tools", None)
3734
+ )
3735
+ _warn_if_interpreter_tools_without_interpreter(
3736
+ args, enable_interpreter=enable_interpreter
3737
+ )
3738
+ _warn_if_interpreter_disabled_by_sandbox(args)
3739
+
3740
+ try:
3741
+ rubric_text = _resolve_rubric_text(getattr(args, "rubric", None))
3742
+ except ValueError as exc:
3743
+ from rich.console import Console as _Console
3744
+
3745
+ _Console(stderr=True).print(f"[bold red]Error:[/bold red] {exc}")
3746
+ sys.exit(2)
3747
+
3748
+ timeout = getattr(args, "timeout", None)
3749
+ try:
3750
+ exit_code = asyncio.run(
3751
+ asyncio.wait_for(
3752
+ run_non_interactive(
3753
+ message=args.non_interactive_message,
3754
+ assistant_id=assistant_id,
3755
+ model_name=getattr(args, "model", None),
3756
+ model_params=model_params,
3757
+ profile_override=profile_override,
3758
+ sandbox_type=args.sandbox,
3759
+ sandbox_id=args.sandbox_id,
3760
+ sandbox_snapshot_name=args.sandbox_snapshot_name,
3761
+ sandbox_setup=getattr(args, "sandbox_setup", None),
3762
+ initial_skill=getattr(args, "initial_skill", None),
3763
+ startup_cmd=getattr(args, "startup_cmd", None),
3764
+ quiet=args.quiet,
3765
+ stream=not args.no_stream,
3766
+ mcp_config_path=getattr(args, "mcp_config", None),
3767
+ no_mcp=getattr(args, "no_mcp", False),
3768
+ trust_project_mcp=getattr(args, "trust_project_mcp", False),
3769
+ enable_interpreter=enable_interpreter,
3770
+ interpreter_ptc=interpreter_ptc,
3771
+ max_turns=getattr(args, "max_turns", None),
3772
+ rubric=rubric_text,
3773
+ rubric_model=getattr(args, "rubric_model", None),
3774
+ rubric_max_iterations=getattr(
3775
+ args, "rubric_max_iterations", None
3776
+ ),
3777
+ ),
3778
+ timeout=timeout,
3779
+ )
3780
+ )
3781
+ except TimeoutError:
3782
+ # `asyncio.wait_for` raises `asyncio.TimeoutError`, which is
3783
+ # an alias of the builtin on Python >= 3.11 (the project's
3784
+ # minimum).
3785
+ from rich.console import Console as _Console
3786
+
3787
+ _Console(stderr=True).print(
3788
+ f"[bold red]Error:[/bold red] agent timed out after "
3789
+ f"{timeout}s. Retry with a larger --timeout, or use "
3790
+ "--max-turns for a turn-count limit."
3791
+ )
3792
+ sys.exit(124)
3793
+ except KeyboardInterrupt:
3794
+ # `asyncio.run` re-raises `KeyboardInterrupt` past the inner
3795
+ # `run_non_interactive` handler when the signal hits during
3796
+ # `wait_for`; mirror its exit code 130 here so Ctrl-C is a
3797
+ # quiet exit instead of a traceback.
3798
+ sys.exit(130)
3799
+ sys.exit(exit_code)
3800
+ else:
3801
+ resume_thread = args.resume_thread # "__MOST_RECENT__", "<id>", or None
3802
+ if resume_thread is None:
3803
+ # A normal (non-resume) launch runs the update path and resets
3804
+ # the resume grace period, so a later resume-only stretch starts
3805
+ # a fresh deferral window rather than inheriting a stale one.
3806
+ from deepagents_code.update_check import (
3807
+ clear_resume_auto_update_deferral,
3808
+ )
3809
+
3810
+ clear_resume_auto_update_deferral()
3811
+ _run_startup_auto_update(console)
3812
+ else:
3813
+ # Keep immediate resume launches uninterrupted, but do not let
3814
+ # a resume-only workflow bypass startup updates indefinitely.
3815
+ from deepagents_code.update_check import (
3816
+ should_defer_startup_auto_update_for_resume,
3817
+ )
3818
+
3819
+ if not should_defer_startup_auto_update_for_resume():
3820
+ _run_startup_auto_update(console)
3821
+ # Resolve recent-agent fallback only for actual session launches.
3822
+ assistant_id = _resolve_agent_arg(args)
3823
+ # Interactive mode - handle thread resume
3824
+ from rich.text import Text
3825
+
3826
+ from deepagents_code.sessions import generate_thread_id
3827
+
3828
+ # Instead of resolving thread_id here with synchronous asyncio.run()
3829
+ # DB calls, pass the raw resume request to the TUI and let it
3830
+ # resolve asynchronously during startup.
3831
+ thread_id = None if resume_thread else generate_thread_id()
3832
+
3833
+ # Validate sandbox provider deps before spawning server subprocess
3834
+ if args.sandbox and args.sandbox != "none":
3835
+ from deepagents_code.integrations.sandbox_factory import (
3836
+ verify_sandbox_deps,
3837
+ )
3838
+
3839
+ try:
3840
+ verify_sandbox_deps(args.sandbox)
3841
+ except ImportError as exc:
3842
+ from rich.markup import escape
3843
+
3844
+ console.print(f"[bold red]Error:[/bold red] {escape(str(exc))}")
3845
+ sys.exit(1)
3846
+
3847
+ enable_interpreter = _resolve_interpreter_enabled(args)
3848
+ if enable_interpreter:
3849
+ _verify_interpreter_or_exit()
3850
+
3851
+ # Check project MCP trust before launching TUI
3852
+ mcp_trust_decision = _check_mcp_project_trust(
3853
+ trust_flag=getattr(args, "trust_project_mcp", False),
3854
+ )
3855
+ if _debug_mcp_project_trust_enabled():
3856
+ sys.exit(0)
3857
+
3858
+ # Run Textual TUI
3859
+ return_code = 0
3860
+ try:
3861
+ interpreter_ptc = _parse_interpreter_tools_flag(
3862
+ getattr(args, "interpreter_tools", None)
3863
+ )
3864
+ # A stderr warning here would be clobbered by the alternate
3865
+ # screen the moment the TUI launches; the app surfaces the
3866
+ # advisory as a startup notification instead (see
3867
+ # `DeepAgentsApp._notify_interpreter_tools_without_interpreter`).
3868
+
3869
+ # An explicit -y/--auto-approve wins; otherwise the persistent
3870
+ # [startup].mode config default decides the launch mode.
3871
+ auto_approve = _resolve_auto_approve(args)
3872
+
3873
+ result = asyncio.run(
3874
+ run_textual_cli_async(
3875
+ assistant_id=assistant_id,
3876
+ auto_approve=auto_approve,
3877
+ sandbox_type=args.sandbox,
3878
+ sandbox_id=args.sandbox_id,
3879
+ sandbox_snapshot_name=args.sandbox_snapshot_name,
3880
+ sandbox_setup=getattr(args, "sandbox_setup", None),
3881
+ model_name=getattr(args, "model", None),
3882
+ model_params=model_params,
3883
+ profile_override=profile_override,
3884
+ thread_id=thread_id,
3885
+ resume_thread=resume_thread,
3886
+ initial_prompt=getattr(args, "initial_prompt", None),
3887
+ initial_skill=getattr(args, "initial_skill", None),
3888
+ initial_goal=getattr(args, "goal", None),
3889
+ startup_cmd=getattr(args, "startup_cmd", None),
3890
+ mcp_config_path=getattr(args, "mcp_config", None),
3891
+ no_mcp=getattr(args, "no_mcp", False),
3892
+ trust_project_mcp=mcp_trust_decision,
3893
+ enable_interpreter=enable_interpreter,
3894
+ interpreter_arg=args.interpreter,
3895
+ interpreter_ptc=interpreter_ptc,
3896
+ mouse=not _resolve_no_mouse(args),
3897
+ )
3898
+ )
3899
+ return_code = result.return_code
3900
+ # The user may have switched threads via /threads during the
3901
+ # session; use the final thread ID for teardown messages.
3902
+ thread_id = result.thread_id or thread_id
3903
+ _print_session_stats(result.session_stats, console)
3904
+ except Exception as e: # noqa: BLE001 # Top-level error handler for the application
3905
+ error_msg = Text("\nApplication error: ", style="red")
3906
+ error_msg.append(str(e))
3907
+ console.print(error_msg)
3908
+ console.print(Text(traceback.format_exc(), style="dim"))
3909
+ sys.exit(1)
3910
+
3911
+ # Show LangSmith thread link and resume hint for threads with
3912
+ # checkpointed content. The `thread_id is not None` check narrows the
3913
+ # type to `str` for the helper; `_should_check_teardown_thread` gates
3914
+ # whether the teardown lookup runs at all.
3915
+ if thread_id is not None and _should_check_teardown_thread(
3916
+ thread_id,
3917
+ request_count=result.session_stats.request_count,
3918
+ resume_thread=args.resume_thread,
3919
+ ):
3920
+ _render_teardown_thread_hints(
3921
+ console, thread_id, return_code=return_code
3922
+ )
3923
+
3924
+ # Warn about available update on exit
3925
+ try:
3926
+ if result.update_available[0]:
3927
+ from deepagents_code._version import __version__ as cli_version
3928
+ from deepagents_code.update_check import (
3929
+ format_installed_age_suffix,
3930
+ format_release_age_parenthetical,
3931
+ is_auto_update_enabled,
3932
+ is_installed_version_at_least,
3933
+ mark_update_notified,
3934
+ should_notify_update,
3935
+ upgrade_command,
3936
+ )
3937
+
3938
+ latest = result.update_available[1]
3939
+ if (
3940
+ latest
3941
+ and not is_installed_version_at_least(latest)
3942
+ and should_notify_update(latest)
3943
+ ):
3944
+ console.print()
3945
+ release_age = format_release_age_parenthetical(latest)
3946
+ installed_age = format_installed_age_suffix(cli_version)
3947
+ update_msg = Text(
3948
+ f"Update available: v{latest}", style="yellow bold"
3949
+ )
3950
+ update_msg.append(
3951
+ f"{release_age}. "
3952
+ f"Currently installed: {cli_version}{installed_age}.",
3953
+ style="dim",
3954
+ )
3955
+ console.print(update_msg)
3956
+ cmd_hint = Text("Run: ", style="dim")
3957
+ cmd_hint.append(upgrade_command(), style="cyan")
3958
+ console.print(cmd_hint)
3959
+ if not is_auto_update_enabled():
3960
+ auto_hint = Text("Enable auto-updates: ", style="dim")
3961
+ auto_hint.append("dcode --auto-update", style="cyan")
3962
+ console.print(auto_hint)
3963
+ mark_update_notified(latest)
3964
+ except Exception:
3965
+ logger.warning("Failed to display exit update banner", exc_info=True)
3966
+ except KeyboardInterrupt:
3967
+ # Clean exit on Ctrl+C — suppress ugly traceback.
3968
+ # `console` may not be bound if Ctrl+C arrives during config import.
3969
+ try:
3970
+ from deepagents_code import session_end_summary
3971
+
3972
+ session_end_summary.mark_reason(
3973
+ session_end_summary.REASON_INTERRUPTED, "KeyboardInterrupt"
3974
+ )
3975
+ except Exception:
3976
+ pass
3977
+ try:
3978
+ console.print("\n\n[yellow]Interrupted[/yellow]")
3979
+ except NameError:
3980
+ sys.stderr.write("\n\nInterrupted\n")
3981
+ sys.exit(0)
3982
+
3983
+
3984
+ if __name__ == "__main__":
3985
+ cli_main()