zjcode 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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