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
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""Auto-install pinned upstream binaries for optional tools.
|
|
2
|
+
|
|
3
|
+
Today this only manages `ripgrep`. The SDK shells out to `rg` via `PATH`,
|
|
4
|
+
so installing into `~/.deepagents/bin/` and prepending that directory to
|
|
5
|
+
`os.environ["PATH"]` is sufficient — no SDK change required.
|
|
6
|
+
|
|
7
|
+
The pinned `RIPGREP_VERSION` and `RIPGREP_ASSETS` table is the single
|
|
8
|
+
source of truth for what gets downloaded and verified. When bumping the
|
|
9
|
+
version, refresh both the version and the SHA-256 entries together.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import TYPE_CHECKING, Literal
|
|
19
|
+
|
|
20
|
+
from deepagents_code._env_vars import OFFLINE, RIPGREP_INSTALLER, is_env_truthy
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import tarfile
|
|
24
|
+
import zipfile
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
RIPGREP_VERSION = "14.1.1"
|
|
29
|
+
"""Pinned upstream ripgrep release. Bump alongside `RIPGREP_ASSETS`."""
|
|
30
|
+
|
|
31
|
+
_RELEASE_URL_PREFIX = (
|
|
32
|
+
"https://github.com/BurntSushi/ripgrep/releases/download/" + RIPGREP_VERSION
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
RIPGREP_ASSETS: dict[tuple[str, str], tuple[str, str]] = {
|
|
36
|
+
("darwin", "arm64"): (
|
|
37
|
+
f"ripgrep-{RIPGREP_VERSION}-aarch64-apple-darwin.tar.gz",
|
|
38
|
+
"24ad76777745fbff131c8fbc466742b011f925bfa4fffa2ded6def23b5b937be",
|
|
39
|
+
),
|
|
40
|
+
("darwin", "x86_64"): (
|
|
41
|
+
f"ripgrep-{RIPGREP_VERSION}-x86_64-apple-darwin.tar.gz",
|
|
42
|
+
"fc87e78f7cb3fea12d69072e7ef3b21509754717b746368fd40d88963630e2b3",
|
|
43
|
+
),
|
|
44
|
+
("linux", "arm64"): (
|
|
45
|
+
f"ripgrep-{RIPGREP_VERSION}-aarch64-unknown-linux-gnu.tar.gz",
|
|
46
|
+
"c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f",
|
|
47
|
+
),
|
|
48
|
+
("linux", "x86_64"): (
|
|
49
|
+
f"ripgrep-{RIPGREP_VERSION}-x86_64-unknown-linux-musl.tar.gz",
|
|
50
|
+
"4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e",
|
|
51
|
+
),
|
|
52
|
+
# Windows on ARM runs x64 binaries via emulation; upstream does not
|
|
53
|
+
# ship an arm64-windows build for ripgrep, so both Windows entries
|
|
54
|
+
# point at the same x86_64 MSVC asset.
|
|
55
|
+
("win32", "arm64"): (
|
|
56
|
+
f"ripgrep-{RIPGREP_VERSION}-x86_64-pc-windows-msvc.zip",
|
|
57
|
+
"d0f534024c42afd6cb4d38907c25cd2b249b79bbe6cc1dbee8e3e37c2b6e25a1",
|
|
58
|
+
),
|
|
59
|
+
("win32", "x86_64"): (
|
|
60
|
+
f"ripgrep-{RIPGREP_VERSION}-x86_64-pc-windows-msvc.zip",
|
|
61
|
+
"d0f534024c42afd6cb4d38907c25cd2b249b79bbe6cc1dbee8e3e37c2b6e25a1",
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
"""`(sys.platform, normalized arch) -> (asset filename, sha256 hex)`."""
|
|
65
|
+
|
|
66
|
+
BIN_DIR: Path = Path.home() / ".zjcode" / "bin"
|
|
67
|
+
"""Directory holding managed binaries. Prepended to `PATH` on startup."""
|
|
68
|
+
|
|
69
|
+
_DOWNLOAD_TIMEOUT_SECONDS = 120
|
|
70
|
+
_VERSION_CHECK_TIMEOUT_SECONDS = 5
|
|
71
|
+
_DOWNLOAD_CHUNK_BYTES = 1 << 16
|
|
72
|
+
_ARCH_ALIASES = {
|
|
73
|
+
"aarch64": "arm64",
|
|
74
|
+
"arm64": "arm64",
|
|
75
|
+
"amd64": "x86_64",
|
|
76
|
+
"x86_64": "x86_64",
|
|
77
|
+
"x64": "x86_64",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ChecksumMismatchError(Exception):
|
|
82
|
+
"""Raised when a downloaded archive fails SHA-256 verification.
|
|
83
|
+
|
|
84
|
+
Distinct from generic install failure so callers can surface a loud,
|
|
85
|
+
user-visible notice — a checksum mismatch is a supply-chain anomaly
|
|
86
|
+
(CDN poisoning, MITM, tampered mirror) and must not be silently
|
|
87
|
+
treated like "you're offline".
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
UnavailableReason = Literal["unsupported", "artifact_not_found"]
|
|
92
|
+
"""Stable reason token for logging and telemetry."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ManagedToolUnavailableError(Exception):
|
|
96
|
+
"""Raised when no managed helper binary is available for this system.
|
|
97
|
+
|
|
98
|
+
Distinct from transient network/download failures so callers can tell users
|
|
99
|
+
whether retrying can help or whether they need a different install path.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self, *, tool: Literal["ripgrep"], reason: UnavailableReason, message: str
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Initialize the unavailable-tool error.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
tool: Managed helper name.
|
|
109
|
+
reason: Stable reason token for logging and telemetry.
|
|
110
|
+
message: User-facing remediation message.
|
|
111
|
+
"""
|
|
112
|
+
super().__init__(message)
|
|
113
|
+
self.tool = tool
|
|
114
|
+
self.reason = reason
|
|
115
|
+
self.message = message
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _normalized_arch() -> str | None:
|
|
119
|
+
"""Return a normalized arch key matching `RIPGREP_ASSETS`.
|
|
120
|
+
|
|
121
|
+
Returns `None` for unsupported architectures (e.g. 32-bit, ppc, s390x).
|
|
122
|
+
"""
|
|
123
|
+
import platform
|
|
124
|
+
|
|
125
|
+
raw = platform.machine().lower()
|
|
126
|
+
return _ARCH_ALIASES.get(raw)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _unsupported_ripgrep_error(
|
|
130
|
+
platform_name: str, arch: str | None
|
|
131
|
+
) -> ManagedToolUnavailableError:
|
|
132
|
+
"""Return a clear unsupported-platform error for managed ripgrep."""
|
|
133
|
+
target = platform_name if arch is None else f"{platform_name}/{arch}"
|
|
134
|
+
return ManagedToolUnavailableError(
|
|
135
|
+
tool="ripgrep",
|
|
136
|
+
reason="unsupported",
|
|
137
|
+
message=(
|
|
138
|
+
f"Managed ripgrep is not available for this system ({target}). "
|
|
139
|
+
"Install ripgrep manually, or set DEEPAGENTS_CODE_RIPGREP_INSTALLER=system."
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _artifact_not_found_error(
|
|
145
|
+
platform_name: str, arch: str
|
|
146
|
+
) -> ManagedToolUnavailableError:
|
|
147
|
+
"""Return a clear missing-artifact error for managed ripgrep."""
|
|
148
|
+
return ManagedToolUnavailableError(
|
|
149
|
+
tool="ripgrep",
|
|
150
|
+
reason="artifact_not_found",
|
|
151
|
+
message=(
|
|
152
|
+
f"Managed ripgrep artifact for {platform_name}/{arch} was not found "
|
|
153
|
+
f"in pinned ripgrep {RIPGREP_VERSION}. Install ripgrep manually, or "
|
|
154
|
+
"try a newer dcode version."
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def managed_rg_path() -> Path:
|
|
160
|
+
"""Return the managed ripgrep binary path (`.exe` on Windows)."""
|
|
161
|
+
name = "rg.exe" if sys.platform == "win32" else "rg"
|
|
162
|
+
return BIN_DIR / name
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def is_offline() -> bool:
|
|
166
|
+
"""Return whether managed-tool downloads are disabled via env var."""
|
|
167
|
+
return is_env_truthy(OFFLINE)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
RipgrepInstaller = Literal["managed", "system"]
|
|
171
|
+
"""The two recognized ripgrep installer modes."""
|
|
172
|
+
|
|
173
|
+
INSTALLER_MANAGED: RipgrepInstaller = "managed"
|
|
174
|
+
"""Default installer mode: fetch the pinned, checksummed upstream binary."""
|
|
175
|
+
|
|
176
|
+
INSTALLER_SYSTEM: RipgrepInstaller = "system"
|
|
177
|
+
"""Installer mode that defers ripgrep to the system package manager / `PATH`."""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def ripgrep_installer() -> RipgrepInstaller:
|
|
181
|
+
"""Return the configured ripgrep installer mode.
|
|
182
|
+
|
|
183
|
+
Reads `RIPGREP_INSTALLER` and normalizes it to `INSTALLER_MANAGED` or
|
|
184
|
+
`INSTALLER_SYSTEM`, falling back to `INSTALLER_MANAGED` for unset or
|
|
185
|
+
unrecognized values. The `strip().lower()` normalization must stay in
|
|
186
|
+
sync with the `case` block in `scripts/install.sh` so both layers agree
|
|
187
|
+
on the parsed mode.
|
|
188
|
+
"""
|
|
189
|
+
raw = os.environ.get(RIPGREP_INSTALLER, "").strip().lower()
|
|
190
|
+
if raw == INSTALLER_SYSTEM:
|
|
191
|
+
return INSTALLER_SYSTEM
|
|
192
|
+
if raw and raw != INSTALLER_MANAGED:
|
|
193
|
+
logger.warning(
|
|
194
|
+
"Unrecognized %s=%r; expected %r or %r. Defaulting to %r.",
|
|
195
|
+
RIPGREP_INSTALLER,
|
|
196
|
+
raw,
|
|
197
|
+
INSTALLER_MANAGED,
|
|
198
|
+
INSTALLER_SYSTEM,
|
|
199
|
+
INSTALLER_MANAGED,
|
|
200
|
+
)
|
|
201
|
+
return INSTALLER_MANAGED
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def prefers_system_ripgrep() -> bool:
|
|
205
|
+
"""Return whether the user opted into the `system` ripgrep installer.
|
|
206
|
+
|
|
207
|
+
In `system` mode ripgrep is provisioned by the OS package manager or an
|
|
208
|
+
existing `PATH` entry rather than the managed download.
|
|
209
|
+
"""
|
|
210
|
+
return ripgrep_installer() == INSTALLER_SYSTEM
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def prepend_managed_bin_to_path() -> None:
|
|
214
|
+
"""Idempotently prepend `BIN_DIR` to `os.environ["PATH"]`.
|
|
215
|
+
|
|
216
|
+
Safe to call on every startup. Callers do not need to check whether
|
|
217
|
+
the directory exists — adding a non-existent directory to `PATH` is
|
|
218
|
+
harmless and matches behavior of common version managers.
|
|
219
|
+
"""
|
|
220
|
+
bin_str = str(BIN_DIR)
|
|
221
|
+
current = os.environ.get("PATH", "")
|
|
222
|
+
parts = current.split(os.pathsep) if current else []
|
|
223
|
+
if parts and parts[0] == bin_str:
|
|
224
|
+
return
|
|
225
|
+
parts = [bin_str, *(p for p in parts if p != bin_str)]
|
|
226
|
+
os.environ["PATH"] = os.pathsep.join(parts)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _path_without_managed_bin() -> str | None:
|
|
230
|
+
"""Return `PATH` with `BIN_DIR` removed."""
|
|
231
|
+
current = os.environ.get("PATH")
|
|
232
|
+
if not current:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
managed_dir = BIN_DIR.resolve()
|
|
236
|
+
parts = [
|
|
237
|
+
part
|
|
238
|
+
for part in current.split(os.pathsep)
|
|
239
|
+
if not part or Path(part).resolve() != managed_dir
|
|
240
|
+
]
|
|
241
|
+
return os.pathsep.join(parts)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _managed_binary_is_current(binary: Path) -> bool:
|
|
245
|
+
"""Return whether the on-disk managed `rg` matches `RIPGREP_VERSION`.
|
|
246
|
+
|
|
247
|
+
Returns `False` on any concrete failure (`OSError`, non-zero exit,
|
|
248
|
+
empty stdout, version mismatch) so a corrupted or wrong-arch
|
|
249
|
+
binary written by a previously crashed install gets re-fetched. Only
|
|
250
|
+
`TimeoutExpired` "falls open" — that case suggests a sandboxed
|
|
251
|
+
subprocess rather than a broken binary.
|
|
252
|
+
"""
|
|
253
|
+
import subprocess # noqa: S404 # fixed-argv probe of a managed binary
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
result = subprocess.run( # noqa: S603 # fixed argv, managed path
|
|
257
|
+
[str(binary), "--version"],
|
|
258
|
+
check=False,
|
|
259
|
+
capture_output=True,
|
|
260
|
+
text=True,
|
|
261
|
+
timeout=_VERSION_CHECK_TIMEOUT_SECONDS,
|
|
262
|
+
)
|
|
263
|
+
except subprocess.TimeoutExpired:
|
|
264
|
+
logger.debug("rg --version probe timed out for %s; assuming current", binary)
|
|
265
|
+
return True
|
|
266
|
+
except OSError:
|
|
267
|
+
logger.debug(
|
|
268
|
+
"rg --version probe failed for %s; treating as stale",
|
|
269
|
+
binary,
|
|
270
|
+
exc_info=True,
|
|
271
|
+
)
|
|
272
|
+
return False
|
|
273
|
+
if result.returncode != 0:
|
|
274
|
+
logger.debug(
|
|
275
|
+
"rg --version exited %d for %s; treating as stale",
|
|
276
|
+
result.returncode,
|
|
277
|
+
binary,
|
|
278
|
+
)
|
|
279
|
+
return False
|
|
280
|
+
first_line = (result.stdout or "").splitlines()[:1]
|
|
281
|
+
if not first_line:
|
|
282
|
+
return False
|
|
283
|
+
return RIPGREP_VERSION in first_line[0]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _download_to(url: str, dest: Path) -> None:
|
|
287
|
+
"""Stream `url` to `dest`, bounded by a wall-clock deadline.
|
|
288
|
+
|
|
289
|
+
`urlopen(timeout=...)` only bounds per-operation socket waits, so a
|
|
290
|
+
slow trickle of bytes from a flaky peer could otherwise stretch the
|
|
291
|
+
transfer well beyond the configured timeout. The chunked read here
|
|
292
|
+
enforces an end-to-end deadline, checked between chunk reads.
|
|
293
|
+
|
|
294
|
+
A non-200 response is rejected before any bytes are written: a proxy
|
|
295
|
+
interstitial or an unfollowed redirect returned with a non-200 status
|
|
296
|
+
must not be streamed to disk and then surface downstream as a
|
|
297
|
+
misleading SHA-256 failure (which reads as a supply-chain anomaly).
|
|
298
|
+
`urlopen` already raises `HTTPError` for 4xx/5xx, so this guards the
|
|
299
|
+
residual 2xx/3xx cases.
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
TimeoutError: When total transfer time exceeds the deadline.
|
|
303
|
+
urllib.error.URLError: When the response status is not 200.
|
|
304
|
+
"""
|
|
305
|
+
import time
|
|
306
|
+
import urllib.error
|
|
307
|
+
import urllib.request
|
|
308
|
+
|
|
309
|
+
deadline = time.monotonic() + _DOWNLOAD_TIMEOUT_SECONDS
|
|
310
|
+
with (
|
|
311
|
+
urllib.request.urlopen(url, timeout=_DOWNLOAD_TIMEOUT_SECONDS) as resp, # noqa: S310 # fixed https GitHub release URL
|
|
312
|
+
dest.open("wb") as fh,
|
|
313
|
+
):
|
|
314
|
+
status = getattr(resp, "status", None)
|
|
315
|
+
if status is not None and status != 200: # noqa: PLR2004 # HTTP 200 OK
|
|
316
|
+
msg = f"Unexpected HTTP {status} response fetching {url}"
|
|
317
|
+
raise urllib.error.URLError(msg)
|
|
318
|
+
while True:
|
|
319
|
+
if time.monotonic() > deadline:
|
|
320
|
+
msg = (
|
|
321
|
+
f"Download of {url} exceeded {_DOWNLOAD_TIMEOUT_SECONDS}s deadline"
|
|
322
|
+
)
|
|
323
|
+
raise TimeoutError(msg)
|
|
324
|
+
chunk = resp.read(_DOWNLOAD_CHUNK_BYTES)
|
|
325
|
+
if not chunk:
|
|
326
|
+
break
|
|
327
|
+
fh.write(chunk)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _verify_sha256(path: Path, expected_hex: str) -> None:
|
|
331
|
+
"""Verify `path` matches `expected_hex`.
|
|
332
|
+
|
|
333
|
+
Raises:
|
|
334
|
+
ChecksumMismatchError: When the SHA-256 of `path` differs from
|
|
335
|
+
`expected_hex`.
|
|
336
|
+
"""
|
|
337
|
+
import hashlib
|
|
338
|
+
|
|
339
|
+
digest = hashlib.sha256()
|
|
340
|
+
with path.open("rb") as fh:
|
|
341
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
342
|
+
digest.update(chunk)
|
|
343
|
+
actual = digest.hexdigest()
|
|
344
|
+
if actual != expected_hex:
|
|
345
|
+
msg = (
|
|
346
|
+
f"Checksum mismatch for {path.name}: expected {expected_hex}, got {actual}"
|
|
347
|
+
)
|
|
348
|
+
raise ChecksumMismatchError(msg)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _validate_legacy_tar_member(member: tarfile.TarInfo, extract_root: Path) -> None:
|
|
352
|
+
"""Reject tar members that cannot be safely extracted without filters.
|
|
353
|
+
|
|
354
|
+
Raises:
|
|
355
|
+
tarfile.TarError: If a member would extract outside `extract_root`
|
|
356
|
+
or uses a tar entry type this fallback does not support.
|
|
357
|
+
"""
|
|
358
|
+
import tarfile
|
|
359
|
+
|
|
360
|
+
target = extract_root / member.name
|
|
361
|
+
try:
|
|
362
|
+
target.resolve().relative_to(extract_root.resolve())
|
|
363
|
+
except ValueError as exc:
|
|
364
|
+
msg = f"Refusing to extract unsafe tar member {member.name!r}"
|
|
365
|
+
raise tarfile.TarError(msg) from exc
|
|
366
|
+
|
|
367
|
+
if not (member.isfile() or member.isdir()):
|
|
368
|
+
msg = f"Refusing to extract unsupported tar member {member.name!r}"
|
|
369
|
+
raise tarfile.TarError(msg)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _extract_tar_data(tf: tarfile.TarFile, extract_root: Path) -> None:
|
|
373
|
+
"""Extract a tar archive with `data` filtering when available.
|
|
374
|
+
|
|
375
|
+
Python versions before the PEP 706 backport (3.11.0-3.11.3) lack the
|
|
376
|
+
`filter` keyword on `extractall`, and this package supports those patch
|
|
377
|
+
versions. The fallback validates the pinned release archive before
|
|
378
|
+
using the legacy API.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
TypeError: Re-raised when `extractall` rejects a non-`filter`
|
|
382
|
+
keyword (i.e. an unrelated `TypeError` we should not swallow).
|
|
383
|
+
"""
|
|
384
|
+
try:
|
|
385
|
+
tf.extractall(extract_root, filter="data")
|
|
386
|
+
except TypeError as exc:
|
|
387
|
+
if "filter" not in str(exc):
|
|
388
|
+
raise
|
|
389
|
+
members = tf.getmembers()
|
|
390
|
+
for member in members:
|
|
391
|
+
_validate_legacy_tar_member(member, extract_root)
|
|
392
|
+
tf.extractall(extract_root, members=members) # noqa: S202 # validated above
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _extract_rg(archive: Path, extract_root: Path) -> Path:
|
|
396
|
+
"""Extract `archive` and locate the `rg` binary inside.
|
|
397
|
+
|
|
398
|
+
Handles both `.tar.gz` and `.zip` archives. Release archives nest the
|
|
399
|
+
binary under `ripgrep-<ver>-<triple>/`, so we walk the tree to find it
|
|
400
|
+
rather than hard-coding the prefix. Malformed archives or unsafe
|
|
401
|
+
members propagate `tarfile.TarError` / `zipfile.BadZipFile`.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Absolute path to the extracted `rg` (or `rg.exe`) binary.
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
FileNotFoundError: When the archive does not contain an `rg` binary.
|
|
408
|
+
"""
|
|
409
|
+
import tarfile
|
|
410
|
+
import zipfile
|
|
411
|
+
|
|
412
|
+
if archive.suffix == ".zip":
|
|
413
|
+
with zipfile.ZipFile(archive) as zf:
|
|
414
|
+
_extract_zip_validated(zf, extract_root)
|
|
415
|
+
else:
|
|
416
|
+
with tarfile.open(archive, mode="r:*") as tf:
|
|
417
|
+
_extract_tar_data(tf, extract_root)
|
|
418
|
+
|
|
419
|
+
target_name = "rg.exe" if sys.platform == "win32" else "rg"
|
|
420
|
+
for path in extract_root.rglob(target_name):
|
|
421
|
+
if path.is_file():
|
|
422
|
+
return path
|
|
423
|
+
msg = f"Could not find {target_name} inside {archive.name}"
|
|
424
|
+
raise FileNotFoundError(msg)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _extract_zip_validated(zf: zipfile.ZipFile, extract_root: Path) -> None:
|
|
428
|
+
"""Extract a zip archive after validating each member's path.
|
|
429
|
+
|
|
430
|
+
`ZipFile.extractall` does sanitize absolute paths and parent-relative
|
|
431
|
+
components on modern Python, but defense-in-depth here keeps the
|
|
432
|
+
SHA-256-verified archive from being the only line of defense against
|
|
433
|
+
a zip-slip variant in a future upstream archive.
|
|
434
|
+
|
|
435
|
+
Raises:
|
|
436
|
+
zipfile.BadZipFile: If a member would extract outside `extract_root`.
|
|
437
|
+
"""
|
|
438
|
+
import zipfile
|
|
439
|
+
|
|
440
|
+
extract_root.mkdir(parents=True, exist_ok=True)
|
|
441
|
+
root = extract_root.resolve()
|
|
442
|
+
for member in zf.infolist():
|
|
443
|
+
target = (extract_root / member.filename).resolve()
|
|
444
|
+
try:
|
|
445
|
+
target.relative_to(root)
|
|
446
|
+
except ValueError as exc:
|
|
447
|
+
msg = f"Refusing to extract unsafe zip member {member.filename!r}"
|
|
448
|
+
raise zipfile.BadZipFile(msg) from exc
|
|
449
|
+
zf.extractall(extract_root) # noqa: S202 # validated above
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _install_ripgrep_sync(asset: str, sha256: str) -> Path:
|
|
453
|
+
"""Download, verify, extract, and install ripgrep atomically.
|
|
454
|
+
|
|
455
|
+
Staging happens *inside* `BIN_DIR` so the final rename is on the same
|
|
456
|
+
filesystem and therefore atomic on POSIX. Windows keeps replacing the
|
|
457
|
+
user-facing `rg.exe` directly because symlink support varies by developer
|
|
458
|
+
mode and policy. POSIX installs use a versioned real binary plus a relative
|
|
459
|
+
`rg` symlink so moving or bind-mounting `~/.deepagents` does not bake in
|
|
460
|
+
the original home directory path. `_verify_sha256` propagates
|
|
461
|
+
`ChecksumMismatchError` to abort install before any move.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
Absolute path to the installed `rg` entrypoint.
|
|
465
|
+
"""
|
|
466
|
+
import os
|
|
467
|
+
import tempfile
|
|
468
|
+
|
|
469
|
+
BIN_DIR.mkdir(parents=True, exist_ok=True)
|
|
470
|
+
url = f"{_RELEASE_URL_PREFIX}/{asset}"
|
|
471
|
+
with tempfile.TemporaryDirectory(prefix=".deepagents-rg-", dir=BIN_DIR) as tmp_str:
|
|
472
|
+
tmp = Path(tmp_str)
|
|
473
|
+
archive = tmp / asset
|
|
474
|
+
_download_to(url, archive)
|
|
475
|
+
_verify_sha256(archive, sha256)
|
|
476
|
+
extracted = _extract_rg(archive, tmp / "unpacked")
|
|
477
|
+
if sys.platform != "win32":
|
|
478
|
+
extracted.chmod(0o755)
|
|
479
|
+
dest = managed_rg_path()
|
|
480
|
+
if sys.platform == "win32":
|
|
481
|
+
extracted.replace(dest)
|
|
482
|
+
return dest
|
|
483
|
+
|
|
484
|
+
real = BIN_DIR / f"rg-{RIPGREP_VERSION}"
|
|
485
|
+
extracted.replace(real)
|
|
486
|
+
link = tmp / "rg-link"
|
|
487
|
+
link.symlink_to(os.path.relpath(real, start=BIN_DIR))
|
|
488
|
+
link.replace(dest)
|
|
489
|
+
return dest
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
async def ensure_ripgrep() -> Path | None:
|
|
493
|
+
"""Ensure a usable `rg` binary is available, installing if necessary.
|
|
494
|
+
|
|
495
|
+
Resolution order:
|
|
496
|
+
|
|
497
|
+
1. If the `system` installer is selected, return a non-managed `rg`
|
|
498
|
+
found on `PATH`, or `None` when only the managed binary is present.
|
|
499
|
+
2. If a managed `rg` exists *and* matches `RIPGREP_VERSION`, return it.
|
|
500
|
+
3. Otherwise, if a system `rg` is on `PATH` and no managed binary
|
|
501
|
+
exists, return its resolved path. This is gated on the *absence*
|
|
502
|
+
of a managed binary: once a managed `rg` exists, the pinned
|
|
503
|
+
version always wins, so a stale managed binary is re-fetched
|
|
504
|
+
rather than deferring to a system `rg` and the resolved version
|
|
505
|
+
stays deterministic.
|
|
506
|
+
4. If offline, return `None` so callers fall back to the existing
|
|
507
|
+
notification + slow path.
|
|
508
|
+
5. If no managed asset matches the platform/arch, return a non-managed
|
|
509
|
+
`rg` on `PATH` when one exists; otherwise raise
|
|
510
|
+
`ManagedToolUnavailableError` so callers can explain that retrying will
|
|
511
|
+
not help.
|
|
512
|
+
6. Otherwise download → SHA-256 verify → extract → install →
|
|
513
|
+
prepend `BIN_DIR` to `PATH` → return the installed path. On a
|
|
514
|
+
checksum mismatch, raises `ChecksumMismatchError` so callers can
|
|
515
|
+
surface a loud notice. On a 404, raises `ManagedToolUnavailableError`;
|
|
516
|
+
other failures log and return `None`.
|
|
517
|
+
|
|
518
|
+
A stale managed binary is never proactively deleted. The atomic
|
|
519
|
+
replace in `_install_ripgrep_sync` overwrites it on success, and on
|
|
520
|
+
failure the user is strictly better off keeping the older copy than
|
|
521
|
+
being left with no `rg` at all.
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
Path to a usable `rg` binary, or `None` when one could not be
|
|
525
|
+
located or installed.
|
|
526
|
+
"""
|
|
527
|
+
import asyncio
|
|
528
|
+
import platform
|
|
529
|
+
import shutil
|
|
530
|
+
import tarfile
|
|
531
|
+
import urllib.error
|
|
532
|
+
import zipfile
|
|
533
|
+
|
|
534
|
+
managed = managed_rg_path()
|
|
535
|
+
managed_exists = managed.exists() or managed.is_symlink()
|
|
536
|
+
|
|
537
|
+
def non_managed_rg() -> Path | None:
|
|
538
|
+
system_rg = shutil.which("rg", path=_path_without_managed_bin())
|
|
539
|
+
if system_rg is not None:
|
|
540
|
+
return Path(system_rg)
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
if prefers_system_ripgrep():
|
|
544
|
+
system_rg_path = non_managed_rg()
|
|
545
|
+
if system_rg_path is not None:
|
|
546
|
+
return system_rg_path
|
|
547
|
+
logger.debug(
|
|
548
|
+
"Skipping managed ripgrep download: %s=%s",
|
|
549
|
+
RIPGREP_INSTALLER,
|
|
550
|
+
INSTALLER_SYSTEM,
|
|
551
|
+
)
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
if managed_exists and _managed_binary_is_current(managed):
|
|
555
|
+
return managed
|
|
556
|
+
|
|
557
|
+
if not managed_exists:
|
|
558
|
+
system_rg = shutil.which("rg")
|
|
559
|
+
if system_rg is not None:
|
|
560
|
+
return Path(system_rg)
|
|
561
|
+
|
|
562
|
+
if is_offline():
|
|
563
|
+
logger.debug("Skipping ripgrep install: %s is set", OFFLINE)
|
|
564
|
+
return None
|
|
565
|
+
if sys.platform == "android":
|
|
566
|
+
logger.debug("Skipping ripgrep install: unsupported platform 'android'")
|
|
567
|
+
system_rg_path = non_managed_rg()
|
|
568
|
+
if system_rg_path is not None:
|
|
569
|
+
return system_rg_path
|
|
570
|
+
error = _unsupported_ripgrep_error(sys.platform, None)
|
|
571
|
+
raise error
|
|
572
|
+
|
|
573
|
+
arch = _normalized_arch()
|
|
574
|
+
if arch is None:
|
|
575
|
+
logger.debug(
|
|
576
|
+
"Skipping ripgrep install: unsupported arch %r", platform.machine()
|
|
577
|
+
)
|
|
578
|
+
system_rg_path = non_managed_rg()
|
|
579
|
+
if system_rg_path is not None:
|
|
580
|
+
return system_rg_path
|
|
581
|
+
error = _unsupported_ripgrep_error(sys.platform, platform.machine())
|
|
582
|
+
raise error
|
|
583
|
+
|
|
584
|
+
asset_entry = RIPGREP_ASSETS.get((sys.platform, arch))
|
|
585
|
+
if asset_entry is None:
|
|
586
|
+
logger.debug(
|
|
587
|
+
"Skipping ripgrep install: no asset for (%s, %s)", sys.platform, arch
|
|
588
|
+
)
|
|
589
|
+
system_rg_path = non_managed_rg()
|
|
590
|
+
if system_rg_path is not None:
|
|
591
|
+
return system_rg_path
|
|
592
|
+
error = _unsupported_ripgrep_error(sys.platform, arch)
|
|
593
|
+
raise error
|
|
594
|
+
asset, sha256 = asset_entry
|
|
595
|
+
|
|
596
|
+
if managed_exists:
|
|
597
|
+
logger.info(
|
|
598
|
+
"Managed ripgrep at %s is stale; replacing with %s",
|
|
599
|
+
managed,
|
|
600
|
+
RIPGREP_VERSION,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
# `_install_ripgrep_sync` atomically replaces the destination on
|
|
605
|
+
# success, so we deliberately leave any stale binary in place
|
|
606
|
+
# until the verified replacement is ready. A failed download must
|
|
607
|
+
# not strand the user with no `rg` at all.
|
|
608
|
+
installed = await asyncio.to_thread(_install_ripgrep_sync, asset, sha256)
|
|
609
|
+
except urllib.error.HTTPError as exc:
|
|
610
|
+
if exc.code == 404: # noqa: PLR2004 # HTTP 404 Not Found
|
|
611
|
+
logger.warning(
|
|
612
|
+
"Managed ripgrep artifact was not found: %s/%s", sys.platform, arch
|
|
613
|
+
)
|
|
614
|
+
error = _artifact_not_found_error(sys.platform, arch)
|
|
615
|
+
raise error from exc
|
|
616
|
+
logger.warning(
|
|
617
|
+
"Could not download ripgrep from %s", _RELEASE_URL_PREFIX, exc_info=True
|
|
618
|
+
)
|
|
619
|
+
return None
|
|
620
|
+
except (urllib.error.URLError, TimeoutError):
|
|
621
|
+
logger.warning(
|
|
622
|
+
"Could not download ripgrep from %s", _RELEASE_URL_PREFIX, exc_info=True
|
|
623
|
+
)
|
|
624
|
+
return None
|
|
625
|
+
except (tarfile.TarError, zipfile.BadZipFile, FileNotFoundError) as exc:
|
|
626
|
+
logger.exception(
|
|
627
|
+
"ripgrep install failed: archive error (%s)", type(exc).__name__
|
|
628
|
+
)
|
|
629
|
+
return None
|
|
630
|
+
except PermissionError:
|
|
631
|
+
logger.exception(
|
|
632
|
+
"ripgrep install failed: cannot write to %s — check permissions", BIN_DIR
|
|
633
|
+
)
|
|
634
|
+
return None
|
|
635
|
+
except OSError as exc:
|
|
636
|
+
logger.exception(
|
|
637
|
+
"ripgrep install failed: %s (errno=%s)", type(exc).__name__, exc.errno
|
|
638
|
+
)
|
|
639
|
+
return None
|
|
640
|
+
else:
|
|
641
|
+
prepend_managed_bin_to_path()
|
|
642
|
+
return installed
|