mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
fix(code): eager managed ripgrep install via dcode tools install (#4199)
`dcode tools install` provisions the managed ripgrep binary; the install script now sets it up by default (opt out with `DEEPAGENTS_CODE_RIPGREP_INSTALLER=system` or `DEEPAGENTS_CODE_OFFLINE=1`). --- Today the pinned, SHA-256-verified ripgrep binary only lands on first run (via `managed_tools.ensure_ripgrep`), so a fresh user pays a one-time download the first time they launch. This makes the install script provision it eagerly so `rg` is ready immediately, without reaching for `sudo` package managers by default. Rather than re-encoding the pinned version + checksum table in bash (drift risk), a new `dcode tools install` verb reuses the existing managed-install code, and `scripts/install.sh` invokes the freshly installed binary. The verb is also handy on its own to repair a missing or stale `rg`. Power users can keep their own toolchain with `DEEPAGENTS_CODE_RIPGREP_INSTALLER=system`, which preserves the existing brew/apt/cargo path in the install script and skips the managed download at runtime. A system `rg` already on `PATH` is reused under either setting, and `DEEPAGENTS_CODE_OFFLINE` / `DEEPAGENTS_CODE_SKIP_OPTIONAL` continue to apply. The default eager install is announced and honors those opt-outs, per the package's "default shell-outs must announce + offer a documented opt-out" rule. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -411,10 +411,10 @@
|
||||
|
||||
#### T11: Auto-Installed ripgrep Binary from Upstream Release
|
||||
|
||||
- **Flow**: First-run download performed by `managed_tools.ensure_ripgrep` when `rg` is not on `PATH`.
|
||||
- **Description**: On first invocation without a system `rg`, Deep Agents Code fetches the pinned ripgrep release tarball from `github.com/BurntSushi/ripgrep/releases/...`, verifies it against an in-tree SHA-256 (`RIPGREP_ASSETS`), extracts it under a `TemporaryDirectory`, and atomically moves the binary into `~/.deepagents/bin/rg`. The binary then runs unsandboxed, inheriting the same trust as a user-installed `rg` (the SDK invokes it via `subprocess.run(["rg", ...])`).
|
||||
- **Mitigations**: (1) SHA-256 verified against the pinned hash table before move — a mismatch aborts the install and leaves `BIN_DIR` clean. (2) Network egress is limited to `github.com`. (3) Opt-out via `DEEPAGENTS_CODE_OFFLINE` for air-gapped environments. (4) Pinned version + checksums are bumped in-tree, so a compromised upstream release is detected on the next Deep Agents Code release rather than silently propagating. (5) Atomic move-into-place avoids partial installs when concurrent CLI invocations race.
|
||||
- **Preconditions**: User has not installed `rg` via their package manager, `DEEPAGENTS_CODE_OFFLINE` is unset, and the host can reach `github.com`. The pinned SHA-256 in `RIPGREP_ASSETS` would need to be incorrect (a supply-chain compromise of the deepagents-code release) for a tampered binary to be installed.
|
||||
- **Flow**: Download performed by `managed_tools.ensure_ripgrep` when `rg` is not on `PATH` — either on first run, or eagerly at install time via `dcode tools install` (invoked by `scripts/install.sh`).
|
||||
- **Description**: Without a system `rg`, Deep Agents Code fetches the pinned ripgrep release tarball from `github.com/BurntSushi/ripgrep/releases/...`, verifies it against an in-tree SHA-256 (`RIPGREP_ASSETS`), extracts it under a `TemporaryDirectory`, and atomically moves the binary into `~/.deepagents/bin/rg`. The binary then runs unsandboxed, inheriting the same trust as a user-installed `rg` (the SDK invokes it via `subprocess.run(["rg", ...])`). The same verified path backs the `dcode tools install` verb, so the install script reuses it rather than re-encoding the version + checksum table in bash.
|
||||
- **Mitigations**: (1) SHA-256 verified against the pinned hash table before move — a mismatch aborts the install and leaves `BIN_DIR` clean. (2) Network egress is limited to `github.com`. (3) Opt-out via `DEEPAGENTS_CODE_OFFLINE` for air-gapped environments, or `DEEPAGENTS_CODE_RIPGREP_INSTALLER=system` to defer to the OS package manager instead of the managed binary. (4) Pinned version + checksums are bumped in-tree, so a compromised upstream release is detected on the next Deep Agents Code release rather than silently propagating. (5) Atomic move-into-place avoids partial installs when concurrent CLI invocations race. (6) The eager install-script path is non-`sudo` (no system package manager is invoked in the default `managed` mode).
|
||||
- **Preconditions**: User has not installed `rg` via their package manager, `DEEPAGENTS_CODE_OFFLINE` is unset, `DEEPAGENTS_CODE_RIPGREP_INSTALLER` is not `system`, and the host can reach `github.com`. The pinned SHA-256 in `RIPGREP_ASSETS` would need to be incorrect (a supply-chain compromise of the deepagents-code release) for a tampered binary to be installed.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -177,6 +177,16 @@ version), skips auto-updating to break out of an otherwise endless
|
||||
upgrade/restart loop. Set and read internally across `os.execv`.
|
||||
"""
|
||||
|
||||
RIPGREP_INSTALLER = "DEEPAGENTS_CODE_RIPGREP_INSTALLER"
|
||||
"""Select how ripgrep is provisioned: `managed` (default) or `system`.
|
||||
|
||||
`managed` downloads the pinned, SHA-256-verified upstream binary into
|
||||
`~/.deepagents/bin` (no sudo). `system` skips that download so power users can
|
||||
rely on their distro package / existing toolchain instead; the install script's
|
||||
`system` mode keeps the brew/apt/cargo path. A system `rg` already on `PATH` is
|
||||
reused under either setting. Unrecognized values fall back to `managed`. See
|
||||
`managed_tools.ripgrep_installer`."""
|
||||
|
||||
SERVER_ENV_PREFIX = "DEEPAGENTS_CODE_SERVER_"
|
||||
"""Environment variable prefix used to pass CLI config to the server subprocess."""
|
||||
|
||||
|
||||
@@ -2796,9 +2796,10 @@ class DeepAgentsApp(App):
|
||||
`_ripgrep_ensured` instead of installing again.
|
||||
|
||||
Returns:
|
||||
`True` when a usable managed `rg` is on `PATH`, `False` when the
|
||||
install was skipped or failed (caller should surface the missing
|
||||
tool and fall back to the slow path).
|
||||
`True` when a usable `rg` is resolved — the managed binary (with
|
||||
`BIN_DIR` prepended to `PATH`) or a system `rg` already on `PATH` —
|
||||
`False` when the install was skipped or failed (caller should
|
||||
surface the missing tool and fall back to the slow path).
|
||||
"""
|
||||
async with self._ripgrep_ensure_lock:
|
||||
if self._ripgrep_ensured.is_set():
|
||||
@@ -2809,6 +2810,7 @@ class DeepAgentsApp(App):
|
||||
from deepagents_code.managed_tools import (
|
||||
ChecksumMismatchError,
|
||||
ensure_ripgrep,
|
||||
managed_rg_path,
|
||||
prepend_managed_bin_to_path,
|
||||
)
|
||||
except ImportError:
|
||||
@@ -2855,7 +2857,8 @@ class DeepAgentsApp(App):
|
||||
)
|
||||
|
||||
if installed is not None:
|
||||
prepend_managed_bin_to_path()
|
||||
if installed == managed_rg_path():
|
||||
prepend_managed_bin_to_path()
|
||||
self._ripgrep_ensured.set()
|
||||
return True
|
||||
|
||||
|
||||
@@ -1071,6 +1071,14 @@ _STATIC_OPTIONS: tuple[ConfigOption, ...] = (
|
||||
default=False,
|
||||
env_var=_env_vars.OFFLINE,
|
||||
),
|
||||
ConfigOption(
|
||||
key="runtime.ripgrep_installer",
|
||||
group="Runtime",
|
||||
summary="Select ripgrep provisioning mode ('managed' or 'system').",
|
||||
kind=OptionKind.STR,
|
||||
default="managed",
|
||||
env_var=_env_vars.RIPGREP_INSTALLER,
|
||||
),
|
||||
# --- Debug / Development -------------------------------------------
|
||||
ConfigOption(
|
||||
key="debug.enabled",
|
||||
|
||||
@@ -695,12 +695,14 @@ def _auto_install_ripgrep_cli(
|
||||
missing_tools: Tool names reported missing by `check_optional_tools`.
|
||||
|
||||
Returns:
|
||||
`missing_tools` with `"ripgrep"` removed once a usable managed binary
|
||||
is on `PATH`, otherwise the list unchanged.
|
||||
`missing_tools` with `"ripgrep"` removed once a usable `rg` is
|
||||
resolved — the managed binary (with `BIN_DIR` prepended to `PATH`) or a
|
||||
system `rg` already on `PATH` — otherwise the list unchanged.
|
||||
"""
|
||||
from deepagents_code.managed_tools import (
|
||||
ChecksumMismatchError,
|
||||
ensure_ripgrep,
|
||||
managed_rg_path,
|
||||
prepend_managed_bin_to_path,
|
||||
)
|
||||
|
||||
@@ -727,7 +729,8 @@ def _auto_install_ripgrep_cli(
|
||||
if installed is None:
|
||||
return missing_tools
|
||||
|
||||
prepend_managed_bin_to_path()
|
||||
if installed == managed_rg_path():
|
||||
prepend_managed_bin_to_path()
|
||||
return [tool for tool in missing_tools if tool != "ripgrep"]
|
||||
|
||||
|
||||
@@ -910,6 +913,7 @@ _HELP_SPECS: dict[str, tuple[str | None, str]] = {
|
||||
"mcp": ("mcp_command", "show_mcp_help"),
|
||||
"config": ("config_command", "show_config_help"),
|
||||
"auth": ("auth_command", "show_auth_help"),
|
||||
"tools": ("tools_command", "show_tools_help"),
|
||||
}
|
||||
"""Maps top-level command names to their startup-fast-path help dispatch.
|
||||
|
||||
@@ -933,8 +937,8 @@ def _show_bare_command_group_help(args: argparse.Namespace) -> bool:
|
||||
|
||||
Short-circuits before `console`/`settings` are imported so help-only
|
||||
invocations stay snappy. Mirrors the dispatch in `cli_main` for the
|
||||
`help`, `agents`, `skills`, `threads`, `mcp`, `config`, and `auth` commands
|
||||
when no subcommand was given.
|
||||
`help`, `agents`, `skills`, `threads`, `mcp`, `config`, `auth`, and `tools`
|
||||
commands when no subcommand was given.
|
||||
|
||||
Args:
|
||||
args: Namespace from `parse_args()`. Only `command` and the per-group
|
||||
@@ -1212,6 +1216,23 @@ def parse_args() -> argparse.Namespace:
|
||||
)
|
||||
add_json_output_arg(doctor_parser)
|
||||
|
||||
tools_parser = subparsers.add_parser(
|
||||
"tools",
|
||||
help="Manage managed external tools (e.g. ripgrep)",
|
||||
add_help=False,
|
||||
parents=help_parent(_lazy_help("show_tools_help")),
|
||||
)
|
||||
add_json_output_arg(tools_parser)
|
||||
tools_sub = tools_parser.add_subparsers(dest="tools_command")
|
||||
|
||||
tools_install = tools_sub.add_parser(
|
||||
"install",
|
||||
help="Install or repair the managed ripgrep binary",
|
||||
add_help=False,
|
||||
parents=help_parent(_lazy_help("show_tools_install_help")),
|
||||
)
|
||||
add_json_output_arg(tools_install)
|
||||
|
||||
# Default interactive mode — argument order here determines the
|
||||
# usage line printed by argparse; keep in sync with ui.show_help().
|
||||
parser.add_argument(
|
||||
@@ -2293,6 +2314,11 @@ def cli_main() -> None:
|
||||
|
||||
sys.exit(run_doctor_command(args))
|
||||
|
||||
if command == "tools":
|
||||
from deepagents_code.tools_commands import run_tools_command
|
||||
|
||||
sys.exit(run_tools_command(args))
|
||||
|
||||
# Best-effort, idempotent migration. Placed after parse_args and the
|
||||
# bare-help fast path so --help / --version / `deepagents <group>`
|
||||
# exit before any I/O. Wrapped broadly so an unexpected non-OSError
|
||||
|
||||
@@ -15,9 +15,9 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from deepagents_code._env_vars import OFFLINE, is_env_truthy
|
||||
from deepagents_code._env_vars import OFFLINE, RIPGREP_INSTALLER, is_env_truthy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import tarfile
|
||||
@@ -110,6 +110,49 @@ def is_offline() -> bool:
|
||||
return is_env_truthy(OFFLINE)
|
||||
|
||||
|
||||
RipgrepInstaller = Literal["managed", "system"]
|
||||
"""The two recognized ripgrep installer modes."""
|
||||
|
||||
INSTALLER_MANAGED: RipgrepInstaller = "managed"
|
||||
"""Default installer mode: fetch the pinned, checksummed upstream binary."""
|
||||
|
||||
INSTALLER_SYSTEM: RipgrepInstaller = "system"
|
||||
"""Installer mode that defers ripgrep to the system package manager / `PATH`."""
|
||||
|
||||
|
||||
def ripgrep_installer() -> RipgrepInstaller:
|
||||
"""Return the configured ripgrep installer mode.
|
||||
|
||||
Reads `RIPGREP_INSTALLER` and normalizes it to `INSTALLER_MANAGED` or
|
||||
`INSTALLER_SYSTEM`, falling back to `INSTALLER_MANAGED` for unset or
|
||||
unrecognized values. The `strip().lower()` normalization must stay in
|
||||
sync with the `case` block in `scripts/install.sh` so both layers agree
|
||||
on the parsed mode.
|
||||
"""
|
||||
raw = os.environ.get(RIPGREP_INSTALLER, "").strip().lower()
|
||||
if raw == INSTALLER_SYSTEM:
|
||||
return INSTALLER_SYSTEM
|
||||
if raw and raw != INSTALLER_MANAGED:
|
||||
logger.warning(
|
||||
"Unrecognized %s=%r; expected %r or %r. Defaulting to %r.",
|
||||
RIPGREP_INSTALLER,
|
||||
raw,
|
||||
INSTALLER_MANAGED,
|
||||
INSTALLER_SYSTEM,
|
||||
INSTALLER_MANAGED,
|
||||
)
|
||||
return INSTALLER_MANAGED
|
||||
|
||||
|
||||
def prefers_system_ripgrep() -> bool:
|
||||
"""Return whether the user opted into the `system` ripgrep installer.
|
||||
|
||||
In `system` mode ripgrep is provisioned by the OS package manager or an
|
||||
existing `PATH` entry rather than the managed download.
|
||||
"""
|
||||
return ripgrep_installer() == INSTALLER_SYSTEM
|
||||
|
||||
|
||||
def prepend_managed_bin_to_path() -> None:
|
||||
"""Idempotently prepend `BIN_DIR` to `os.environ["PATH"]`.
|
||||
|
||||
@@ -126,6 +169,21 @@ def prepend_managed_bin_to_path() -> None:
|
||||
os.environ["PATH"] = os.pathsep.join(parts)
|
||||
|
||||
|
||||
def _path_without_managed_bin() -> str | None:
|
||||
"""Return `PATH` with `BIN_DIR` removed."""
|
||||
current = os.environ.get("PATH")
|
||||
if not current:
|
||||
return None
|
||||
|
||||
managed_dir = BIN_DIR.resolve()
|
||||
parts = [
|
||||
part
|
||||
for part in current.split(os.pathsep)
|
||||
if not part or Path(part).resolve() != managed_dir
|
||||
]
|
||||
return os.pathsep.join(parts)
|
||||
|
||||
|
||||
def _managed_binary_is_current(binary: Path) -> bool:
|
||||
"""Return whether the on-disk managed `rg` matches `RIPGREP_VERSION`.
|
||||
|
||||
@@ -369,17 +427,19 @@ async def ensure_ripgrep() -> Path | None:
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. If a managed `rg` exists *and* matches `RIPGREP_VERSION`, return it.
|
||||
2. Otherwise, if a system `rg` is on `PATH` and no managed binary
|
||||
1. If the `system` installer is selected, return a non-managed `rg`
|
||||
found on `PATH`, or `None` when only the managed binary is present.
|
||||
2. If a managed `rg` exists *and* matches `RIPGREP_VERSION`, return it.
|
||||
3. Otherwise, if a system `rg` is on `PATH` and no managed binary
|
||||
exists, return its resolved path. This is gated on the *absence*
|
||||
of a managed binary: once a managed `rg` exists, the pinned
|
||||
version always wins, so a stale managed binary is re-fetched
|
||||
rather than deferring to a system `rg` and the resolved version
|
||||
stays deterministic.
|
||||
3. If offline, on an unsupported platform, or no asset matches the
|
||||
4. If offline, on an unsupported platform, or no asset matches the
|
||||
platform/arch, return `None` so callers fall back to the existing
|
||||
notification + slow path.
|
||||
4. Otherwise download → SHA-256 verify → extract → install →
|
||||
5. Otherwise download → SHA-256 verify → extract → install →
|
||||
prepend `BIN_DIR` to `PATH` → return the installed path. On a
|
||||
checksum mismatch, raises `ChecksumMismatchError` so callers can
|
||||
surface a loud notice; other failures log and return `None`.
|
||||
@@ -402,6 +462,17 @@ async def ensure_ripgrep() -> Path | None:
|
||||
|
||||
managed = managed_rg_path()
|
||||
managed_exists = managed.exists()
|
||||
if prefers_system_ripgrep():
|
||||
system_rg = shutil.which("rg", path=_path_without_managed_bin())
|
||||
if system_rg is not None:
|
||||
return Path(system_rg)
|
||||
logger.debug(
|
||||
"Skipping managed ripgrep download: %s=%s",
|
||||
RIPGREP_INSTALLER,
|
||||
INSTALLER_SYSTEM,
|
||||
)
|
||||
return None
|
||||
|
||||
if managed_exists and _managed_binary_is_current(managed):
|
||||
return managed
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""The `dcode tools` command group: provision managed external tools.
|
||||
|
||||
Currently this exposes `dcode tools install`, which fetches the pinned,
|
||||
SHA-256-verified ripgrep binary into `~/.deepagents/bin` (the same managed
|
||||
path used on first run) and is also handy for repairing a missing or stale
|
||||
`rg`. The install script calls this verb instead of re-encoding the pinned
|
||||
version + checksum table in bash.
|
||||
|
||||
Help rendering for `dcode tools -h` / `dcode tools install -h` is served by
|
||||
`ui.show_tools_help` / `ui.show_tools_install_help`, which do not import this
|
||||
module, so the help path stays light.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from deepagents_code.output import write_json
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import argparse
|
||||
|
||||
from deepagents_code.output import OutputFormat
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
InstallStatus = Literal["ok", "skipped", "error"]
|
||||
"""Stable machine token for the `tools install` outcome, surfaced via `--json`.
|
||||
|
||||
`ok` (installed or already current), `skipped` (intentional opt-out), and
|
||||
`error` (an install was expected but failed). Only `error` is unhealthy, so it
|
||||
alone drives a non-zero exit code."""
|
||||
|
||||
|
||||
def run_tools_command(args: argparse.Namespace) -> int:
|
||||
"""Dispatch a `dcode tools` subcommand.
|
||||
|
||||
Args:
|
||||
args: Parsed CLI namespace.
|
||||
|
||||
Returns:
|
||||
Process exit code.
|
||||
"""
|
||||
subcommand = getattr(args, "tools_command", None)
|
||||
if subcommand == "install":
|
||||
return _run_tools_install(args)
|
||||
|
||||
# `cli_main`'s bare-group help fast path handles `dcode tools` with no
|
||||
# subcommand, so this is only reached for an unexpected value.
|
||||
from deepagents_code import ui
|
||||
|
||||
ui.show_tools_help()
|
||||
return 0
|
||||
|
||||
|
||||
def _run_tools_install(args: argparse.Namespace) -> int:
|
||||
"""Install or repair the managed ripgrep binary.
|
||||
|
||||
Honors the same opt-outs as first-run startup (`DEEPAGENTS_CODE_OFFLINE`
|
||||
and `DEEPAGENTS_CODE_RIPGREP_INSTALLER=system`) so behavior stays
|
||||
consistent across entry points.
|
||||
|
||||
Args:
|
||||
args: Parsed CLI namespace. Only `output_format` is read.
|
||||
|
||||
Returns:
|
||||
`0` when a usable `rg` is available (installed, already current, or an
|
||||
intentional opt-out), `1` when an install was expected but failed.
|
||||
"""
|
||||
from deepagents_code.managed_tools import (
|
||||
RIPGREP_VERSION,
|
||||
ChecksumMismatchError,
|
||||
ensure_ripgrep,
|
||||
is_offline,
|
||||
managed_rg_path,
|
||||
prefers_system_ripgrep,
|
||||
prepend_managed_bin_to_path,
|
||||
)
|
||||
|
||||
output_format: OutputFormat = getattr(args, "output_format", "text")
|
||||
managed_target = managed_rg_path()
|
||||
|
||||
try:
|
||||
installed = asyncio.run(ensure_ripgrep())
|
||||
except ChecksumMismatchError:
|
||||
logger.exception(
|
||||
"ripgrep install aborted: SHA-256 mismatch on downloaded archive"
|
||||
)
|
||||
return _emit_install_result(
|
||||
output_format,
|
||||
status="error",
|
||||
message=(
|
||||
"ripgrep install aborted: the downloaded archive failed SHA-256 "
|
||||
"verification. Refusing to install."
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
# Backstop for a clean exit instead of a raw traceback.
|
||||
# `ensure_ripgrep` is defensive internally, but this is the only
|
||||
# `ensure_ripgrep` caller wired into `scripts/install.sh`, so an
|
||||
# unexpected escape must degrade to a structured error + exit 1
|
||||
# (matching the broad backstops in `app.py` / `main.py`) rather than
|
||||
# dumping a traceback and breaking the `--json` envelope.
|
||||
logger.warning("ripgrep install failed unexpectedly", exc_info=True)
|
||||
return _emit_install_result(
|
||||
output_format,
|
||||
status="error",
|
||||
message="ripgrep install failed unexpectedly. See logs for details.",
|
||||
)
|
||||
|
||||
if installed is not None:
|
||||
if installed == managed_target:
|
||||
prepend_managed_bin_to_path()
|
||||
message = f"Managed ripgrep {RIPGREP_VERSION} ready at {installed}"
|
||||
else:
|
||||
message = f"Using ripgrep already on PATH at {installed}"
|
||||
return _emit_install_result(
|
||||
output_format,
|
||||
status="ok",
|
||||
message=message,
|
||||
path=str(installed),
|
||||
)
|
||||
|
||||
# `ensure_ripgrep` returned `None`: an intentional opt-out is a success
|
||||
# (nothing to do), while an unexpected failure is reported as an error.
|
||||
if prefers_system_ripgrep():
|
||||
return _emit_install_result(
|
||||
output_format,
|
||||
status="skipped",
|
||||
message=(
|
||||
"Skipped managed ripgrep install: DEEPAGENTS_CODE_RIPGREP_INSTALLER"
|
||||
"=system. Install ripgrep with your package manager, or unset the "
|
||||
"variable to use the managed binary."
|
||||
),
|
||||
)
|
||||
if is_offline():
|
||||
return _emit_install_result(
|
||||
output_format,
|
||||
status="skipped",
|
||||
message=(
|
||||
"Skipped managed ripgrep install: DEEPAGENTS_CODE_OFFLINE is set. "
|
||||
"Unset it to download the managed binary."
|
||||
),
|
||||
)
|
||||
return _emit_install_result(
|
||||
output_format,
|
||||
status="error",
|
||||
message=(
|
||||
"Could not install ripgrep (unsupported platform or download failure). "
|
||||
"See logs, or install ripgrep manually."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _emit_install_result(
|
||||
output_format: OutputFormat,
|
||||
*,
|
||||
status: InstallStatus,
|
||||
message: str,
|
||||
path: str | None = None,
|
||||
) -> int:
|
||||
"""Print the install outcome as text or JSON and return its exit code.
|
||||
|
||||
`ok` is derived from `status` (only `"error"` is unhealthy) so the JSON
|
||||
envelope and exit code cannot disagree and no illegal `(status, ok)` pair
|
||||
is representable.
|
||||
|
||||
Args:
|
||||
output_format: `"json"` for machine-readable output, else text.
|
||||
status: Stable machine token (`"ok"`, `"skipped"`, or `"error"`).
|
||||
message: Human-readable summary line.
|
||||
path: Resolved `rg` path when one is available.
|
||||
|
||||
Returns:
|
||||
`0` for `"ok"`/`"skipped"`, `1` for `"error"`.
|
||||
"""
|
||||
ok = status != "error"
|
||||
if output_format == "json":
|
||||
payload: dict[str, object] = {"status": status, "ok": ok, "message": message}
|
||||
if path is not None:
|
||||
payload["path"] = path
|
||||
write_json("tools install", payload)
|
||||
else:
|
||||
from deepagents_code.config import console
|
||||
|
||||
style = "green" if ok else "bold red"
|
||||
console.print(message, style=style, markup=False)
|
||||
|
||||
return 0 if ok else 1
|
||||
@@ -116,6 +116,9 @@ def show_help() -> None:
|
||||
console.print(
|
||||
" dcode doctor Print install diagnostics"
|
||||
)
|
||||
console.print(
|
||||
" dcode tools <install> Manage managed tools (ripgrep)"
|
||||
)
|
||||
console.print()
|
||||
|
||||
console.print("[bold]Options:[/bold]", style=theme.PRIMARY)
|
||||
@@ -491,6 +494,58 @@ def show_doctor_help() -> None:
|
||||
console.print()
|
||||
|
||||
|
||||
def show_tools_help() -> None:
|
||||
"""Show help information for the `tools` subcommand."""
|
||||
console.print()
|
||||
console.print("[bold]Usage:[/bold]", style=theme.PRIMARY)
|
||||
console.print(" dcode tools <command> [options]")
|
||||
console.print()
|
||||
console.print("[bold]Commands:[/bold]", style=theme.PRIMARY)
|
||||
console.print(" install Install or repair the managed ripgrep binary")
|
||||
console.print()
|
||||
_print_option_section()
|
||||
console.print()
|
||||
console.print("[bold]Examples:[/bold]", style=theme.PRIMARY)
|
||||
console.print(" dcode tools install")
|
||||
console.print(" dcode tools install --json")
|
||||
console.print()
|
||||
|
||||
|
||||
def show_tools_install_help() -> None:
|
||||
"""Show help information for the `tools install` subcommand."""
|
||||
console.print()
|
||||
console.print("[bold]Usage:[/bold]", style=theme.PRIMARY)
|
||||
console.print(" dcode tools install [options]")
|
||||
console.print()
|
||||
console.print(
|
||||
"Download the pinned, SHA-256-verified ripgrep binary into",
|
||||
)
|
||||
console.print(
|
||||
"~/.deepagents/bin (no sudo). Reuses a system `rg` already on PATH and",
|
||||
)
|
||||
console.print(
|
||||
"is also handy for repairing a missing or stale managed binary.",
|
||||
)
|
||||
console.print()
|
||||
_print_option_section()
|
||||
console.print()
|
||||
console.print("[bold]Examples:[/bold]", style=theme.PRIMARY)
|
||||
console.print(" dcode tools install")
|
||||
console.print(" dcode tools install --json")
|
||||
console.print()
|
||||
console.print(
|
||||
"Opt out with DEEPAGENTS_CODE_OFFLINE=1 or set",
|
||||
style=theme.MUTED,
|
||||
highlight=False,
|
||||
)
|
||||
console.print(
|
||||
"DEEPAGENTS_CODE_RIPGREP_INSTALLER=system to use your package manager.",
|
||||
style=theme.MUTED,
|
||||
highlight=False,
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def _print_mcp_discovery_paths() -> None:
|
||||
"""Print the auto-discovered MCP config paths in precedence order."""
|
||||
from deepagents_code.mcp_tools import MCP_CONFIG_DISCOVERY_PATHS
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
# terminal (CI, wrapper scripts) update instead of stalling at the y/n
|
||||
# prompt.
|
||||
# DEEPAGENTS_CODE_SKIP_OPTIONAL — set to 1 to skip optional tool checks
|
||||
# DEEPAGENTS_CODE_RIPGREP_INSTALLER — how to provision ripgrep:
|
||||
# "managed" (default) eagerly installs the pinned, SHA-256-verified binary
|
||||
# into ~/.deepagents/bin (no sudo) via `dcode tools install`; "system"
|
||||
# keeps the interactive package-manager install (brew/apt/cargo/...). Set
|
||||
# DEEPAGENTS_CODE_OFFLINE=1 to skip the managed download entirely.
|
||||
# DEEPAGENTS_CODE_SKIP_XCODE_CHECK — set to 1 to bypass the macOS Xcode
|
||||
# Command Line Tools preflight check
|
||||
# DEEPAGENTS_CODE_VERBOSE — set to 1 to show uv's raw stderr (timing lines,
|
||||
@@ -345,6 +350,22 @@ PYTHON_VERSION="${DEEPAGENTS_CODE_PYTHON:-3.13}"
|
||||
SKIP_OPTIONAL="${DEEPAGENTS_CODE_SKIP_OPTIONAL:-0}"
|
||||
VERBOSE="${DEEPAGENTS_CODE_VERBOSE:-0}"
|
||||
ASSUME_YES="${DEEPAGENTS_CODE_YES:-0}"
|
||||
# How ripgrep gets provisioned: "managed" (default) eagerly fetches the
|
||||
# pinned, SHA-256-verified binary into ~/.deepagents/bin via `dcode tools
|
||||
# install`; "system" keeps the interactive package-manager path below. Any
|
||||
# value other than "system" normalizes to "managed".
|
||||
#
|
||||
# Lowercase and strip whitespace first so this matches the `.strip().lower()`
|
||||
# normalization in managed_tools.ripgrep_installer(). Without this, a value
|
||||
# like "System" would parse as "managed" here but "system" in dcode, and the
|
||||
# eager `dcode tools install` would skip silently while this script also
|
||||
# skipped the package-manager path — leaving ripgrep unprovisioned.
|
||||
RIPGREP_INSTALLER="$(printf '%s' "${DEEPAGENTS_CODE_RIPGREP_INSTALLER:-managed}" \
|
||||
| tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||||
case "$RIPGREP_INSTALLER" in
|
||||
system) RIPGREP_INSTALLER="system" ;;
|
||||
*) RIPGREP_INSTALLER="managed" ;;
|
||||
esac
|
||||
|
||||
# PyPI JSON endpoint used to discover the latest published release so we can
|
||||
# tell whether an existing install is out of date before upgrading it.
|
||||
@@ -939,7 +960,22 @@ ripgrep_manual_hint() {
|
||||
}
|
||||
|
||||
if [ "$SKIP_OPTIONAL" != "1" ]; then
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
if [ "$RIPGREP_INSTALLER" = "managed" ] && [ "$VERIFY_OK" = true ] && [ -n "$DCODE_BIN" ]; then
|
||||
# Eager, non-prompting managed install through the freshly installed binary
|
||||
# — the same pinned, SHA-256-verified path dcode uses on first run
|
||||
# (downloads into ~/.deepagents/bin, no sudo). Doing it here removes the
|
||||
# first-run download latency. The binary reuses a system `rg` already on
|
||||
# PATH and honors DEEPAGENTS_CODE_OFFLINE and
|
||||
# DEEPAGENTS_CODE_RIPGREP_INSTALLER=system, reporting what it did.
|
||||
echo ""
|
||||
log_info "Setting up ripgrep (managed; opt out with DEEPAGENTS_CODE_RIPGREP_INSTALLER=system)..."
|
||||
if "$DCODE_BIN" tools install; then
|
||||
fix_owner "${HOME}/.deepagents/bin"
|
||||
else
|
||||
log_warn "Managed ripgrep setup did not complete; the grep tool will use a slower fallback."
|
||||
ripgrep_manual_hint
|
||||
fi
|
||||
elif command -v rg >/dev/null 2>&1; then
|
||||
if [ "$VERBOSE" = "1" ]; then
|
||||
echo ""
|
||||
log_info "Checking optional tools..."
|
||||
|
||||
@@ -569,6 +569,14 @@ class TestHelpScreenDriftExtended:
|
||||
show_help()
|
||||
assert "dcode mcp" in buf.getvalue()
|
||||
|
||||
def test_show_help_includes_tools_subcommand(self) -> None:
|
||||
"""show_help should mention the tools subcommand."""
|
||||
buf = io.StringIO()
|
||||
test_console = Console(file=buf, highlight=False, width=200)
|
||||
with patch("deepagents_code.ui.console", test_console):
|
||||
show_help()
|
||||
assert "dcode tools" in buf.getvalue()
|
||||
|
||||
def test_show_help_includes_stdin(self) -> None:
|
||||
"""show_help should mention --stdin."""
|
||||
buf = io.StringIO()
|
||||
|
||||
@@ -16720,6 +16720,10 @@ class TestEnsureManagedRipgrep:
|
||||
return_value=True,
|
||||
),
|
||||
patch("deepagents_code.managed_tools.ensure_ripgrep", ensure),
|
||||
patch(
|
||||
"deepagents_code.managed_tools.managed_rg_path",
|
||||
return_value=Path("/managed/rg"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.managed_tools.prepend_managed_bin_to_path",
|
||||
prepend,
|
||||
@@ -16733,6 +16737,40 @@ class TestEnsureManagedRipgrep:
|
||||
assert app._ripgrep_ensured.is_set()
|
||||
assert app._ripgrep_install_failed is False
|
||||
|
||||
async def test_system_rg_resolved_without_prepending(self) -> None:
|
||||
"""A resolved system `rg` must not prepend the managed bin dir.
|
||||
|
||||
`ensure_ripgrep` can return a system `rg` (system-installer mode or a
|
||||
pre-existing binary). Prepending `BIN_DIR` then would pollute the
|
||||
langgraph subprocess's `PATH` with a managed dir holding no binary, so
|
||||
the gate must compare against `managed_rg_path()` before prepending.
|
||||
"""
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
|
||||
ensure = AsyncMock(return_value=Path("/usr/bin/rg"))
|
||||
prepend = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.main._should_ensure_managed_ripgrep",
|
||||
return_value=True,
|
||||
),
|
||||
patch("deepagents_code.managed_tools.ensure_ripgrep", ensure),
|
||||
patch(
|
||||
"deepagents_code.managed_tools.managed_rg_path",
|
||||
return_value=Path("/managed/rg"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.managed_tools.prepend_managed_bin_to_path",
|
||||
prepend,
|
||||
),
|
||||
):
|
||||
assert await app._ensure_managed_ripgrep() is True
|
||||
|
||||
ensure.assert_awaited_once()
|
||||
prepend.assert_not_called()
|
||||
assert app._ripgrep_ensured.is_set()
|
||||
assert app._ripgrep_install_failed is False
|
||||
|
||||
async def test_installs_when_ripgrep_warning_is_suppressed(self) -> None:
|
||||
"""Suppressed warning state must not skip managed `rg` installation."""
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
|
||||
@@ -16750,6 +16788,10 @@ class TestEnsureManagedRipgrep:
|
||||
return_value=True,
|
||||
),
|
||||
patch("deepagents_code.managed_tools.ensure_ripgrep", ensure),
|
||||
patch(
|
||||
"deepagents_code.managed_tools.managed_rg_path",
|
||||
return_value=Path("/managed/rg"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.managed_tools.prepend_managed_bin_to_path",
|
||||
prepend,
|
||||
|
||||
@@ -32,13 +32,16 @@ def _write_fake_tools(
|
||||
installed_version: str | None = "0.0.1",
|
||||
latest_version: str | None = None,
|
||||
curl_fails: bool = False,
|
||||
dcode_verify_fails: bool = False,
|
||||
) -> tuple[Path, Path, Path]:
|
||||
"""Stage fake `uv`, `curl`, and (optionally) `dcode` binaries on `PATH`.
|
||||
|
||||
`installed_version` controls whether `dcode -v` reports an existing install
|
||||
(`None` simulates a fresh machine). `latest_version` is the version the
|
||||
fake `curl` reports from PyPI; `curl_fails` makes that probe error out so
|
||||
the script's offline fallback can be exercised.
|
||||
the script's offline fallback can be exercised. `dcode_verify_fails` makes
|
||||
`dcode -v` exit non-zero (`VERIFY_OK=false`) so the eager managed-ripgrep
|
||||
guard can be exercised against a present-but-broken binary.
|
||||
"""
|
||||
bin_dir = tmp_path / "bin"
|
||||
home = tmp_path / "home"
|
||||
@@ -82,11 +85,17 @@ exit 1
|
||||
|
||||
if installed_version is not None:
|
||||
dcode = bin_dir / "dcode"
|
||||
tools_log = tmp_path / "dcode-tools.txt"
|
||||
verify_rc = 1 if dcode_verify_fails else 0
|
||||
dcode.write_text(
|
||||
f"""#!/usr/bin/env bash
|
||||
if [ "${{1:-}}" = "-v" ]; then
|
||||
printf 'deepagents-code {installed_version}\\n'
|
||||
exit 0
|
||||
exit {verify_rc}
|
||||
fi
|
||||
if [ "${{1:-}}" = "tools" ]; then
|
||||
printf '%s\\n' "$*" >> {str(tools_log)!r}
|
||||
exit "${{FAKE_DCODE_TOOLS_RC:-0}}"
|
||||
fi
|
||||
exit 0
|
||||
"""
|
||||
@@ -102,12 +111,14 @@ def _env(
|
||||
installed_version: str | None = "0.0.1",
|
||||
latest_version: str | None = None,
|
||||
curl_fails: bool = False,
|
||||
dcode_verify_fails: bool = False,
|
||||
) -> dict[str, str]:
|
||||
bin_dir, home, uv = _write_fake_tools(
|
||||
tmp_path,
|
||||
installed_version=installed_version,
|
||||
latest_version=latest_version,
|
||||
curl_fails=curl_fails,
|
||||
dcode_verify_fails=dcode_verify_fails,
|
||||
)
|
||||
return {
|
||||
**os.environ,
|
||||
@@ -127,6 +138,7 @@ def _invoke(
|
||||
installed_version: str | None = "0.0.1",
|
||||
latest_version: str | None = None,
|
||||
curl_fails: bool = False,
|
||||
dcode_verify_fails: bool = False,
|
||||
) -> tuple[subprocess.CompletedProcess[str], Path]:
|
||||
"""Run `install.sh` non-interactively with the fake tools on `PATH`.
|
||||
|
||||
@@ -141,6 +153,7 @@ def _invoke(
|
||||
installed_version=installed_version,
|
||||
latest_version=latest_version,
|
||||
curl_fails=curl_fails,
|
||||
dcode_verify_fails=dcode_verify_fails,
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["bash", str(SCRIPT)],
|
||||
@@ -1245,3 +1258,80 @@ def test_install_script_no_path_warning_when_dcode_on_path(tmp_path: Path) -> No
|
||||
assert proc.returncode == 0
|
||||
combined = proc.stdout + proc.stderr
|
||||
assert "isn't on your PATH yet" not in combined
|
||||
|
||||
|
||||
def test_install_script_managed_ripgrep_calls_tools_install(tmp_path: Path) -> None:
|
||||
"""Default (`managed`) mode eagerly runs `dcode tools install`."""
|
||||
proc, _ = _invoke(
|
||||
tmp_path,
|
||||
{"DEEPAGENTS_CODE_SKIP_OPTIONAL": "0"},
|
||||
installed_version="0.1.0",
|
||||
latest_version="0.2.0",
|
||||
)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
tools_log = tmp_path / "dcode-tools.txt"
|
||||
assert tools_log.exists(), proc.stdout + proc.stderr
|
||||
assert "tools install" in tools_log.read_text()
|
||||
|
||||
|
||||
def test_install_script_system_ripgrep_skips_tools_install(tmp_path: Path) -> None:
|
||||
"""`DEEPAGENTS_CODE_RIPGREP_INSTALLER=system` keeps the package-manager path."""
|
||||
proc, _ = _invoke(
|
||||
tmp_path,
|
||||
{
|
||||
"DEEPAGENTS_CODE_SKIP_OPTIONAL": "0",
|
||||
"DEEPAGENTS_CODE_RIPGREP_INSTALLER": "system",
|
||||
},
|
||||
installed_version="0.1.0",
|
||||
latest_version="0.2.0",
|
||||
)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert not (tmp_path / "dcode-tools.txt").exists()
|
||||
|
||||
|
||||
def test_install_script_skip_optional_skips_tools_install(tmp_path: Path) -> None:
|
||||
"""`DEEPAGENTS_CODE_SKIP_OPTIONAL=1` skips the managed install entirely."""
|
||||
proc, _ = _invoke(
|
||||
tmp_path,
|
||||
{"DEEPAGENTS_CODE_SKIP_OPTIONAL": "1"},
|
||||
installed_version="0.1.0",
|
||||
latest_version="0.2.0",
|
||||
)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert not (tmp_path / "dcode-tools.txt").exists()
|
||||
|
||||
|
||||
def test_install_script_managed_ripgrep_failure_warns(tmp_path: Path) -> None:
|
||||
"""A failed `dcode tools install` falls back with a slow-grep warning."""
|
||||
proc, _ = _invoke(
|
||||
tmp_path,
|
||||
{"DEEPAGENTS_CODE_SKIP_OPTIONAL": "0", "FAKE_DCODE_TOOLS_RC": "1"},
|
||||
installed_version="0.1.0",
|
||||
latest_version="0.2.0",
|
||||
)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert "slower fallback" in (proc.stdout + proc.stderr)
|
||||
|
||||
|
||||
def test_install_script_skips_managed_install_when_verify_failed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A present-but-broken `dcode` (`VERIFY_OK=false`) is not run for `tools`.
|
||||
|
||||
The eager managed-ripgrep block is gated on `VERIFY_OK = true`, so a binary
|
||||
that fails its `-v` probe must not be invoked as `dcode tools install`.
|
||||
"""
|
||||
proc, _ = _invoke(
|
||||
tmp_path,
|
||||
{"DEEPAGENTS_CODE_SKIP_OPTIONAL": "0"},
|
||||
installed_version="0.1.0",
|
||||
latest_version="0.2.0",
|
||||
dcode_verify_fails=True,
|
||||
)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert not (tmp_path / "dcode-tools.txt").exists(), proc.stdout + proc.stderr
|
||||
|
||||
@@ -1444,6 +1444,10 @@ class TestAutoInstallRipgrepCli:
|
||||
"deepagents_code.managed_tools.ensure_ripgrep",
|
||||
AsyncMock(return_value=Path("/managed/rg")),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.managed_tools.managed_rg_path",
|
||||
return_value=Path("/managed/rg"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.managed_tools.prepend_managed_bin_to_path",
|
||||
prepend,
|
||||
@@ -1454,6 +1458,29 @@ class TestAutoInstallRipgrepCli:
|
||||
assert result == ["tavily"]
|
||||
prepend.assert_called_once()
|
||||
|
||||
def test_system_rg_drops_ripgrep_without_prepending(self) -> None:
|
||||
"""A system `rg` is usable without prepending the managed binary dir."""
|
||||
console = MagicMock()
|
||||
prepend = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.managed_tools.ensure_ripgrep",
|
||||
AsyncMock(return_value=Path("/usr/bin/rg")),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.managed_tools.managed_rg_path",
|
||||
return_value=Path("/managed/rg"),
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.managed_tools.prepend_managed_bin_to_path",
|
||||
prepend,
|
||||
),
|
||||
):
|
||||
result = _auto_install_ripgrep_cli(console, ["ripgrep", "tavily"])
|
||||
|
||||
assert result == ["tavily"]
|
||||
prepend.assert_not_called()
|
||||
|
||||
def test_install_returns_none_keeps_ripgrep(self) -> None:
|
||||
"""A skipped/failed install leaves `ripgrep` in the missing list."""
|
||||
console = MagicMock()
|
||||
|
||||
@@ -88,6 +88,10 @@ def test_headless_installs_ripgrep_when_warning_is_suppressed() -> None:
|
||||
return_value=True,
|
||||
),
|
||||
patch("deepagents_code.managed_tools.ensure_ripgrep", ensure),
|
||||
patch(
|
||||
"deepagents_code.managed_tools.managed_rg_path",
|
||||
return_value=Path("/managed/rg"),
|
||||
),
|
||||
patch("deepagents_code.managed_tools.prepend_managed_bin_to_path", prepend),
|
||||
patch(
|
||||
"deepagents_code.non_interactive.run_non_interactive",
|
||||
|
||||
@@ -15,7 +15,7 @@ from unittest import mock
|
||||
import pytest
|
||||
|
||||
from deepagents_code import managed_tools
|
||||
from deepagents_code._env_vars import OFFLINE
|
||||
from deepagents_code._env_vars import OFFLINE, RIPGREP_INSTALLER
|
||||
from deepagents_code.managed_tools import ChecksumMismatchError
|
||||
|
||||
_EXPECTED_PLATFORM_ARCHS = {
|
||||
@@ -120,6 +120,155 @@ async def test_ensure_ripgrep_short_circuits_when_offline(
|
||||
assert await managed_tools.ensure_ripgrep() is None
|
||||
|
||||
|
||||
def test_ripgrep_installer_defaults_to_managed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv(RIPGREP_INSTALLER, raising=False)
|
||||
assert managed_tools.ripgrep_installer() == managed_tools.INSTALLER_MANAGED
|
||||
assert managed_tools.prefers_system_ripgrep() is False
|
||||
|
||||
|
||||
def test_ripgrep_installer_system(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv(RIPGREP_INSTALLER, "System")
|
||||
assert managed_tools.ripgrep_installer() == managed_tools.INSTALLER_SYSTEM
|
||||
assert managed_tools.prefers_system_ripgrep() is True
|
||||
|
||||
|
||||
def test_ripgrep_installer_unrecognized_falls_back_to_managed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv(RIPGREP_INSTALLER, "bogus")
|
||||
assert managed_tools.ripgrep_installer() == managed_tools.INSTALLER_MANAGED
|
||||
|
||||
|
||||
async def test_ensure_ripgrep_short_circuits_when_system_installer(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""`RIPGREP_INSTALLER=system` skips the managed download (no system rg)."""
|
||||
monkeypatch.delenv(OFFLINE, raising=False)
|
||||
monkeypatch.setenv(RIPGREP_INSTALLER, "system")
|
||||
monkeypatch.setattr(managed_tools, "managed_rg_path", lambda: tmp_path / "absent")
|
||||
|
||||
def _no_download(_url: str, _dest: Path) -> None:
|
||||
msg = "_download_to must not be called in system installer mode"
|
||||
raise AssertionError(msg)
|
||||
|
||||
monkeypatch.setattr(managed_tools, "_download_to", _no_download)
|
||||
with mock.patch("shutil.which", return_value=None):
|
||||
assert await managed_tools.ensure_ripgrep() is None
|
||||
|
||||
|
||||
async def test_ensure_ripgrep_system_installer_ignores_current_managed(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
bin_dir = tmp_path / "managed-bin"
|
||||
bin_dir.mkdir()
|
||||
managed = bin_dir / "rg"
|
||||
managed.write_bytes(b"current-managed")
|
||||
monkeypatch.setattr(managed_tools, "BIN_DIR", bin_dir)
|
||||
monkeypatch.setattr(managed_tools, "managed_rg_path", lambda: managed)
|
||||
monkeypatch.delenv(OFFLINE, raising=False)
|
||||
monkeypatch.setenv(RIPGREP_INSTALLER, "system")
|
||||
monkeypatch.setenv("PATH", str(bin_dir))
|
||||
|
||||
with (
|
||||
mock.patch("shutil.which", return_value=None),
|
||||
mock.patch.object(
|
||||
subprocess,
|
||||
"run",
|
||||
side_effect=AssertionError("managed rg must not be version-probed"),
|
||||
),
|
||||
):
|
||||
assert await managed_tools.ensure_ripgrep() is None
|
||||
|
||||
|
||||
async def test_ensure_ripgrep_system_installer_uses_non_managed_path_entry(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
bin_dir = tmp_path / "managed-bin"
|
||||
system_bin = tmp_path / "system-bin"
|
||||
bin_dir.mkdir()
|
||||
system_bin.mkdir()
|
||||
managed = bin_dir / "rg"
|
||||
system_rg = system_bin / "rg"
|
||||
managed.write_bytes(b"current-managed")
|
||||
monkeypatch.setattr(managed_tools, "BIN_DIR", bin_dir)
|
||||
monkeypatch.setattr(managed_tools, "managed_rg_path", lambda: managed)
|
||||
monkeypatch.delenv(OFFLINE, raising=False)
|
||||
monkeypatch.setenv(RIPGREP_INSTALLER, "system")
|
||||
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{system_bin}")
|
||||
|
||||
def _which(cmd: str, path: str | None = None) -> str | None:
|
||||
assert cmd == "rg"
|
||||
assert path == str(system_bin)
|
||||
return str(system_rg)
|
||||
|
||||
with mock.patch("shutil.which", side_effect=_which):
|
||||
assert await managed_tools.ensure_ripgrep() == system_rg
|
||||
|
||||
|
||||
def test_path_without_managed_bin_returns_none_when_path_unset(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""An unset/empty `PATH` yields `None` so `shutil.which` uses its default."""
|
||||
monkeypatch.setattr(managed_tools, "BIN_DIR", tmp_path / "managed-bin")
|
||||
monkeypatch.delenv("PATH", raising=False)
|
||||
assert managed_tools._path_without_managed_bin() is None
|
||||
|
||||
|
||||
def test_path_without_managed_bin_leaves_other_entries(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""A `PATH` without the managed dir is returned unchanged."""
|
||||
bin_dir = tmp_path / "managed-bin"
|
||||
other = tmp_path / "usr-bin"
|
||||
monkeypatch.setattr(managed_tools, "BIN_DIR", bin_dir)
|
||||
monkeypatch.setenv("PATH", str(other))
|
||||
assert managed_tools._path_without_managed_bin() == str(other)
|
||||
|
||||
|
||||
def test_path_without_managed_bin_removes_managed_entry(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""The managed dir is dropped while sibling entries survive."""
|
||||
bin_dir = tmp_path / "managed-bin"
|
||||
other = tmp_path / "usr-bin"
|
||||
monkeypatch.setattr(managed_tools, "BIN_DIR", bin_dir)
|
||||
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{other}")
|
||||
assert managed_tools._path_without_managed_bin() == str(other)
|
||||
|
||||
|
||||
def test_path_without_managed_bin_removes_non_canonical_alias(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""An entry that differs textually but `resolve()`s equal is still removed.
|
||||
|
||||
Guards the `Path(part).resolve()` comparison against a regression to a
|
||||
plain string compare, which would miss `.../managed-bin/.` and friends.
|
||||
"""
|
||||
bin_dir = tmp_path / "managed-bin"
|
||||
other = tmp_path / "usr-bin"
|
||||
alias = f"{bin_dir}{os.sep}."
|
||||
monkeypatch.setattr(managed_tools, "BIN_DIR", bin_dir)
|
||||
monkeypatch.setenv("PATH", f"{alias}{os.pathsep}{other}")
|
||||
assert managed_tools._path_without_managed_bin() == str(other)
|
||||
|
||||
|
||||
def test_path_without_managed_bin_preserves_empty_entries(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Empty `PATH` components are kept verbatim, not resolved to cwd.
|
||||
|
||||
Resolving an empty entry would collapse it to the current directory, which
|
||||
could spuriously match `BIN_DIR`; the `not part` short-circuit avoids that.
|
||||
"""
|
||||
bin_dir = tmp_path / "managed-bin"
|
||||
other = tmp_path / "usr-bin"
|
||||
monkeypatch.setattr(managed_tools, "BIN_DIR", bin_dir)
|
||||
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.pathsep}{other}")
|
||||
assert managed_tools._path_without_managed_bin() == f"{os.pathsep}{other}"
|
||||
|
||||
|
||||
async def test_ensure_ripgrep_short_circuits_on_android(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
|
||||
@@ -102,6 +102,7 @@ def _read_marker(stderr: str, prefix: str) -> object:
|
||||
(["mcp"], "dcode mcp <command>"),
|
||||
(["config"], "dcode config <command>"),
|
||||
(["auth"], "dcode auth <command>"),
|
||||
(["tools"], "dcode tools <command>"),
|
||||
],
|
||||
)
|
||||
def test_help_only_commands_skip_runtime_imports(
|
||||
@@ -166,6 +167,7 @@ def test_auth_credential_resolution_commands_run_settings_bootstrap() -> None:
|
||||
["mcp", "login", "example.com"],
|
||||
["config", "show"],
|
||||
["auth", "list"],
|
||||
["tools", "install"],
|
||||
],
|
||||
)
|
||||
def test_subcommands_bypass_fast_path(argv: list[str]) -> None:
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests for the `dcode tools` command group."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from deepagents_code import managed_tools
|
||||
from deepagents_code._env_vars import OFFLINE, RIPGREP_INSTALLER
|
||||
from deepagents_code.tools_commands import run_tools_command
|
||||
|
||||
|
||||
def _run_text(args: argparse.Namespace) -> tuple[int, str]:
|
||||
buf = io.StringIO()
|
||||
test_console = Console(file=buf, highlight=False, width=200)
|
||||
with patch("deepagents_code.config.console", test_console):
|
||||
code = run_tools_command(args)
|
||||
return code, buf.getvalue()
|
||||
|
||||
|
||||
class TestToolsInstall:
|
||||
"""Tests for `dcode tools install` dispatch."""
|
||||
|
||||
def test_install_success_text(self, tmp_path: Path) -> None:
|
||||
installed = tmp_path / "[/green]" / "rg"
|
||||
args = argparse.Namespace(tools_command="install", output_format="text")
|
||||
with (
|
||||
patch.object(managed_tools, "ensure_ripgrep", return_value=installed),
|
||||
patch.object(managed_tools, "prepend_managed_bin_to_path"),
|
||||
patch.object(managed_tools, "managed_rg_path", return_value=installed),
|
||||
):
|
||||
code, output = _run_text(args)
|
||||
assert code == 0
|
||||
assert "Managed ripgrep" in output
|
||||
assert str(installed) in output
|
||||
|
||||
def test_install_reuses_system_rg(self, tmp_path: Path) -> None:
|
||||
system_rg = Path("/usr/bin/rg")
|
||||
managed = tmp_path / "rg"
|
||||
args = argparse.Namespace(tools_command="install", output_format="text")
|
||||
with (
|
||||
patch.object(managed_tools, "ensure_ripgrep", return_value=system_rg),
|
||||
patch.object(managed_tools, "prepend_managed_bin_to_path") as prepend,
|
||||
patch.object(managed_tools, "managed_rg_path", return_value=managed),
|
||||
):
|
||||
code, output = _run_text(args)
|
||||
assert code == 0
|
||||
assert "already on PATH" in output
|
||||
prepend.assert_not_called()
|
||||
|
||||
def test_install_json_success(self, tmp_path: Path, capsys) -> None:
|
||||
installed = tmp_path / "rg"
|
||||
args = argparse.Namespace(tools_command="install", output_format="json")
|
||||
with (
|
||||
patch.object(managed_tools, "ensure_ripgrep", return_value=installed),
|
||||
patch.object(managed_tools, "prepend_managed_bin_to_path"),
|
||||
patch.object(managed_tools, "managed_rg_path", return_value=installed),
|
||||
):
|
||||
code = run_tools_command(args)
|
||||
assert code == 0
|
||||
envelope = json.loads(capsys.readouterr().out)
|
||||
assert envelope["command"] == "tools install"
|
||||
assert envelope["data"]["status"] == "ok"
|
||||
assert envelope["data"]["path"] == str(installed)
|
||||
|
||||
def test_install_skipped_system_installer(
|
||||
self, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setenv(RIPGREP_INSTALLER, "system")
|
||||
monkeypatch.delenv(OFFLINE, raising=False)
|
||||
args = argparse.Namespace(tools_command="install", output_format="text")
|
||||
with (
|
||||
patch.object(managed_tools, "ensure_ripgrep", return_value=None),
|
||||
patch.object(managed_tools, "managed_rg_path", return_value=tmp_path / "x"),
|
||||
):
|
||||
code, output = _run_text(args)
|
||||
assert code == 0
|
||||
assert "system" in output
|
||||
|
||||
def test_install_skipped_offline(self, monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv(OFFLINE, "1")
|
||||
monkeypatch.delenv(RIPGREP_INSTALLER, raising=False)
|
||||
args = argparse.Namespace(tools_command="install", output_format="text")
|
||||
with (
|
||||
patch.object(managed_tools, "ensure_ripgrep", return_value=None),
|
||||
patch.object(managed_tools, "managed_rg_path", return_value=tmp_path / "x"),
|
||||
):
|
||||
code, output = _run_text(args)
|
||||
assert code == 0
|
||||
assert "OFFLINE" in output
|
||||
|
||||
def test_install_failure_returns_nonzero(self, monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.delenv(OFFLINE, raising=False)
|
||||
monkeypatch.delenv(RIPGREP_INSTALLER, raising=False)
|
||||
args = argparse.Namespace(tools_command="install", output_format="text")
|
||||
with (
|
||||
patch.object(managed_tools, "ensure_ripgrep", return_value=None),
|
||||
patch.object(managed_tools, "managed_rg_path", return_value=tmp_path / "x"),
|
||||
):
|
||||
code, output = _run_text(args)
|
||||
assert code == 1
|
||||
assert "Could not install" in output
|
||||
|
||||
def test_install_checksum_mismatch_returns_nonzero(self, tmp_path: Path) -> None:
|
||||
args = argparse.Namespace(tools_command="install", output_format="text")
|
||||
with (
|
||||
patch.object(
|
||||
managed_tools,
|
||||
"ensure_ripgrep",
|
||||
side_effect=managed_tools.ChecksumMismatchError("bad"),
|
||||
),
|
||||
patch.object(managed_tools, "managed_rg_path", return_value=tmp_path / "x"),
|
||||
):
|
||||
code, output = _run_text(args)
|
||||
assert code == 1
|
||||
assert "SHA-256" in output
|
||||
|
||||
def test_install_unexpected_error_returns_nonzero(self, tmp_path: Path) -> None:
|
||||
"""An unexpected exception degrades to a clean error, not a traceback."""
|
||||
args = argparse.Namespace(tools_command="install", output_format="text")
|
||||
with (
|
||||
patch.object(
|
||||
managed_tools,
|
||||
"ensure_ripgrep",
|
||||
side_effect=OSError("boom"),
|
||||
),
|
||||
patch.object(managed_tools, "managed_rg_path", return_value=tmp_path / "x"),
|
||||
):
|
||||
code, output = _run_text(args)
|
||||
assert code == 1
|
||||
assert "unexpectedly" in output
|
||||
assert "boom" not in output # internals stay in the logs, not stdout
|
||||
|
||||
def test_no_subcommand_shows_help(self) -> None:
|
||||
args = argparse.Namespace(tools_command=None)
|
||||
with patch("deepagents_code.ui.show_tools_help") as show_help:
|
||||
code = run_tools_command(args)
|
||||
assert code == 0
|
||||
show_help.assert_called_once()
|
||||
Reference in New Issue
Block a user