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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
deepagents_code/main.py
ADDED
|
@@ -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()
|