Merge branch 'simp/r2-config-tools' into simp/integration2
This commit is contained in:
+505
-4427
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,939 @@
|
||||
"""cua-driver installer, lock hygiene and pip-install helper for `hermes tools` / `hermes computer-use install`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from hermes_cli.cli_output import (
|
||||
print_info as _print_info, print_success as _print_success, print_warning as _print_warning,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("hermes_cli.tools_config")
|
||||
|
||||
|
||||
def _post_setup_no_window_flags(*, streams_to_console: bool = False) -> int:
|
||||
"""Win32 creationflags that stop post-setup children flashing a console.
|
||||
|
||||
CREATE_NO_WINDOW hides console grandchildren (npm, pip, powershell) while keeping stdio inheritable
|
||||
(unlike DETACHED_PROCESS). Returns 0 on POSIX. ``streams_to_console`` children are only hidden when
|
||||
our own stdout is not a console, so live installer output is never swallowed.
|
||||
"""
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
|
||||
flags = windows_hide_flags()
|
||||
if not flags:
|
||||
return 0
|
||||
if streams_to_console:
|
||||
try:
|
||||
if sys.stdout is not None and sys.stdout.isatty():
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
return flags
|
||||
|
||||
|
||||
def _cua_driver_cmd() -> str:
|
||||
"""Return the configured cua-driver override, or the bare default name."""
|
||||
return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver"
|
||||
|
||||
|
||||
def _cua_version_summary(raw: str, *, limit: int = 120) -> str:
|
||||
"""First non-empty line of ``--version`` output, bounded (an override may print a multi-line banner)."""
|
||||
for line in (raw or "").splitlines():
|
||||
text = line.strip()
|
||||
if text:
|
||||
return text[:limit]
|
||||
return ""
|
||||
|
||||
|
||||
def _resolved_cua_driver_cmd() -> Optional[str]:
|
||||
"""Resolve cua-driver exactly as the runtime and Desktop status do."""
|
||||
from tools.computer_use.cua_backend import resolve_cua_driver_cmd
|
||||
|
||||
return resolve_cua_driver_cmd()
|
||||
|
||||
|
||||
def _cua_driver_env() -> dict:
|
||||
"""cua-driver child env with the Hermes telemetry policy applied (``cua_backend.cua_driver_child_env``).
|
||||
|
||||
Falls back to the current environment if the helper can't be imported, so install/status never break.
|
||||
"""
|
||||
try:
|
||||
from tools.computer_use.cua_backend import cua_driver_child_env
|
||||
|
||||
return cua_driver_child_env()
|
||||
except Exception:
|
||||
return dict(os.environ)
|
||||
|
||||
|
||||
_CUA_DRIVER_CONTRACT_CACHE: dict = {}
|
||||
|
||||
|
||||
def _cua_driver_contract_status(binary: Optional[str] = None) -> dict:
|
||||
"""Inspect whether an installed driver supports Hermes' runtime contract."""
|
||||
import time
|
||||
|
||||
from tools.computer_use.cua_backend import cua_driver_runtime_contract_status
|
||||
|
||||
resolved = binary or _resolved_cua_driver_cmd()
|
||||
if not resolved:
|
||||
return cua_driver_runtime_contract_status(None)
|
||||
try:
|
||||
stat = os.stat(resolved)
|
||||
fingerprint = (resolved, stat.st_mtime_ns, stat.st_size)
|
||||
except OSError:
|
||||
return cua_driver_runtime_contract_status(resolved)
|
||||
|
||||
now = time.monotonic()
|
||||
if (
|
||||
_CUA_DRIVER_CONTRACT_CACHE.get("fingerprint") == fingerprint
|
||||
and now - _CUA_DRIVER_CONTRACT_CACHE.get("checked_at", 0.0) < 30.0
|
||||
):
|
||||
return dict(_CUA_DRIVER_CONTRACT_CACHE["state"])
|
||||
|
||||
state = cua_driver_runtime_contract_status(resolved)
|
||||
_CUA_DRIVER_CONTRACT_CACHE.update(fingerprint=fingerprint, checked_at=now, state=dict(state))
|
||||
return state
|
||||
|
||||
|
||||
def _cua_driver_install_ready() -> bool:
|
||||
"""Return whether an existing driver needs no install-time repair."""
|
||||
if not _cua_driver_contract_status().get("ready"):
|
||||
return False
|
||||
return sys.platform != "win32" or _cua_driver_autostart_registered_windows()
|
||||
|
||||
|
||||
def _pip_install(args: List[str], *, timeout: int = 300, capture_output: bool = True):
|
||||
"""Install Python packages from a post-setup hook.
|
||||
|
||||
Order: ``uv pip install`` (fast, needs no pip in the venv), then ``python -m pip install``, then
|
||||
``python -m ensurepip --upgrade`` and retry pip. The last tier exists because the Windows installer
|
||||
creates the venv via ``uv venv``, which does NOT seed pip, so bare ``-m pip`` failed on fresh installs.
|
||||
"""
|
||||
venv_root = Path(sys.executable).parent.parent
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)}
|
||||
|
||||
# Managed uv first: $HERMES_HOME/bin is never on PATH, so a bare which() misses the uv Hermes installed;
|
||||
# ensure_uv() (not a pure lookup) because installing uv is in scope during setup.
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
|
||||
uv_bin = ensure_uv()
|
||||
if uv_bin:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", *args],
|
||||
capture_output=capture_output, text=True, encoding="utf-8", errors="replace", timeout=timeout,
|
||||
env=uv_env,
|
||||
creationflags=_post_setup_no_window_flags(streams_to_console=not capture_output),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result
|
||||
# Fall through to pip — uv may have failed for a reason pip can handle.
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
try:
|
||||
# Probe for pip; bootstrap via ensurepip if missing (uv venv lacks it).
|
||||
probe = subprocess.run(
|
||||
pip_cmd + ["--version"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15,
|
||||
creationflags=_post_setup_no_window_flags(),
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
raise FileNotFoundError("pip not in venv")
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120, check=True,
|
||||
creationflags=_post_setup_no_window_flags(),
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
# Synthesize a result so callers see a clean failure path.
|
||||
return subprocess.CompletedProcess(
|
||||
pip_cmd, returncode=1, stdout="", stderr=f"pip not available and ensurepip failed: {e}",
|
||||
)
|
||||
|
||||
return subprocess.run(
|
||||
pip_cmd + ["install", *args],
|
||||
capture_output=capture_output, text=True, encoding="utf-8", errors="replace", timeout=timeout,
|
||||
creationflags=_post_setup_no_window_flags(streams_to_console=not capture_output),
|
||||
)
|
||||
|
||||
|
||||
# No pre-install release/asset probe: cua-driver-rs releases are all prereleases, which GitHub's
|
||||
# `/releases/latest` skips (zero binary assets → every non-arm64 host skipped the install), and
|
||||
# re-implementing upstream's tag resolution here would drift. Fresh installs run install.sh directly (it
|
||||
# errors clean on a missing-arch asset); upgrades ask the binary via `cua_driver_update_check()`.
|
||||
|
||||
|
||||
def _cua_install_target_writable() -> bool:
|
||||
"""Return whether the upstream installer can write its app bundle target."""
|
||||
if sys.platform != "darwin":
|
||||
return True
|
||||
try:
|
||||
return not os.path.isdir("/Applications") or os.access("/Applications", os.W_OK)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _cua_driver_version(binary: str) -> Optional[str]:
|
||||
"""``<binary> --version`` stdout (possibly ""), or None when the probe itself fails."""
|
||||
try:
|
||||
return subprocess.run(
|
||||
[binary, "--version"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, env=_cua_driver_env(),
|
||||
creationflags=_post_setup_no_window_flags(),
|
||||
).stdout.strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _confirmed_update_check(driver_cmd: str, require_confirmed_update: bool) -> tuple:
|
||||
"""Ask the installed driver whether a newer release exists; returns ``(proceed, pin_version)``.
|
||||
|
||||
``proceed=False`` means stop with success (already latest, or indeterminate under
|
||||
``require_confirmed_update``). An old driver (no check-update verb) or offline check yields None:
|
||||
`hermes update` then keeps the installed version — an indeterminate check must never cost a
|
||||
multi-minute silent reinstall on every update — while explicit `install --upgrade` falls through.
|
||||
"""
|
||||
try:
|
||||
from tools.computer_use.cua_backend import cua_driver_update_check
|
||||
_state = cua_driver_update_check()
|
||||
except Exception:
|
||||
_state = None
|
||||
if _state is not None and not _state.get("update_available"):
|
||||
_print_success(
|
||||
f" {driver_cmd} is already on the latest release "
|
||||
f"({_state.get('current_version') or 'unknown'})."
|
||||
)
|
||||
return False, None
|
||||
if _state is None and require_confirmed_update:
|
||||
_print_info(
|
||||
f" Could not confirm a newer {driver_cmd} release "
|
||||
"(offline, rate-limited, or driver too old to check); "
|
||||
"keeping the installed version."
|
||||
)
|
||||
_print_info(" Force a refresh with: hermes computer-use install --upgrade")
|
||||
return False, None
|
||||
confirmed_version = None
|
||||
if _state is not None and _state.get("update_available"):
|
||||
# Windows routine upgrades run unattended-safe (stdin closed, version pinned, ceiling
|
||||
# _CUA_BACKGROUND_UPDATE_TIMEOUT, preflights skip in seconds); only contract repairs and fresh
|
||||
# installs stay interactive-only, where upstream needs a human (autostart elevation, SmartScreen).
|
||||
# Pin to the release check-update confirmed: `latest_version` comes from the GitHub Releases API so
|
||||
# its assets exist, unlike the installer's baked version on `main`, which is bumped before assets
|
||||
# are published and 404s unpinned. Malformed values are ignored → unpinned fallback.
|
||||
import re as _re
|
||||
|
||||
_latest = str(_state.get("latest_version") or "").strip().lstrip("vV")
|
||||
if _re.fullmatch(r"\d+(\.\d+)*", _latest):
|
||||
confirmed_version = _latest
|
||||
return True, confirmed_version
|
||||
|
||||
|
||||
def install_cua_driver(
|
||||
upgrade: bool = False,
|
||||
require_confirmed_update: bool = False,
|
||||
show_installer_progress: bool = True,
|
||||
) -> bool:
|
||||
"""Install or refresh the cua-driver binary used by Computer Use.
|
||||
|
||||
The upstream installer always pulls the latest release tag, so re-running it is the canonical way to
|
||||
upgrade. ``upgrade=False`` (toolset enable flow) keeps a compatible installation, repairs an
|
||||
old/incomplete one and installs when missing; ``upgrade=True`` always refreshes.
|
||||
"""
|
||||
import platform as _plat
|
||||
|
||||
system = _plat.system()
|
||||
if system not in ("Darwin", "Windows", "Linux"):
|
||||
if upgrade:
|
||||
# Silent: `hermes update` calls this for every user.
|
||||
return False
|
||||
_print_warning(" Computer Use (cua-driver) is unsupported on this platform; skipping.")
|
||||
return False
|
||||
|
||||
is_windows = system == "Windows"
|
||||
is_linux = system == "Linux"
|
||||
# install.ps1 is fetched via PowerShell's `irm`; macOS/Linux use curl | bash.
|
||||
fetch_tool = "powershell" if is_windows else "curl"
|
||||
|
||||
driver_cmd = _cua_driver_cmd()
|
||||
binary = _resolved_cua_driver_cmd()
|
||||
|
||||
# An explicit override is authoritative even when broken: installing the standard driver
|
||||
# cannot repair the configured path and would mutate an unrelated installation.
|
||||
override = os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip()
|
||||
if override and not binary:
|
||||
_print_warning(f" HERMES_CUA_DRIVER_CMD does not resolve to an executable: {override}")
|
||||
_print_info(" Fix or unset the override before running computer-use install.")
|
||||
return False
|
||||
|
||||
# Not installed → fresh install path (only when caller asked for it).
|
||||
if not binary and not upgrade:
|
||||
if not _cua_install_target_writable():
|
||||
_print_info(" /Applications is not writable; skipping cua-driver install.")
|
||||
_print_info(" Run from an admin account or install cua-driver manually.")
|
||||
return False
|
||||
if not shutil.which(fetch_tool):
|
||||
_print_warning(f" {fetch_tool} not found — install manually:")
|
||||
_print_info(" https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md")
|
||||
return False
|
||||
return _run_cua_driver_installer(label="Installing")
|
||||
|
||||
# A driver failing Hermes' runtime contract (version floor, missing manifest verbs) is repaired
|
||||
# regardless of mode. Hermes' minimum requirement IS the confirmation an upgrade is needed, so this
|
||||
# path must not defer to the driver's `check-update` verb — a cached/indeterminate "no update"
|
||||
# answer would pin users on an unusable driver forever.
|
||||
contract = _cua_driver_contract_status(binary) if binary else None
|
||||
repair_existing = bool(binary and contract and not contract.get("ready"))
|
||||
|
||||
# Compatible existing install: no download, just the host-specific setup upstream normally owns.
|
||||
if binary and not upgrade and not repair_existing:
|
||||
version = _cua_driver_version(binary)
|
||||
if version is None:
|
||||
_print_success(f" {driver_cmd} already installed.")
|
||||
else:
|
||||
_print_success(f" {driver_cmd} already installed: {version or 'unknown version'}")
|
||||
if is_windows and not _repair_cua_driver_autostart_windows(binary, verbose=False):
|
||||
_print_warning(" cua-driver is compatible, but Windows autostart repair failed.")
|
||||
return False
|
||||
_print_cua_platform_notes(is_windows, is_linux, fresh_install=False)
|
||||
return True
|
||||
|
||||
if repair_existing:
|
||||
version = contract.get("version") or "unknown version"
|
||||
reason = contract.get("reason") or "required runtime features are missing"
|
||||
_print_warning(
|
||||
f" Found cua-driver {version}, but Hermes cannot use its current runtime contract: {reason}."
|
||||
)
|
||||
if os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip():
|
||||
_print_info(
|
||||
" Update the binary selected by HERMES_CUA_DRIVER_CMD, or unset "
|
||||
"the override and run: hermes computer-use install --upgrade"
|
||||
)
|
||||
return False
|
||||
if is_windows and require_confirmed_update:
|
||||
_print_info(
|
||||
" Automatic Windows updates cannot safely run cua-driver's interactive repair installer."
|
||||
)
|
||||
_print_info(
|
||||
" Repair it from an interactive terminal with: hermes computer-use install --upgrade"
|
||||
)
|
||||
return False
|
||||
_print_info(" Repairing it with the current upstream installer.")
|
||||
|
||||
# upgrade=True path — refresh to the latest upstream release.
|
||||
if not _cua_install_target_writable():
|
||||
_print_info(" /Applications is not writable; skipping cua-driver refresh.")
|
||||
_print_info(" Run `hermes computer-use install --upgrade` from an admin account to update it.")
|
||||
return bool(binary)
|
||||
|
||||
if not shutil.which(fetch_tool):
|
||||
_print_warning(f" {fetch_tool} not found — cannot refresh cua-driver.")
|
||||
return bool(binary)
|
||||
|
||||
confirmed_version = None
|
||||
if binary and not repair_existing:
|
||||
proceed, confirmed_version = _confirmed_update_check(driver_cmd, require_confirmed_update)
|
||||
if not proceed:
|
||||
return True
|
||||
|
||||
if is_windows and require_confirmed_update and not binary:
|
||||
# Missing binary (enabled but never installed, or wiped by a failed install): an automatic Windows
|
||||
# update must never launch install.ps1, which can demand console/UAC consent the hidden updater
|
||||
# cannot provide.
|
||||
_print_info(
|
||||
" cua-driver is not installed; automatic Windows updates "
|
||||
"cannot safely run its interactive installer."
|
||||
)
|
||||
_print_info(" Install it from an interactive terminal with: hermes computer-use install --upgrade")
|
||||
return False
|
||||
|
||||
# Best-effort before/after version display.
|
||||
before = (_cua_driver_version(binary) or "") if binary else ""
|
||||
|
||||
ok = _run_cua_driver_installer(
|
||||
label="Repairing" if repair_existing else "Refreshing",
|
||||
verbose=False,
|
||||
pin_version=confirmed_version,
|
||||
show_progress=show_installer_progress,
|
||||
installer_timeout=_CUA_BACKGROUND_UPDATE_TIMEOUT if require_confirmed_update else None,
|
||||
)
|
||||
if ok and repair_existing:
|
||||
repaired = _cua_driver_contract_status()
|
||||
if not repaired.get("ready"):
|
||||
_print_warning(
|
||||
" cua-driver was reinstalled, but its runtime contract is still "
|
||||
f"unusable: {repaired.get('reason') or 'unknown error'}."
|
||||
)
|
||||
_print_info(" Run: hermes computer-use doctor")
|
||||
return False
|
||||
if ok and before:
|
||||
after = _cua_driver_version(binary)
|
||||
if after and after != before:
|
||||
_print_success(f" {driver_cmd} upgraded: {before} → {after}")
|
||||
elif after:
|
||||
_print_info(f" {driver_cmd} up to date: {after}")
|
||||
return ok
|
||||
|
||||
|
||||
# Ceiling for one upstream-installer run: must exceed the installer's own stale-lock recovery window
|
||||
# (_install-rust.sh force-releases a dead holder's lock only after LOCK_STALE_AFTER_SECONDS=600; a shorter
|
||||
# timeout kills every run before that fires — a permanent wedge). 660s = 600s + 60s headroom.
|
||||
_CUA_INSTALLER_TIMEOUT = 660
|
||||
|
||||
# Grace for draining the installer's pipes after a timeout kill. The kill is best-effort (_reap_after_timeout)
|
||||
# so the drain must be bounded: a surviving descendant holding the inherited stdout handle would otherwise
|
||||
# make the read wait on an EOF that never comes. A successful kill closes the pipe at once, so this is free.
|
||||
_CUA_INSTALLER_DRAIN_GRACE = 15
|
||||
|
||||
# Quiet unattended refreshes from ``hermes update``: bounded even when upstream waits on Read-Host or a
|
||||
# consent prompt; explicit ``computer-use install --upgrade`` keeps the full ceiling. Safe because the
|
||||
# lock/network preflights make a legitimate long wait impossible here.
|
||||
_CUA_BACKGROUND_UPDATE_TIMEOUT = 120
|
||||
|
||||
# Upstream installer's stale-lock threshold (LOCK_STALE_AFTER_SECONDS in _install-rust.sh), so the
|
||||
# pre-clear never yanks a lock a live-but-slow install still holds.
|
||||
_CUA_LOCK_STALE_AFTER = 600
|
||||
|
||||
|
||||
def _cua_install_home() -> "Path":
|
||||
"""Package home shared by the upstream POSIX and Windows installers."""
|
||||
return Path(os.environ.get("CUA_DRIVER_RS_HOME") or str(Path.home() / ".cua-driver"))
|
||||
|
||||
|
||||
def _cua_install_lock_dir() -> "Path":
|
||||
"""Path of the upstream installer's concurrent-install lock dir."""
|
||||
return _cua_install_home() / "packages" / ".install.lock.d"
|
||||
|
||||
|
||||
def _cua_windows_install_lock_file() -> "Path":
|
||||
"""Path of install.ps1's FileShare::None lock file."""
|
||||
return _cua_install_home() / "install.lock"
|
||||
|
||||
|
||||
def _clear_stale_windows_cua_install_lock() -> None:
|
||||
"""Delete install.ps1's lock file only when no process still holds it.
|
||||
|
||||
install.ps1 locks with ``FileShare::None``; mirror it with a zero-share ``CreateFileW`` probe and
|
||||
``FILE_FLAG_DELETE_ON_CLOSE`` so an unlocked leftover is removed atomically, with no window in which
|
||||
a new installer could acquire the file between probe and delete.
|
||||
"""
|
||||
lock_file = _cua_windows_install_lock_file()
|
||||
try:
|
||||
if not lock_file.is_file():
|
||||
return
|
||||
|
||||
import ctypes as _ctypes
|
||||
from ctypes import wintypes as _wintypes
|
||||
|
||||
# Win32 constants used by install.ps1's FileShare::None equivalent.
|
||||
delete_access = 0x00010000
|
||||
generic_read = 0x80000000
|
||||
generic_write = 0x40000000
|
||||
open_existing = 3
|
||||
file_attribute_normal = 0x00000080
|
||||
file_flag_delete_on_close = 0x04000000
|
||||
|
||||
kernel32 = _ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
create_file = kernel32.CreateFileW
|
||||
create_file.argtypes = [
|
||||
_wintypes.LPCWSTR, _wintypes.DWORD, _wintypes.DWORD, _wintypes.LPVOID,
|
||||
_wintypes.DWORD, _wintypes.DWORD, _wintypes.HANDLE,
|
||||
]
|
||||
create_file.restype = _wintypes.HANDLE
|
||||
close_handle = kernel32.CloseHandle
|
||||
close_handle.argtypes = [_wintypes.HANDLE]
|
||||
close_handle.restype = _wintypes.BOOL
|
||||
|
||||
handle = create_file(
|
||||
str(lock_file),
|
||||
generic_read | generic_write | delete_access,
|
||||
0, # FileShare::None
|
||||
None,
|
||||
open_existing,
|
||||
file_attribute_normal | file_flag_delete_on_close,
|
||||
None,
|
||||
)
|
||||
invalid_handle = _wintypes.HANDLE(-1).value
|
||||
if handle == invalid_handle:
|
||||
logger.debug(
|
||||
"Windows cua install lock at %s is still held or cannot be removed (winerror %s)",
|
||||
lock_file, _ctypes.get_last_error(),
|
||||
)
|
||||
return
|
||||
|
||||
if not close_handle(handle):
|
||||
logger.debug(
|
||||
"could not close Windows cua install lock probe at %s (winerror %s)",
|
||||
lock_file, _ctypes.get_last_error(),
|
||||
)
|
||||
return
|
||||
if lock_file.exists():
|
||||
logger.debug("Windows cua install lock probe succeeded but %s remains", lock_file)
|
||||
return
|
||||
|
||||
logger.info("Cleared stale Windows cua-driver install lock at %s", lock_file)
|
||||
_print_info(f" Cleared stale cua-driver install lock ({lock_file}).")
|
||||
except Exception as e:
|
||||
logger.debug("stale Windows cua install lock check failed: %s", e)
|
||||
|
||||
|
||||
def _clear_stale_cua_install_lock() -> None:
|
||||
"""Best-effort: remove a stale installer lock left by a dead holder.
|
||||
|
||||
POSIX stamps the holder pid into ``~/.cua-driver/packages/.install.lock.d/info``; Windows holds
|
||||
``~/.cua-driver/install.lock`` open with ``FileShare::None``. Clear either artifact up front only
|
||||
when its platform-specific liveness check proves that no install still holds it.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
_clear_stale_windows_cua_install_lock()
|
||||
return
|
||||
lock_dir = _cua_install_lock_dir()
|
||||
try:
|
||||
if not lock_dir.is_dir():
|
||||
return
|
||||
holder_pid = None
|
||||
info = lock_dir / "info"
|
||||
try:
|
||||
for line in info.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
if line.startswith("pid="):
|
||||
holder_pid = int(line.split("=", 1)[1].strip())
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
holder_pid = None
|
||||
|
||||
if holder_pid is not None:
|
||||
try:
|
||||
os.kill(holder_pid, 0) # windows-footgun: ok — function early-returns on win32
|
||||
return # holder alive → concurrent install running
|
||||
except ProcessLookupError:
|
||||
pass # dead holder → stale, clear below
|
||||
except PermissionError:
|
||||
return # alive but owned by someone else — treat as live
|
||||
else:
|
||||
# No readable pid: only clear if old enough that upstream itself would reclaim it.
|
||||
import time as _time
|
||||
try:
|
||||
age = _time.time() - lock_dir.stat().st_mtime
|
||||
except OSError:
|
||||
return
|
||||
if age < _CUA_LOCK_STALE_AFTER:
|
||||
return
|
||||
|
||||
shutil.rmtree(lock_dir, ignore_errors=True)
|
||||
logger.info("Cleared stale cua-driver install lock at %s", lock_dir)
|
||||
_print_info(f" Cleared stale cua-driver install lock ({lock_dir}).")
|
||||
except Exception as e:
|
||||
logger.debug("stale cua install lock check failed: %s", e)
|
||||
|
||||
|
||||
def _cua_install_lock_held() -> bool:
|
||||
"""True when the upstream installer's lock is held by a LIVE process.
|
||||
|
||||
Called after ``_clear_stale_cua_install_lock()``: anything provably stale is already gone, so a
|
||||
surviving lock artifact means a concurrent (or orphaned-but-alive) install owns it.
|
||||
"""
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
lock_file = _cua_windows_install_lock_file()
|
||||
if not lock_file.is_file():
|
||||
return False
|
||||
# install.ps1 holds the file with FileShare::None — any open fails with a sharing violation
|
||||
# while held. Surviving the stale-clear = held; confirm with an open probe.
|
||||
try:
|
||||
with open(lock_file, "r+b"):
|
||||
return False # opened fine → not held (racy leftover)
|
||||
except OSError: # sharing violation surfaces as PermissionError
|
||||
return True
|
||||
return _cua_install_lock_dir().is_dir()
|
||||
except Exception as e:
|
||||
logger.debug("cua install lock probe failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _cua_release_endpoint_reachable(timeout: float = 5.0) -> bool:
|
||||
"""Fast probe: can we reach GitHub's release download host at all?
|
||||
|
||||
When github.com is down the installer dies slowly inside its own retries and eats the whole unattended
|
||||
ceiling; a 5s HEAD decides in seconds. Only a connection-level failure counts as unreachable — any
|
||||
HTTP response (even 4xx/5xx) proves the path works.
|
||||
"""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
req = urllib.request.Request("https://github.com/trycua/cua/releases", method="HEAD")
|
||||
with urllib.request.urlopen(req, timeout=timeout):
|
||||
return True
|
||||
except urllib.error.HTTPError:
|
||||
return True # server answered → reachable
|
||||
except Exception as e:
|
||||
logger.debug("cua release endpoint probe failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _ps_single_quote(value: str) -> str:
|
||||
"""Return a PowerShell single-quoted string literal."""
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _cua_driver_autostart_registered_windows() -> bool:
|
||||
"""Return whether the Windows cua-driver scheduled task is registered."""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["schtasks.exe", "/Query", "/TN", "cua-driver-serve"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _repair_cua_driver_autostart_windows(driver_cmd: str, *, verbose: bool) -> bool:
|
||||
"""Best-effort repair for Windows installer autostart quoting failures.
|
||||
|
||||
Older install.ps1 builds interpolated the binary path into a PowerShell command string, which split at
|
||||
the first space. If the scheduled task is missing, retry via Start-Process's structured ``-FilePath`` /
|
||||
``-ArgumentList`` parameters instead.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return True
|
||||
if _cua_driver_autostart_registered_windows():
|
||||
return True
|
||||
|
||||
binary = shutil.which(driver_cmd)
|
||||
if not binary:
|
||||
return False
|
||||
|
||||
ps = shutil.which("powershell") or shutil.which("powershell.exe") or "powershell"
|
||||
ps_cmd = (
|
||||
f"$exe = {_ps_single_quote(binary)}; "
|
||||
"$proc = Start-Process -FilePath $exe "
|
||||
"-ArgumentList @('autostart','enable') "
|
||||
"-Verb RunAs -Wait -PassThru -ErrorAction Stop; "
|
||||
"exit $proc.ExitCode"
|
||||
)
|
||||
|
||||
if verbose:
|
||||
_print_info(" Registering cua-driver auto-start...")
|
||||
else:
|
||||
_print_info(" Repairing cua-driver auto-start registration...")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=300,
|
||||
env=_cua_driver_env(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
_print_warning(" cua-driver autostart registration timed out.")
|
||||
return False
|
||||
except Exception as exc:
|
||||
_print_warning(f" cua-driver autostart registration failed: {exc}")
|
||||
return False
|
||||
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
|
||||
tail = (result.stderr or result.stdout or "").strip().splitlines()[-3:]
|
||||
_print_warning(" cua-driver autostart registration failed.")
|
||||
for line in tail:
|
||||
_print_info(f" {line[:200]}")
|
||||
_print_info(" From an elevated shell, run: cua-driver autostart enable")
|
||||
return False
|
||||
|
||||
|
||||
def _remove_quietly(path: str) -> None:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _print_cua_platform_notes(is_windows: bool, is_linux: bool, *, fresh_install: bool) -> None:
|
||||
"""Host-specific follow-up notes after an install or a compatible-install check."""
|
||||
if is_windows:
|
||||
_print_info(" cua-driver may spawn a UIAccess worker (cua-driver-uia.exe);")
|
||||
_print_info(" Windows/SmartScreen may prompt the first time it runs.")
|
||||
elif is_linux:
|
||||
_print_warning(" Linux support is alpha.")
|
||||
elif fresh_install:
|
||||
_print_info(" IMPORTANT — grant macOS permissions now:")
|
||||
_print_info(" System Settings > Privacy & Security > Accessibility")
|
||||
_print_info(" System Settings > Privacy & Security > Screen Recording")
|
||||
_print_info(" Both must allow the terminal / Hermes process.")
|
||||
else:
|
||||
_print_info(" Grant macOS permissions if not done yet:")
|
||||
_print_info(" System Settings > Privacy & Security > Accessibility")
|
||||
_print_info(" System Settings > Privacy & Security > Screen Recording")
|
||||
|
||||
|
||||
_CUA_INSTALL_PS1_URL = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.ps1"
|
||||
|
||||
|
||||
def _kill_installer_tree(proc, *, is_windows: bool) -> None:
|
||||
"""Kill the installer and its descendants (best-effort)."""
|
||||
import signal as _signal
|
||||
try:
|
||||
if not is_windows:
|
||||
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) # windows-footgun: ok — POSIX branch only
|
||||
else:
|
||||
# PowerShell may leave download/install helpers alive after its direct process is killed. They
|
||||
# inherit stdout and can keep communicate() and install.lock wedged, so kill the tree leaf-up.
|
||||
import psutil as _psutil
|
||||
|
||||
try:
|
||||
parent = _psutil.Process(proc.pid)
|
||||
descendants = parent.children(recursive=True)
|
||||
except _psutil.NoSuchProcess:
|
||||
return
|
||||
except _psutil.Error as e:
|
||||
logger.debug(
|
||||
"could not enumerate cua-driver installer tree for pid %s: %s", proc.pid, e
|
||||
)
|
||||
proc.kill()
|
||||
return
|
||||
|
||||
for child in reversed(descendants):
|
||||
try:
|
||||
child.kill()
|
||||
except _psutil.NoSuchProcess:
|
||||
pass
|
||||
except _psutil.Error as e:
|
||||
logger.debug("could not kill cua-driver installer child pid %s: %s", child.pid, e)
|
||||
try:
|
||||
parent.kill()
|
||||
except _psutil.NoSuchProcess:
|
||||
pass
|
||||
except _psutil.Error as e:
|
||||
logger.debug("could not kill cua-driver installer parent pid %s: %s", proc.pid, e)
|
||||
proc.kill()
|
||||
except (OSError, ProcessLookupError):
|
||||
proc.kill()
|
||||
|
||||
|
||||
def _reap_after_timeout(proc, *, is_windows: bool) -> None:
|
||||
"""Kill the installer tree, then drain its pipes under a deadline.
|
||||
|
||||
An unbounded drain blocks on an EOF that only arrives when someone kills a surviving descendant by
|
||||
hand, so ``_CUA_INSTALLER_TIMEOUT`` would stop bounding anything.
|
||||
"""
|
||||
_kill_installer_tree(proc, is_windows=is_windows)
|
||||
try:
|
||||
drained_out, _ = proc.communicate(timeout=_CUA_INSTALLER_DRAIN_GRACE)
|
||||
# The partial output names WHERE the installer was stuck (lock wait, consent prompt, download).
|
||||
if drained_out:
|
||||
logger.warning(
|
||||
"cua-driver installer timed out; last output before kill:\n%s", drained_out[-2000:],
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Deliberately not closing proc.stdout: communicate()'s reader threads are still blocked on that
|
||||
# handle and closing it underneath them races; they are daemon threads.
|
||||
logger.debug(
|
||||
"cua-driver installer pipes still open %ss after the kill — "
|
||||
"abandoning the drain, a surviving descendant holds the inherited handle",
|
||||
_CUA_INSTALLER_DRAIN_GRACE,
|
||||
)
|
||||
except (OSError, ValueError) as e:
|
||||
logger.debug("cua-driver installer drain failed: %s", e)
|
||||
|
||||
|
||||
def _cua_installer_command(is_windows: bool):
|
||||
"""Return ``(install_cmd, manual_hint, script_path)`` or None when the POSIX download fails."""
|
||||
if is_windows:
|
||||
# Mirror the one-liner printed by cua_driver_install_hint().
|
||||
ps_oneliner = f"irm {_CUA_INSTALL_PS1_URL} | iex"
|
||||
install_cmd = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_oneliner]
|
||||
manual_hint = f'powershell -NoProfile -ExecutionPolicy Bypass -Command "{ps_oneliner}"'
|
||||
return install_cmd, manual_hint, None
|
||||
|
||||
# Download-then-exec instead of `bash -c "$(curl …)"`: no shell=True, no command substitution, and the
|
||||
# script lands in a mkstemp file (unpredictable name, 0600) rather than a fixed /tmp path — avoiding
|
||||
# both shell injection and a symlink/TOCTOU race. The manual hint stays the upstream one-liner.
|
||||
import tempfile as _tempfile
|
||||
|
||||
install_url = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh"
|
||||
manual_hint = f'/bin/bash -c "$(curl -fsSL {install_url})"'
|
||||
fd, script_path = _tempfile.mkstemp(prefix="cua-driver-install-", suffix=".sh")
|
||||
os.close(fd)
|
||||
try:
|
||||
dl = subprocess.run(
|
||||
["curl", "-fsSL", "-o", script_path, install_url],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120,
|
||||
)
|
||||
failure = None if dl.returncode == 0 else (dl.stderr or "").strip()[:200]
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
failure = str(e)
|
||||
if failure is not None:
|
||||
_print_warning(f" cua-driver installer download failed: {failure}")
|
||||
_remove_quietly(script_path)
|
||||
return None
|
||||
return ["/bin/bash", script_path], manual_hint, script_path
|
||||
|
||||
|
||||
def _unattended_installer_preflight(install_cmd: list, is_windows: bool):
|
||||
"""Fail FAST on the two conditions that otherwise consume the whole unattended ceiling.
|
||||
|
||||
1. Install lock held by a live process — upstream would poll it for up to LOCK_STALE_AFTER_SECONDS=600
|
||||
before probing the holder (the 11-minute silent hang class).
|
||||
2. Release host unreachable — the installer would die slowly inside its own retries; a 5s HEAD answers.
|
||||
Returns the (possibly rewritten) install command, or None to skip this refresh. Explicit
|
||||
`computer-use install --upgrade` runs never come here and keep upstream's full lock-recovery.
|
||||
"""
|
||||
if _cua_install_lock_held():
|
||||
_print_info(
|
||||
" Another cua-driver install is in progress (upstream "
|
||||
"install lock is held) — skipping this refresh."
|
||||
)
|
||||
_print_info(" If no install is really running, retry with: hermes computer-use install --upgrade")
|
||||
return None
|
||||
if not _cua_release_endpoint_reachable():
|
||||
_print_info(
|
||||
" github.com is unreachable — skipping cua-driver refresh (will retry on the next update)."
|
||||
)
|
||||
return None
|
||||
if is_windows:
|
||||
# -NoAutoStart skips Register-CuaDriverAutostart — the ONLY branch of install.ps1 that self-elevates
|
||||
# (UAC). Cost: an existing cua-driver-serve task keeps pointing at the previous binary until the
|
||||
# next interactive upgrade. Scriptblock invocation (not `| iex`) is what lets us pass the parameter.
|
||||
install_cmd = [
|
||||
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-Command", f"$sc = irm {_CUA_INSTALL_PS1_URL}; & ([scriptblock]::Create($sc)) -NoAutoStart",
|
||||
]
|
||||
return install_cmd
|
||||
|
||||
|
||||
def _run_cua_driver_installer(
|
||||
label: str = "Installing",
|
||||
verbose: bool = True,
|
||||
pin_version: Optional[str] = None,
|
||||
show_progress: bool = True,
|
||||
installer_timeout: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""Run the upstream cua-driver installer for this platform.
|
||||
|
||||
The scripts are idempotent: they always download the latest release, so re-running on an
|
||||
already-installed system performs an upgrade. ``installer_timeout`` lets quiet callers use a shorter
|
||||
ceiling without weakening the explicit install path's stale-lock recovery window.
|
||||
"""
|
||||
import platform as _plat
|
||||
|
||||
system = _plat.system()
|
||||
is_windows = system == "Windows"
|
||||
is_linux = system == "Linux"
|
||||
|
||||
prepared = _cua_installer_command(is_windows)
|
||||
if prepared is None:
|
||||
return False
|
||||
install_cmd, manual_hint, script_path = prepared
|
||||
|
||||
if show_progress:
|
||||
if verbose:
|
||||
_print_info(f" {label} cua-driver (background computer-use)...")
|
||||
else:
|
||||
_print_info(f"→ {label} cua-driver (Computer Use)...")
|
||||
driver_cmd = _cua_driver_cmd()
|
||||
timeout = _CUA_INSTALLER_TIMEOUT if installer_timeout is None else installer_timeout
|
||||
|
||||
installer_env = _cua_driver_env()
|
||||
if pin_version:
|
||||
# Both upstream installers honour CUA_DRIVER_RS_VERSION over their baked default.
|
||||
installer_env["CUA_DRIVER_RS_VERSION"] = pin_version
|
||||
|
||||
# A previous timed-out install can leave upstream's concurrent-install lock behind; clear it when
|
||||
# provably stale so the refresh doesn't wedge waiting on a dead holder.
|
||||
_clear_stale_cua_install_lock()
|
||||
|
||||
# Unattended refreshes (installer_timeout set by `hermes update`) preflight and may skip.
|
||||
if installer_timeout is not None:
|
||||
install_cmd = _unattended_installer_preflight(install_cmd, is_windows)
|
||||
if install_cmd is None:
|
||||
return False
|
||||
|
||||
# POSIX: own process group so a timeout kill takes out the whole `curl | bash` pipeline (and the exec'd
|
||||
# _install-rust.sh), not just the outer shell — surviving grandchildren would keep holding the install
|
||||
# lock and wedge every later run.
|
||||
popen_kwargs = {}
|
||||
if not is_windows:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
try:
|
||||
# Non-verbose (`hermes update` refresh): capture the installer's chatty "Next steps" wall and log it
|
||||
# so a failure stays debuggable. Verbose interactive installs stream live.
|
||||
if verbose:
|
||||
proc = subprocess.Popen(
|
||||
install_cmd, shell=False, env=installer_env,
|
||||
creationflags=_post_setup_no_window_flags(streams_to_console=True),
|
||||
**popen_kwargs
|
||||
)
|
||||
try:
|
||||
proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
_reap_after_timeout(proc, is_windows=is_windows)
|
||||
raise
|
||||
result = subprocess.CompletedProcess(install_cmd, proc.returncode, stdout=None, stderr=None)
|
||||
else:
|
||||
proc = subprocess.Popen(
|
||||
install_cmd, shell=False, env=installer_env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
creationflags=_post_setup_no_window_flags(),
|
||||
**popen_kwargs
|
||||
)
|
||||
try:
|
||||
out, _ = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
_reap_after_timeout(proc, is_windows=is_windows)
|
||||
raise
|
||||
result = subprocess.CompletedProcess(install_cmd, proc.returncode, stdout=out, stderr=None)
|
||||
# During `hermes update`, sys.stdout is the mirroring _UpdateOutputStream whose `_log` handle is
|
||||
# ~/.hermes/logs/update.log — write straight to it so the full installer output is kept
|
||||
# (success AND failure) without echoing it to the terminal.
|
||||
if result.stdout:
|
||||
_update_log = getattr(sys.stdout, "_log", None)
|
||||
if _update_log is not None:
|
||||
try:
|
||||
_update_log.write("\n--- cua-driver installer output ---\n" + result.stdout + "\n")
|
||||
_update_log.flush()
|
||||
except Exception:
|
||||
pass
|
||||
if result.returncode != 0:
|
||||
logger.debug("cua-driver installer output:\n%s", result.stdout)
|
||||
installed_binary = _resolved_cua_driver_cmd()
|
||||
if result.returncode == 0 and installed_binary:
|
||||
if is_windows and not _repair_cua_driver_autostart_windows(installed_binary, verbose=verbose):
|
||||
_print_warning(" cua-driver installed, but auto-start was not registered.")
|
||||
if verbose:
|
||||
_print_success(f" {driver_cmd} installed.")
|
||||
_print_cua_platform_notes(is_windows, is_linux, fresh_install=True)
|
||||
return True
|
||||
_print_warning(f" cua-driver {label.lower()} did not complete. Re-run manually:")
|
||||
_print_info(f" {manual_hint}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
_print_warning(f" cua-driver {label.lower()} timed out after {timeout}s.")
|
||||
if not is_windows:
|
||||
_print_info(
|
||||
" If this repeats, a stale installer lock may be present — "
|
||||
f"check {_cua_install_lock_dir()}"
|
||||
)
|
||||
_print_info(f" Re-run manually: {manual_hint}")
|
||||
return False
|
||||
except Exception as e:
|
||||
_print_warning(f" cua-driver {label.lower()} failed: {e}")
|
||||
return False
|
||||
finally:
|
||||
if script_path:
|
||||
_remove_quietly(script_path)
|
||||
@@ -0,0 +1,311 @@
|
||||
"""MCP tool checklists and the non-interactive ``hermes tools enable|disable|list`` command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Set
|
||||
|
||||
from hermes_cli.cli_output import (
|
||||
print_error as _print_error,
|
||||
print_info as _print_info,
|
||||
print_success as _print_success,
|
||||
print_warning as _print_warning,
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.toolset_scope import (
|
||||
_TOOLSET_PLATFORM_RESTRICTIONS,
|
||||
toolset_allowed_for_platform as _toolset_allowed_for_platform,
|
||||
)
|
||||
|
||||
|
||||
def _mcp_match_filter():
|
||||
"""Runtime name-filter matcher (exact names or fnmatch globs), with a literal fallback.
|
||||
|
||||
Must use the SAME semantics as tools/mcp_tool.py registration — a literal ``in`` check renders
|
||||
glob excludes (e.g. ``*team_member*`` from catalog default_excluded manifests) as if nothing
|
||||
were excluded.
|
||||
"""
|
||||
try:
|
||||
from tools.mcp_tool import matches_name_filter
|
||||
return matches_name_filter
|
||||
except ImportError: # pragma: no cover — defensive fallback
|
||||
return lambda tool_name, patterns: tool_name in patterns
|
||||
|
||||
|
||||
def _mcp_preselected(tool_names: List[str], include_set, exclude_set, match) -> Set[int]:
|
||||
"""Indices of tools currently enabled: include mode, exclude mode, or all when unfiltered."""
|
||||
if include_set:
|
||||
return {i for i, tn in enumerate(tool_names) if match(tn, include_set)}
|
||||
if exclude_set:
|
||||
return {i for i, tn in enumerate(tool_names) if not match(tn, exclude_set)}
|
||||
return set(range(len(tool_names)))
|
||||
|
||||
|
||||
def _apply_mcp_checklist(server_name: str, tools_cfg: dict, tool_names: List[str], chosen: Set[int],
|
||||
include_set, exclude_set, match) -> None:
|
||||
"""Write a checklist result back as ``tools.include`` / ``tools.exclude``."""
|
||||
exclude_mode = bool(exclude_set) and not include_set
|
||||
|
||||
if len(chosen) == len(tool_names) and not exclude_mode:
|
||||
# All tools enabled — clear filters so tools the server adds later are auto-enabled.
|
||||
tools_cfg.pop("exclude", None)
|
||||
tools_cfg.pop("include", None)
|
||||
elif exclude_mode:
|
||||
# Exclude-mode server (catalog default_excluded / hand-written tools.exclude): stay in exclude
|
||||
# mode — do NOT demote the dynamic filter to a frozen include list. Unchecked tools become literal
|
||||
# excludes; re-checked literals are dropped; glob patterns are preserved (they intentionally keep
|
||||
# matching tools the vendor ships later).
|
||||
old_exclude = sorted(exclude_set or set())
|
||||
glob_entries = [p for p in old_exclude if "*" in p or "?" in p or "[" in p]
|
||||
literal_entries = {p for p in old_exclude if p not in glob_entries}
|
||||
unchecked = {tn for i, tn in enumerate(tool_names) if i not in chosen}
|
||||
checked = {tool_names[i] for i in chosen}
|
||||
new_literals = (literal_entries - checked) | {tn for tn in unchecked if not match(tn, set(old_exclude))}
|
||||
new_exclude = glob_entries + sorted(new_literals)
|
||||
glob_shadowed = sorted(tn for tn in checked if glob_entries and match(tn, set(glob_entries)))
|
||||
if glob_shadowed:
|
||||
_print_warning(
|
||||
f" {server_name}: {len(glob_shadowed)} re-enabled "
|
||||
f"tool(s) still match glob exclude pattern(s) "
|
||||
f"{glob_entries} and stay excluded — edit "
|
||||
f"mcp_servers.{server_name}.tools.exclude in config.yaml "
|
||||
"to enable them."
|
||||
)
|
||||
if new_exclude:
|
||||
tools_cfg["exclude"] = new_exclude
|
||||
else:
|
||||
tools_cfg.pop("exclude", None)
|
||||
tools_cfg.pop("include", None)
|
||||
else:
|
||||
tools_cfg["include"] = [tool_names[i] for i in sorted(chosen)]
|
||||
tools_cfg.pop("exclude", None) # include-mode now; drop any legacy exclude block
|
||||
|
||||
|
||||
def _configure_mcp_tools_interactive(config: dict):
|
||||
"""Probe each MCP server for its tools, show a per-server curses checklist, and write the result
|
||||
back as ``tools.exclude`` / ``tools.include`` entries in config.yaml."""
|
||||
from hermes_cli.tools_config import save_config
|
||||
|
||||
from hermes_cli.curses_ui import curses_checklist
|
||||
|
||||
mcp_servers = config.get("mcp_servers") or {}
|
||||
if not mcp_servers:
|
||||
_print_info("No MCP servers configured.")
|
||||
return
|
||||
|
||||
enabled_names = [k for k, v in mcp_servers.items() if v.get("enabled", True) not in {False, "false", "0", "no", "off"}]
|
||||
if not enabled_names:
|
||||
_print_info("All MCP servers are disabled.")
|
||||
return
|
||||
|
||||
print()
|
||||
print(color(" Discovering tools from MCP servers...", Colors.YELLOW))
|
||||
print(color(f" Connecting to {len(enabled_names)} server(s): {', '.join(enabled_names)}", Colors.DIM))
|
||||
|
||||
try:
|
||||
from tools.mcp_tool import probe_mcp_server_tools
|
||||
server_tools = probe_mcp_server_tools()
|
||||
except Exception as exc:
|
||||
_print_error(f"Failed to probe MCP servers: {exc}")
|
||||
return
|
||||
|
||||
if not server_tools:
|
||||
_print_warning("Could not discover tools from any MCP server.")
|
||||
_print_info("Check that server commands/URLs are correct and dependencies are installed.")
|
||||
return
|
||||
|
||||
for name in enabled_names:
|
||||
if name not in server_tools:
|
||||
_print_warning(f" Could not connect to '{name}'")
|
||||
|
||||
total_tools = sum(len(tools) for tools in server_tools.values())
|
||||
print(color(f" Found {total_tools} tool(s) across {len(server_tools)} server(s)", Colors.GREEN))
|
||||
print()
|
||||
|
||||
any_changes = False
|
||||
for server_name, tools in server_tools.items():
|
||||
if not tools:
|
||||
_print_info(f" {server_name}: no tools found")
|
||||
continue
|
||||
|
||||
tools_cfg = mcp_servers.get(server_name, {}).get("tools") or {}
|
||||
include_list = tools_cfg.get("include") or []
|
||||
exclude_list = tools_cfg.get("exclude") or []
|
||||
|
||||
labels = []
|
||||
for tool_name, description in tools:
|
||||
desc_short = description[:70] + "..." if len(description) > 70 else description
|
||||
labels.append(f"{tool_name} ({desc_short})" if desc_short else tool_name)
|
||||
|
||||
match = _mcp_match_filter()
|
||||
tool_names = [t[0] for t in tools]
|
||||
include_set = {str(p) for p in include_list} if include_list else None
|
||||
exclude_set = {str(p) for p in exclude_list} if exclude_list else None
|
||||
pre_selected = _mcp_preselected(tool_names, include_set, exclude_set, match)
|
||||
|
||||
chosen = curses_checklist(
|
||||
f"MCP Server: {server_name} ({len(tools)} tools)", labels, pre_selected, cancel_returns=pre_selected,
|
||||
)
|
||||
|
||||
if chosen == pre_selected:
|
||||
_print_info(f" {server_name}: no changes")
|
||||
continue
|
||||
|
||||
tools_cfg = mcp_servers.setdefault(server_name, {}).setdefault("tools", {})
|
||||
_apply_mcp_checklist(server_name, tools_cfg, tool_names, chosen, include_set, exclude_set, match)
|
||||
|
||||
_print_success(f" {server_name}: {len(chosen)} enabled, {len(tools) - len(chosen)} disabled")
|
||||
any_changes = True
|
||||
|
||||
if any_changes:
|
||||
save_config(config)
|
||||
print()
|
||||
print(color(" ✓ MCP tool configuration saved", Colors.GREEN))
|
||||
else:
|
||||
print(color(" No changes to MCP tools", Colors.DIM))
|
||||
|
||||
|
||||
def _apply_toolset_change(config: dict, platform: str, toolset_names: List[str], action: str):
|
||||
"""Add or remove built-in toolsets for a platform."""
|
||||
from hermes_cli.tools_config import _get_platform_tools, _save_platform_tools
|
||||
|
||||
enabled = _get_platform_tools(config, platform, include_default_mcp_servers=False)
|
||||
updated = enabled - set(toolset_names) if action == "disable" else enabled | set(toolset_names)
|
||||
_save_platform_tools(config, platform, updated)
|
||||
|
||||
|
||||
def _apply_mcp_change(config: dict, targets: List[str], action: str) -> Set[str]:
|
||||
"""Add or remove specific MCP tools from a server's exclude list."""
|
||||
failed_servers: Set[str] = set()
|
||||
mcp_servers = config.get("mcp_servers") or {}
|
||||
|
||||
for target in targets:
|
||||
server_name, tool_name = target.split(":", 1)
|
||||
if server_name not in mcp_servers:
|
||||
failed_servers.add(server_name)
|
||||
continue
|
||||
tools_cfg = mcp_servers[server_name].setdefault("tools", {})
|
||||
exclude = list(tools_cfg.get("exclude") or [])
|
||||
if action == "disable":
|
||||
if tool_name not in exclude:
|
||||
exclude.append(tool_name)
|
||||
else:
|
||||
exclude = [t for t in exclude if t != tool_name]
|
||||
tools_cfg["exclude"] = exclude
|
||||
|
||||
return failed_servers
|
||||
|
||||
|
||||
def _print_tools_list(enabled_toolsets: set, mcp_servers: dict, platform: str = "cli"):
|
||||
"""Print a summary of enabled/disabled toolsets and MCP tool filters."""
|
||||
from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS, _get_effective_configurable_toolsets
|
||||
|
||||
effective_all = _get_effective_configurable_toolsets()
|
||||
effective = [(k, l, d) for (k, l, d) in effective_all if _toolset_allowed_for_platform(k, platform)]
|
||||
builtin_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
|
||||
|
||||
def _print_rows(entries):
|
||||
for ts_key, label in entries:
|
||||
status = (color("✓ enabled", Colors.GREEN) if ts_key in enabled_toolsets
|
||||
else color("✗ disabled", Colors.RED))
|
||||
print(f" {status} {ts_key} {color(label, Colors.DIM)}")
|
||||
|
||||
print(f"Built-in toolsets ({platform}):")
|
||||
_print_rows((k, l) for k, l, _ in effective if k in builtin_keys)
|
||||
|
||||
plugin_entries = [(k, l) for k, l, _ in effective if k not in builtin_keys]
|
||||
if plugin_entries:
|
||||
print()
|
||||
print(f"Plugin toolsets ({platform}):")
|
||||
_print_rows(plugin_entries)
|
||||
|
||||
if mcp_servers:
|
||||
print()
|
||||
print("MCP servers:")
|
||||
for srv_name, srv_cfg in mcp_servers.items():
|
||||
tools_cfg = srv_cfg.get("tools") or {}
|
||||
exclude = tools_cfg.get("exclude") or []
|
||||
include = tools_cfg.get("include") or []
|
||||
if include:
|
||||
_print_info(f"{srv_name} [include only: {', '.join(include)}]")
|
||||
elif exclude:
|
||||
_print_info(f"{srv_name} [excluded: {color(', '.join(exclude), Colors.YELLOW)}]")
|
||||
else:
|
||||
_print_info(f"{srv_name} {color('all tools enabled', Colors.DIM)}")
|
||||
|
||||
|
||||
def _known_tool_platforms() -> set[str]:
|
||||
"""Return built-in plus discovered plugin platform names. Plugin platforms register at runtime, not
|
||||
in the static CLI display registry, and must be recognized so an active plugin platform can audit
|
||||
its authority."""
|
||||
from hermes_cli.tools_config import PLATFORMS
|
||||
|
||||
known = set(PLATFORMS)
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
from gateway.platform_registry import platform_registry
|
||||
|
||||
discover_plugins() # idempotent
|
||||
known.update(platform_registry.registered_names())
|
||||
except Exception:
|
||||
# Plugin discovery is optional: keep the built-in path when a plugin is malformed or deps are missing.
|
||||
pass
|
||||
return known
|
||||
|
||||
|
||||
def tools_disable_enable_command(args):
|
||||
"""Enable, disable, or list tools for a platform."""
|
||||
from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS, _get_platform_tools, _get_plugin_toolset_keys, load_config, save_config
|
||||
|
||||
action = args.tools_action
|
||||
platform = getattr(args, "platform", "cli")
|
||||
config = load_config()
|
||||
|
||||
valid_platforms = _known_tool_platforms()
|
||||
if platform not in valid_platforms:
|
||||
_print_error(f"Unknown platform '{platform}'. Valid: {', '.join(sorted(valid_platforms))}")
|
||||
return
|
||||
|
||||
if action == "list":
|
||||
_print_tools_list(_get_platform_tools(config, platform, include_default_mcp_servers=False),
|
||||
config.get("mcp_servers") or {}, platform)
|
||||
return
|
||||
|
||||
targets: List[str] = args.names
|
||||
toolset_targets = [t for t in targets if ":" not in t]
|
||||
mcp_targets = [t for t in targets if ":" in t]
|
||||
|
||||
valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} | _get_plugin_toolset_keys()
|
||||
unknown_toolsets = [t for t in toolset_targets if t not in valid_toolsets]
|
||||
if unknown_toolsets:
|
||||
for name in unknown_toolsets:
|
||||
_print_error(f"Unknown toolset '{name}'")
|
||||
toolset_targets = [t for t in toolset_targets if t in valid_toolsets]
|
||||
|
||||
# Reject platform-scoped toolsets on platforms that don't allow them.
|
||||
restricted_targets = [t for t in toolset_targets if not _toolset_allowed_for_platform(t, platform)]
|
||||
if restricted_targets:
|
||||
for name in restricted_targets:
|
||||
allowed = sorted(_TOOLSET_PLATFORM_RESTRICTIONS.get(name) or set())
|
||||
_print_error(f"Toolset '{name}' is not available on platform '{platform}' (only: {', '.join(allowed)})")
|
||||
toolset_targets = [t for t in toolset_targets if t not in restricted_targets]
|
||||
|
||||
if toolset_targets:
|
||||
_apply_toolset_change(config, platform, toolset_targets, action)
|
||||
|
||||
failed_servers: Set[str] = set()
|
||||
if mcp_targets:
|
||||
failed_servers = _apply_mcp_change(config, mcp_targets, action)
|
||||
for srv in failed_servers:
|
||||
_print_error(f"MCP server '{srv}' not found in config")
|
||||
|
||||
save_config(config)
|
||||
|
||||
successful = [
|
||||
t for t in targets
|
||||
if t not in unknown_toolsets
|
||||
and t not in restricted_targets
|
||||
and (":" not in t or t.split(":")[0] not in failed_servers)
|
||||
]
|
||||
if successful:
|
||||
verb = "Disabled" if action == "disable" else "Enabled"
|
||||
_print_success(f"{verb}: {', '.join(successful)}")
|
||||
@@ -0,0 +1,550 @@
|
||||
"""Post-setup install hooks and installed-state predicates for `hermes tools` provider rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Set
|
||||
|
||||
from hermes_cli.cli_output import (
|
||||
print_error as _print_error,
|
||||
print_info as _print_info,
|
||||
print_success as _print_success,
|
||||
print_warning as _print_warning,
|
||||
)
|
||||
from hermes_cli.config import get_env_value
|
||||
from hermes_cli.tools_config_cua import (
|
||||
_cua_driver_install_ready,
|
||||
_pip_install,
|
||||
_post_setup_no_window_flags,
|
||||
install_cua_driver,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("hermes_cli.tools_config")
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
|
||||
def _ensure_browser_use_cli(*, verbose_hints: bool = False) -> None:
|
||||
"""Install the Browser Use CLI if it isn't already runnable.
|
||||
|
||||
Primary driver engine for EVERY browser backend except Camofox (Firefox-based, no CDP surface).
|
||||
MANAGED-FIRST: a browser-use on the user's PATH does NOT satisfy this check — only the
|
||||
Hermes-managed ``$HERMES_HOME/bin`` copy does.
|
||||
"""
|
||||
_print_info(" Ensuring browser-use CLI (managed install)...")
|
||||
try:
|
||||
from tools.browser_use_cli import install_cli
|
||||
ok, message = install_cli()
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
ok, message = False, f"install failed: {exc}"
|
||||
if ok:
|
||||
_print_success(f" {message}")
|
||||
else:
|
||||
for line in str(message).splitlines():
|
||||
_print_warning(f" {line[:200]}")
|
||||
if shutil.which("uvx"):
|
||||
_print_info(" Falling back to zero-install runs via `uvx browser-use`")
|
||||
else:
|
||||
_print_info(" Install manually: uv tool install browser-use (https://docs.astral.sh/uv/)")
|
||||
if verbose_hints:
|
||||
_print_info(" Local Chrome needs remote debugging: chrome://inspect/#remote-debugging")
|
||||
_print_info(" Cloud browsers: browser-use auth login (or set BROWSER_USE_API_KEY)")
|
||||
|
||||
|
||||
def _post_setup_lightpanda() -> None:
|
||||
# Browser Use mode spawns ``lightpanda serve``; built-in tools go through agent-browser. No Chromium needed.
|
||||
_ensure_browser_use_cli()
|
||||
from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary
|
||||
|
||||
lightpanda_bin = find_lightpanda_binary()
|
||||
if lightpanda_bin:
|
||||
_print_success(f" Lightpanda found: {lightpanda_bin}")
|
||||
else:
|
||||
_print_warning(" lightpanda binary not found on PATH, ~/.lightpanda or ~/.local/bin")
|
||||
_print_info(f" {LIGHTPANDA_INSTALL_HINT}")
|
||||
if os.name == "nt":
|
||||
_print_info(" Lightpanda has no native Windows build; run Hermes under WSL2.")
|
||||
|
||||
|
||||
def _install_chromium(install_cmd: list[str]) -> None:
|
||||
"""Run the agent-browser Chromium install command and report the outcome."""
|
||||
_print_info(" Installing Chromium (~170MB one-time download)...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
install_cmd,
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=str(PROJECT_ROOT), timeout=600,
|
||||
creationflags=_post_setup_no_window_flags(),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" Chromium installed")
|
||||
# Invalidate the cached "missing" flag so later check_browser_requirements() calls see the install.
|
||||
import tools.browser_tool as _bt
|
||||
_bt._cached_chromium_installed = None
|
||||
else:
|
||||
_print_warning(" Chromium install failed:")
|
||||
tail = (result.stderr or result.stdout or "").strip().splitlines()[-3:]
|
||||
for line in tail:
|
||||
_print_info(f" {line[:200]}")
|
||||
_print_info(" Run manually: npx agent-browser install --with-deps")
|
||||
except subprocess.TimeoutExpired:
|
||||
_print_warning(" Chromium install timed out (>10min)")
|
||||
_print_info(" Run manually: npx agent-browser install --with-deps")
|
||||
except Exception as exc:
|
||||
_print_warning(f" Chromium install failed: {exc}")
|
||||
_print_info(" Run manually: npx agent-browser install --with-deps")
|
||||
|
||||
|
||||
def _post_setup_agent_browser(post_setup_key: str) -> None:
|
||||
"""``agent_browser`` (local Chromium) and ``browserbase`` (cloud rows) hooks.
|
||||
|
||||
agent-browser is not a root package.json dependency — it resolves lazily via npx (or a
|
||||
global/Hermes-managed install), so there is no ``npm install`` step here.
|
||||
"""
|
||||
# Every non-Camofox backend drives through the Browser Use CLI — install it here too.
|
||||
_ensure_browser_use_cli()
|
||||
try:
|
||||
# Lazy import so the tools_config UI doesn't pull in browser_tool at import time.
|
||||
from tools.browser_tool import (
|
||||
_chromium_installed,
|
||||
_running_in_docker,
|
||||
_find_agent_browser,
|
||||
_resolve_npx_bin,
|
||||
_is_npx_agent_browser_sentinel,
|
||||
AGENT_BROWSER_NPX_SPEC,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
_print_warning(f" Could not check Chromium status: {exc}")
|
||||
return
|
||||
|
||||
# Reuse the runtime resolution cascade (PATH -> Homebrew/Hermes-managed node -> npx) rather than
|
||||
# a bare shutil.which — Hermes-managed-Node-only setups resolve agent-browser/npx only that way.
|
||||
try:
|
||||
browser_cmd = _find_agent_browser(validate=False)
|
||||
except FileNotFoundError:
|
||||
_print_warning(" npx not found - browser tools require Node.js: https://nodejs.org")
|
||||
return
|
||||
|
||||
# Only the local provider needs Chromium on disk; cloud providers host their own.
|
||||
if post_setup_key != "agent_browser":
|
||||
return
|
||||
|
||||
# Without Chromium the CLI hangs on first use until the command timeout fires. Skip inside
|
||||
# Docker — the image bakes Chromium in, and runtime users usually can't write PLAYWRIGHT_BROWSERS_PATH.
|
||||
if _chromium_installed():
|
||||
_print_success(" Chromium browser already installed, nothing to do")
|
||||
return
|
||||
|
||||
if _running_in_docker():
|
||||
_print_warning(" Chromium is missing but you're running in Docker.")
|
||||
_print_info(" Pull the latest image to get the bundled Chromium:")
|
||||
_print_info(" docker pull ghcr.io/nousresearch/hermes-agent:latest")
|
||||
return
|
||||
|
||||
if _is_npx_agent_browser_sentinel(browser_cmd):
|
||||
# Re-resolve npx via the same cascade _find_agent_browser used — a bare shutil.which("npx")
|
||||
# would silently diverge and hand subprocess.run a None argument.
|
||||
npx_bin = _resolve_npx_bin()
|
||||
if not npx_bin:
|
||||
_print_warning(" npx not found - install Chromium manually: npx agent-browser install --with-deps")
|
||||
return
|
||||
install_cmd = [npx_bin, "--ignore-scripts", "-y", AGENT_BROWSER_NPX_SPEC, "install", "--with-deps"]
|
||||
else:
|
||||
install_cmd = [browser_cmd, "install", "--with-deps"]
|
||||
_install_chromium(install_cmd)
|
||||
|
||||
|
||||
def _post_setup_camofox() -> None:
|
||||
from hermes_constants import find_node_executable
|
||||
|
||||
camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser"
|
||||
_npm_bin = find_node_executable("npm")
|
||||
if camofox_dir.exists():
|
||||
_print_success(" Camofox already installed, nothing to do")
|
||||
elif _npm_bin:
|
||||
_print_info(" Installing Camofox browser server...")
|
||||
# Absolute npm path so the .cmd shim executes on Windows; --workspaces=false avoids resolving apps/desktop.
|
||||
result = subprocess.run(
|
||||
[_npm_bin, "install", "--silent", "--workspaces=false"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=str(PROJECT_ROOT),
|
||||
creationflags=_post_setup_no_window_flags(),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" Camofox installed")
|
||||
else:
|
||||
_print_warning(" npm install failed - run manually: npm install --workspaces=false")
|
||||
if camofox_dir.exists():
|
||||
_print_info(" Start the Camofox server:")
|
||||
_print_info(" npx @askjo/camofox-browser")
|
||||
_print_info(" First run downloads the Camoufox engine (~300MB)")
|
||||
_print_info(" Or use Docker: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser")
|
||||
elif not _npm_bin:
|
||||
_print_warning(" Node.js not found. Install Camofox via Docker:")
|
||||
_print_info(" docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser")
|
||||
|
||||
|
||||
_KITTENTTS_WHEEL_URL = "https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl"
|
||||
|
||||
# Post-setup hooks that only pip-install a Python package. Fields: module (import probe; present → skip
|
||||
# install), label (status lines), installing (progress line), args (_pip_install args — keep in sync with
|
||||
# _RESTORABLE_PYTHON_TOOL_DEPENDENCIES), manual ("Run manually:" command on failure/timeout),
|
||||
# on_install (info lines only after a fresh install), always (info lines whenever the package is present).
|
||||
_PIP_POST_SETUP_HOOKS: dict = {
|
||||
"faster_whisper": {
|
||||
"module": "faster_whisper", "label": "faster-whisper",
|
||||
"installing": "Installing faster-whisper (model ~150MB downloads on first use)...",
|
||||
"args": ["-U", "faster-whisper", "--quiet"], "manual": "uv pip install -U faster-whisper",
|
||||
"on_install": ("Model sizes: tiny, base (default), small, medium, large-v3",
|
||||
"Change via stt.local.model in ~/.hermes/config.yaml"),
|
||||
"always": (),
|
||||
},
|
||||
"kittentts": {
|
||||
"module": "kittentts", "label": "kittentts",
|
||||
"installing": "Installing kittentts (~25-80MB model, CPU-only)...",
|
||||
"args": ["-U", _KITTENTTS_WHEEL_URL, "soundfile", "--quiet"],
|
||||
"manual": f"uv pip install -U '{_KITTENTTS_WHEEL_URL}' soundfile",
|
||||
"on_install": ("Voices: Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo",
|
||||
"Models: KittenML/kitten-tts-nano-0.8-int8 (25MB), micro (41MB), mini (80MB)"),
|
||||
"always": (),
|
||||
},
|
||||
"piper": {
|
||||
"module": "piper", "label": "piper-tts",
|
||||
"installing": "Installing piper-tts (~14MB wheel, voices downloaded on first use)...",
|
||||
"args": ["-U", "piper-tts", "--quiet"], "manual": "uv pip install -U piper-tts",
|
||||
"on_install": (),
|
||||
"always": ("Default voice: en_US-lessac-medium (downloaded on first TTS call)",
|
||||
"Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md",
|
||||
"Switch voices by setting tts.piper.voice in ~/.hermes/config.yaml"),
|
||||
},
|
||||
"ddgs": {
|
||||
"module": "ddgs", "label": "ddgs",
|
||||
"installing": "Installing ddgs (DuckDuckGo search package)...",
|
||||
"args": ["-U", "ddgs", "--quiet"], "manual": "uv pip install -U ddgs",
|
||||
"on_install": (),
|
||||
"always": ("No API key required. DuckDuckGo enforces server-side rate limits.",
|
||||
"Pair with an extract provider if you also need web_extract."),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _post_setup_pip(spec: dict) -> None:
|
||||
"""Run one ``_PIP_POST_SETUP_HOOKS`` entry."""
|
||||
label = spec["label"]
|
||||
freshly_installed = False
|
||||
try:
|
||||
__import__(spec["module"])
|
||||
_print_success(f" {label} is already installed")
|
||||
except ImportError:
|
||||
_print_info(f" {spec['installing']}")
|
||||
try:
|
||||
result = _pip_install(spec["args"], timeout=300)
|
||||
if result.returncode == 0:
|
||||
_print_success(f" {label} installed")
|
||||
freshly_installed = True
|
||||
else:
|
||||
_print_warning(f" {label} install failed:")
|
||||
_print_info(f" {(result.stderr or '').strip()[:300]}")
|
||||
_print_info(f" Run manually: {spec['manual']}")
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
_print_warning(f" {label} install timed out (>5min)")
|
||||
_print_info(f" Run manually: {spec['manual']}")
|
||||
return
|
||||
if freshly_installed:
|
||||
for line in spec["on_install"]:
|
||||
_print_info(f" {line}")
|
||||
for line in spec["always"]:
|
||||
_print_info(f" {line}")
|
||||
|
||||
|
||||
def _post_setup_spotify() -> None:
|
||||
# Full `hermes auth spotify` flow: no client_id yet → interactive wizard (persists to ~/.hermes/.env)
|
||||
# then PKCE; existing app → OAuth only.
|
||||
from types import SimpleNamespace
|
||||
try:
|
||||
from hermes_cli.auth import login_spotify_command
|
||||
except Exception as exc:
|
||||
_print_warning(f" Could not load Spotify auth: {exc}")
|
||||
_print_info(" Run manually: hermes auth spotify")
|
||||
return
|
||||
_print_info(" Starting Spotify login...")
|
||||
try:
|
||||
login_spotify_command(SimpleNamespace(
|
||||
client_id=None, redirect_uri=None, scope=None, no_browser=False, timeout=None,
|
||||
))
|
||||
_print_success(" Spotify authenticated")
|
||||
except SystemExit as exc:
|
||||
# User aborted the wizard or OAuth failed — don't fail the toolset enable.
|
||||
_print_warning(f" Spotify login did not complete: {exc}")
|
||||
_print_info(" Run later: hermes auth spotify")
|
||||
except Exception as exc:
|
||||
_print_warning(f" Spotify login failed: {exc}")
|
||||
_print_info(" Run manually: hermes auth spotify")
|
||||
|
||||
|
||||
def _post_setup_langfuse() -> None:
|
||||
try:
|
||||
__import__("langfuse")
|
||||
_print_success(" langfuse SDK already installed")
|
||||
except ImportError:
|
||||
_print_info(" Installing langfuse SDK...")
|
||||
result = _pip_install(["langfuse", "--quiet"], timeout=120)
|
||||
if result.returncode == 0:
|
||||
_print_success(" langfuse SDK installed")
|
||||
else:
|
||||
_print_warning(" langfuse SDK install failed — run manually: uv pip install langfuse")
|
||||
# The bundled observability/langfuse plugin is opt-in (standalone plugins don't load until enabled).
|
||||
try:
|
||||
from hermes_cli.plugins_cmd import _get_enabled_set, _save_enabled_set
|
||||
enabled = _get_enabled_set()
|
||||
if "observability/langfuse" in enabled or "langfuse" in enabled:
|
||||
_print_success(" Plugin observability/langfuse already enabled")
|
||||
else:
|
||||
enabled.add("observability/langfuse")
|
||||
_save_enabled_set(enabled)
|
||||
_print_success(" Plugin observability/langfuse enabled")
|
||||
except Exception as exc:
|
||||
_print_warning(f" Could not enable plugin automatically: {exc}")
|
||||
_print_info(" Run manually: hermes plugins enable observability/langfuse")
|
||||
_print_info(" Restart Hermes for tracing to take effect.")
|
||||
_print_info(" Verify: hermes plugins list")
|
||||
|
||||
|
||||
def _post_setup_xai_grok() -> None:
|
||||
"""Shared xAI credential bootstrap for any picker row that talks to xAI (TTS, STT, Video Gen,
|
||||
x_search …). Accepts a SuperGrok-tier OAuth token (preferred — billed to the existing
|
||||
subscription) or a raw XAI_API_KEY; the rows declare empty env_vars so the auth UX lives here.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import get_xai_oauth_auth_status
|
||||
oauth_logged_in = bool(get_xai_oauth_auth_status().get("logged_in"))
|
||||
except Exception:
|
||||
oauth_logged_in = False
|
||||
existing_api_key = get_env_value("XAI_API_KEY")
|
||||
|
||||
if oauth_logged_in:
|
||||
_print_success(" xAI will use your xAI Grok OAuth (SuperGrok / Premium+) credentials")
|
||||
return
|
||||
if existing_api_key:
|
||||
_print_success(" xAI will use your existing XAI_API_KEY")
|
||||
return
|
||||
|
||||
_print_info(" xAI needs credentials. Choose one:")
|
||||
try:
|
||||
from hermes_cli.setup import _run_xai_oauth_login_from_setup, prompt_choice, prompt as _setup_prompt
|
||||
from hermes_cli.config import save_env_value
|
||||
except Exception as exc:
|
||||
_print_warning(f" Could not load setup helpers: {exc}")
|
||||
_print_info(" Run later: hermes auth add xai-oauth (or set XAI_API_KEY)")
|
||||
return
|
||||
|
||||
idx = prompt_choice(
|
||||
" How do you want xAI to authenticate?",
|
||||
choices=[
|
||||
"Sign in with xAI Grok OAuth (SuperGrok / Premium+) — browser login",
|
||||
"Paste an xAI API key (console.x.ai)",
|
||||
"Skip — configure later via `hermes auth add xai-oauth`",
|
||||
],
|
||||
default=0,
|
||||
)
|
||||
if idx == 0:
|
||||
if _run_xai_oauth_login_from_setup():
|
||||
_print_success(" Logged in — xAI will use these OAuth credentials")
|
||||
else:
|
||||
_print_warning(" xAI Grok OAuth login did not complete. Run later: hermes auth add xai-oauth")
|
||||
elif idx == 1:
|
||||
api_key = _setup_prompt(" xAI API key", password=True)
|
||||
if api_key:
|
||||
save_env_value("XAI_API_KEY", api_key)
|
||||
_print_success(" XAI_API_KEY saved")
|
||||
else:
|
||||
_print_warning(" No API key provided. Run later: hermes auth add xai-oauth")
|
||||
else:
|
||||
_print_info(" xAI will remain inactive until credentials are configured.")
|
||||
|
||||
|
||||
# post_setup key -> hook. Unknown keys are a silent no-op (callers validate against valid_post_setup_keys()).
|
||||
_POST_SETUP_HOOKS: dict = {
|
||||
"lightpanda": _post_setup_lightpanda,
|
||||
"agent_browser": lambda: _post_setup_agent_browser("agent_browser"),
|
||||
"browserbase": lambda: _post_setup_agent_browser("browserbase"),
|
||||
"browser_use_cli": lambda: _ensure_browser_use_cli(verbose_hints=True),
|
||||
"camofox": _post_setup_camofox,
|
||||
"cua_driver": lambda: install_cua_driver(upgrade=False),
|
||||
"spotify": _post_setup_spotify,
|
||||
"langfuse": _post_setup_langfuse,
|
||||
"xai_grok": _post_setup_xai_grok,
|
||||
**{key: (lambda spec=spec: _post_setup_pip(spec)) for key, spec in _PIP_POST_SETUP_HOOKS.items()},
|
||||
}
|
||||
|
||||
|
||||
def _run_post_setup(post_setup_key: str):
|
||||
"""Run post-setup hooks for tools that need extra installation steps."""
|
||||
hook = _POST_SETUP_HOOKS.get(post_setup_key)
|
||||
if hook is not None:
|
||||
hook()
|
||||
|
||||
|
||||
def valid_post_setup_keys() -> Set[str]:
|
||||
"""Return the set of post-setup keys declared by any visible provider (``TOOL_CATEGORIES`` plus
|
||||
plugin-registered providers). This is the allowlist ``post-setup`` and the dashboard endpoint
|
||||
validate against, so a caller cannot drive ``_run_post_setup`` with an arbitrary key.
|
||||
"""
|
||||
from hermes_cli.tools_config import (
|
||||
TOOL_CATEGORIES,
|
||||
_plugin_browser_providers,
|
||||
_plugin_image_gen_providers,
|
||||
_plugin_video_gen_providers,
|
||||
_plugin_web_search_providers,
|
||||
)
|
||||
|
||||
keys: Set[str] = set()
|
||||
for cat in TOOL_CATEGORIES.values():
|
||||
for prov in cat.get("providers", []):
|
||||
ps = prov.get("post_setup")
|
||||
if ps:
|
||||
keys.add(ps)
|
||||
for builder in (
|
||||
_plugin_web_search_providers, _plugin_image_gen_providers,
|
||||
_plugin_video_gen_providers, _plugin_browser_providers,
|
||||
):
|
||||
try:
|
||||
for prov in builder():
|
||||
ps = prov.get("post_setup")
|
||||
if ps:
|
||||
keys.add(ps)
|
||||
except Exception: # pragma: no cover — defensive; plugins optional
|
||||
continue
|
||||
return keys
|
||||
|
||||
|
||||
def run_post_setup_command(args) -> int:
|
||||
"""``hermes tools post-setup <key>`` — non-interactive runner the dashboard spawns so the GUI can
|
||||
drive backend setup without re-implementing install logic. Exit code: 0 ok, 2 unknown key.
|
||||
"""
|
||||
key = getattr(args, "post_setup_key", None)
|
||||
if not key:
|
||||
_print_error("Usage: hermes tools post-setup <key>")
|
||||
return 2
|
||||
valid = valid_post_setup_keys()
|
||||
if key not in valid:
|
||||
_print_error(f"Unknown post-setup key: {key!r}. Valid keys: {', '.join(sorted(valid)) or '(none)'}")
|
||||
return 2
|
||||
_print_info(f"Running post-setup hook: {key}")
|
||||
try:
|
||||
_run_post_setup(key)
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
_print_error(f"Post-setup failed: {exc}")
|
||||
return 1
|
||||
_print_success(f"Post-setup '{key}' complete")
|
||||
return 0
|
||||
|
||||
|
||||
# post_setup_key -> predicate(): True when the install side-effect is already satisfied. Used by
|
||||
# `_toolset_needs_configuration_prompt` to force provider setup when a no-key provider still needs a
|
||||
# binary/dependency install (otherwise toggling the toolset on silently skips the hook). Only add an
|
||||
# entry when the post_setup is the ONLY install side-effect for a no-key provider and the check is
|
||||
# local, bounded, and import-light.
|
||||
_POST_SETUP_INSTALLED: dict = {
|
||||
"cua_driver": lambda: _cua_driver_install_ready(),
|
||||
}
|
||||
|
||||
|
||||
def _post_setup_already_installed(post_setup_key: str) -> bool:
|
||||
"""Return True when the post_setup install side-effect is satisfied (or no check is registered)."""
|
||||
predicate = _POST_SETUP_INSTALLED.get(post_setup_key)
|
||||
if predicate is None:
|
||||
return True
|
||||
try:
|
||||
return bool(predicate())
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _module_installed(module_name: str) -> bool:
|
||||
"""Cheap importable-without-importing check (no heavy side effects)."""
|
||||
import importlib.util
|
||||
|
||||
try:
|
||||
return importlib.util.find_spec(module_name) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# Python deps installed via ``hermes tools`` aren't in the managed runtime's locked ``all`` sync, so a
|
||||
# runtime replacement snapshots this static allowlist before the old site-packages disappears and
|
||||
# restores it afterward. Keep install args in sync with the ``_run_post_setup`` hooks.
|
||||
_RESTORABLE_PYTHON_TOOL_DEPENDENCIES: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||
"faster_whisper": ("faster_whisper", ("-U", "faster-whisper")),
|
||||
"kittentts": ("kittentts", ("-U", _KITTENTTS_WHEEL_URL, "soundfile")),
|
||||
"piper": ("piper", ("-U", "piper-tts")),
|
||||
"ddgs": ("ddgs", ("-U", "ddgs")),
|
||||
"langfuse": ("langfuse", ("langfuse",)),
|
||||
}
|
||||
|
||||
|
||||
def active_restorable_python_tool_dependencies() -> list[str]:
|
||||
"""Return ``hermes tools`` Python dependencies present in this runtime."""
|
||||
return [
|
||||
name for name, (module_name, _install_args) in _RESTORABLE_PYTHON_TOOL_DEPENDENCIES.items()
|
||||
if _module_installed(module_name)
|
||||
]
|
||||
|
||||
|
||||
def restorable_python_tool_dependency(name: str) -> tuple[str, tuple[str, ...]] | None:
|
||||
"""Return the import probe and pip arguments for an allowlisted tool."""
|
||||
return _RESTORABLE_PYTHON_TOOL_DEPENDENCIES.get(name)
|
||||
|
||||
|
||||
def _agent_browser_installed() -> bool:
|
||||
"""True when everything ``_run_post_setup("agent_browser")`` installs is present: the agent-browser
|
||||
CLI *and* the Chromium build it drives (or the Lightpanda engine, which needs no Chromium), so
|
||||
"Run setup" flips to installed only when re-running it would be a no-op.
|
||||
"""
|
||||
from hermes_cli.nous_subscription import _local_browser_runnable
|
||||
|
||||
# The hook runs in a spawned process; this probe runs in the long-lived web-server/CLI process whose
|
||||
# browser_tool may have cached a stale "Chromium missing" result. Drop the cache so the pill flips to Ready.
|
||||
bt = sys.modules.get("tools.browser_tool")
|
||||
if bt is not None:
|
||||
bt._cached_chromium_installed = None
|
||||
return _local_browser_runnable()
|
||||
|
||||
|
||||
def _camofox_installed() -> bool:
|
||||
"""True when the Camofox npm package ``_run_post_setup("camofox")`` installs is in node_modules."""
|
||||
return (PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser").exists()
|
||||
|
||||
|
||||
def _lightpanda_installed() -> bool:
|
||||
"""True when a lightpanda binary is on PATH or in a known install dir."""
|
||||
try:
|
||||
from tools.browser_lightpanda import find_lightpanda_binary
|
||||
return find_lightpanda_binary() is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _cloud_agent_browser_installed() -> bool:
|
||||
"""Installed-check for the ``browserbase`` hook: cloud providers host their own Chromium, so
|
||||
presence of the agent-browser CLI is the whole contract."""
|
||||
from hermes_cli.nous_subscription import _has_agent_browser
|
||||
return _has_agent_browser()
|
||||
|
||||
|
||||
# post_setup_key -> predicate(): True when the install side-effect is satisfied. Used by
|
||||
# ``provider_readiness_status`` to mark a keyless post_setup row "ready" vs "needs_setup"; mirrors the
|
||||
# installed-checks the hooks perform. ``xai_grok`` is absent — a credential bootstrap handled as an
|
||||
# auth check. Late-bound lambdas so tests can monkeypatch the underlying predicates.
|
||||
_POST_SETUP_READY: dict = {
|
||||
**{key: (lambda m=module: _module_installed(m)) for key, (module, _args) in _RESTORABLE_PYTHON_TOOL_DEPENDENCIES.items()},
|
||||
"agent_browser": lambda: _agent_browser_installed(),
|
||||
"browserbase": lambda: _cloud_agent_browser_installed(),
|
||||
"camofox": lambda: _camofox_installed(),
|
||||
"lightpanda": lambda: _lightpanda_installed(),
|
||||
"cua_driver": lambda: _cua_driver_install_ready(),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ from hermes_cli.tools_config import (
|
||||
|
||||
|
||||
def _quiet(monkeypatch):
|
||||
import hermes_cli.tools_config as tc
|
||||
import hermes_cli.tools_config_providers as tc
|
||||
|
||||
monkeypatch.setattr(tc, "_print_success", lambda *a, **k: None)
|
||||
monkeypatch.setattr(tc, "_print_info", lambda *a, **k: None, raising=False)
|
||||
@@ -80,11 +80,13 @@ def test_image_and_video_selectors_share_the_selection_contract(monkeypatch):
|
||||
def _quiet_reconfigure(monkeypatch):
|
||||
"""Silence prints + model pickers for _reconfigure_provider paths."""
|
||||
import hermes_cli.tools_config as tc
|
||||
import hermes_cli.tools_config_providers as tcp
|
||||
|
||||
monkeypatch.setattr(tc, "_print_success", lambda *a, **k: None)
|
||||
monkeypatch.setattr(tc, "_print_info", lambda *a, **k: None, raising=False)
|
||||
monkeypatch.setattr(tc, "_print_warning", lambda *a, **k: None, raising=False)
|
||||
monkeypatch.setattr(tc, "_configure_imagegen_model", lambda *a, **k: None)
|
||||
monkeypatch.setattr(tcp, "_print_success", lambda *a, **k: None)
|
||||
monkeypatch.setattr(tcp, "_print_info", lambda *a, **k: None, raising=False)
|
||||
monkeypatch.setattr(tcp, "_print_warning", lambda *a, **k: None, raising=False)
|
||||
monkeypatch.setattr(tcp, "_configure_imagegen_model", lambda *a, **k: None)
|
||||
# _configure_provider resolves the post-setup hook lazily from tools_config.
|
||||
monkeypatch.setattr(tc, "_run_post_setup", lambda *a, **k: None, raising=False)
|
||||
# Managed rows gate on live Portal auth — stub it green.
|
||||
import hermes_cli.nous_subscription as ns
|
||||
|
||||
@@ -13,7 +13,7 @@ must:
|
||||
incomplete installations, and install when missing.
|
||||
|
||||
The pre-install arch probe that used to live alongside this function was
|
||||
deleted (see top-of-file comment in tools_config.py) — the upstream
|
||||
deleted (see the release-probe comment in tools_config_cua.py) — the upstream
|
||||
installer has CUA_DRIVER_RS_BAKED_VERSION baked in by CD and errors
|
||||
cleanly on missing-arch assets, and the upgrade path uses
|
||||
``cua_driver_update_check()`` (which shells `cua-driver check-update
|
||||
@@ -61,7 +61,7 @@ def _runtime_manifest(version="0.20.0", *, omit=None):
|
||||
|
||||
class TestCuaDriverRuntimeContract:
|
||||
def test_current_manifest_is_ready(self):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
result = SimpleNamespace(
|
||||
returncode=0,
|
||||
@@ -80,7 +80,7 @@ class TestCuaDriverRuntimeContract:
|
||||
|
||||
@pytest.mark.parametrize("version", ["0.19.4", "bad-version"])
|
||||
def test_old_or_unversioned_driver_needs_repair(self, version):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
result = SimpleNamespace(
|
||||
returncode=0,
|
||||
@@ -94,7 +94,7 @@ class TestCuaDriverRuntimeContract:
|
||||
assert state["reason"]
|
||||
|
||||
def test_incomplete_manifest_needs_repair(self):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
result = SimpleNamespace(
|
||||
returncode=0,
|
||||
@@ -128,7 +128,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
no subprocess, no path handling, no import — so there is nothing
|
||||
underneath the branch for a real host to falsify.
|
||||
"""
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
with patch.object(tools_config, "_print_warning") as warn, \
|
||||
patch("platform.system", return_value="FreeBSD"):
|
||||
@@ -137,7 +137,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
|
||||
def test_non_upgrade_on_unsupported_platform_warns(self):
|
||||
"""Same narrow exception as above — see that test's docstring."""
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
with patch.object(tools_config, "_print_warning") as warn, \
|
||||
patch("platform.system", return_value="FreeBSD"):
|
||||
@@ -145,7 +145,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
warn.assert_called()
|
||||
|
||||
def test_upgrade_with_binary_present_runs_installer(self):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
with patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/local/bin/" + n
|
||||
@@ -164,7 +164,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
assert kwargs.get("verbose") is False
|
||||
|
||||
def test_upgrade_without_binary_runs_installer(self):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
with patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
|
||||
@@ -179,7 +179,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
branch, which this lane takes for real."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
fake_proc = MagicMock()
|
||||
fake_proc.pid = 1
|
||||
@@ -212,7 +212,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
"""``linux_only``: same POSIX Popen path as the test above."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
fake_proc = MagicMock()
|
||||
fake_proc.pid = 1
|
||||
@@ -245,7 +245,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
fake_proc = MagicMock()
|
||||
fake_proc.pid = 1
|
||||
@@ -280,7 +280,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
fake_proc.communicate.assert_called_once_with(timeout=120)
|
||||
|
||||
def test_upgrade_can_suppress_installer_progress(self):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
with patch.object(
|
||||
tools_config.shutil,
|
||||
@@ -310,7 +310,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
assert runner.call_args.kwargs["show_progress"] is False
|
||||
|
||||
def test_upgrade_non_writable_install_target_skips_refresh(self):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
with patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/local/bin/" + n
|
||||
@@ -327,7 +327,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
)
|
||||
|
||||
def test_fresh_install_non_writable_install_target_skips_install(self):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
with patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
|
||||
@@ -353,7 +353,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
"""
|
||||
import os
|
||||
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
writable = tools_config._cua_install_target_writable()
|
||||
if os.path.isdir("/Applications"):
|
||||
@@ -362,7 +362,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
assert writable is True
|
||||
|
||||
def test_non_upgrade_with_binary_skips_install(self):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
with patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/local/bin/" + n
|
||||
@@ -383,7 +383,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
runner.assert_not_called()
|
||||
|
||||
def test_non_upgrade_repairs_incompatible_existing_driver(self):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
incompatible = {
|
||||
"ready": False,
|
||||
@@ -416,7 +416,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
assert runner.call_args.kwargs["label"] == "Repairing"
|
||||
|
||||
def test_incompatible_explicit_override_is_not_replaced(self, monkeypatch):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/opt/custom/cua-driver")
|
||||
incompatible = {
|
||||
@@ -443,7 +443,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
def test_missing_explicit_override_does_not_install_standard_driver(
|
||||
self, monkeypatch, upgrade
|
||||
):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/missing/custom/cua-driver")
|
||||
with patch.object(
|
||||
@@ -457,7 +457,7 @@ class TestInstallCuaDriverUpgrade:
|
||||
runner.assert_not_called()
|
||||
|
||||
def test_non_upgrade_without_binary_runs_installer(self):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
with patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
|
||||
@@ -493,7 +493,7 @@ class TestRequireConfirmedUpdate:
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
_which_names = {"curl", "powershell"}
|
||||
if not binary_missing:
|
||||
@@ -637,7 +637,7 @@ class TestRequireConfirmedUpdate:
|
||||
wedge)."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
incompatible = {
|
||||
"ready": False,
|
||||
@@ -761,7 +761,7 @@ class TestArchProbeRemoval:
|
||||
"""
|
||||
|
||||
def test_probe_function_is_gone(self):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
assert not hasattr(tools_config, "_check_cua_driver_asset_for_arch")
|
||||
assert not hasattr(tools_config, "_latest_cua_driver_rs_release")
|
||||
|
||||
@@ -771,7 +771,7 @@ class TestArchProbeRemoval:
|
||||
line. install.sh errors cleanly when the arch has no asset, so the
|
||||
probe was duplicate gatekeeping.
|
||||
"""
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
# No platform fake: "does Python hit the GitHub API?" is host-agnostic,
|
||||
# and ``which`` is stubbed so the host's own fetch tool resolves.
|
||||
@@ -793,7 +793,7 @@ class TestArchProbeRemoval:
|
||||
short-circuits the network re-install via the binary's native
|
||||
``check-update --json`` verb.
|
||||
"""
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
with patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/usr/local/bin/" + n
|
||||
@@ -837,7 +837,7 @@ class TestPosixStaleInstallLockClear:
|
||||
os.environ.pop("CUA_DRIVER_RS_HOME", None)
|
||||
|
||||
def test_dead_holder_lock_is_cleared(self, tmp_path):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
dead_pid = 4194000 # above default pid_max on most systems
|
||||
lock = self._make_lock(tmp_path, pid=dead_pid)
|
||||
@@ -847,14 +847,14 @@ class TestPosixStaleInstallLockClear:
|
||||
|
||||
def test_live_holder_lock_is_kept(self, tmp_path):
|
||||
import os
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
lock = self._make_lock(tmp_path, pid=os.getpid())
|
||||
tools_config._clear_stale_cua_install_lock()
|
||||
assert lock.exists()
|
||||
|
||||
def test_pidless_fresh_lock_is_kept(self, tmp_path):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
lock = self._make_lock(tmp_path, pid=None)
|
||||
tools_config._clear_stale_cua_install_lock()
|
||||
@@ -863,7 +863,7 @@ class TestPosixStaleInstallLockClear:
|
||||
def test_pidless_old_lock_is_cleared(self, tmp_path):
|
||||
import os
|
||||
import time
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
lock = self._make_lock(tmp_path, pid=None)
|
||||
old = time.time() - (tools_config._CUA_LOCK_STALE_AFTER + 60)
|
||||
@@ -875,7 +875,7 @@ class TestPosixStaleInstallLockClear:
|
||||
def test_no_lock_is_noop(self, tmp_path):
|
||||
import os
|
||||
os.environ["CUA_DRIVER_RS_HOME"] = str(tmp_path / ".cua-driver")
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
tools_config._clear_stale_cua_install_lock() # must not raise
|
||||
|
||||
|
||||
@@ -886,7 +886,7 @@ class TestWindowsStaleInstallLockClearDispatch:
|
||||
test — on Linux the faked platform asserted the dispatch and skipped
|
||||
the ``.install.lock.d`` directory that really exists here.
|
||||
"""
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
with patch.object(
|
||||
tools_config, "_clear_stale_windows_cua_install_lock"
|
||||
@@ -917,7 +917,7 @@ class TestWindowsStaleInstallLockClear:
|
||||
os.environ.pop("CUA_DRIVER_RS_HOME", None)
|
||||
|
||||
def test_unlocked_lock_file_is_cleared(self, tmp_path):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
lock = self._make_lock(tmp_path)
|
||||
with patch.object(tools_config, "_print_info"):
|
||||
@@ -928,7 +928,7 @@ class TestWindowsStaleInstallLockClear:
|
||||
def test_lock_held_with_file_share_none_is_kept(self, tmp_path):
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
lock = self._make_lock(tmp_path)
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
@@ -979,7 +979,7 @@ class TestInstallerTimeoutKillsProcessGroup:
|
||||
import signal
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
killed = {}
|
||||
sigkill = getattr(signal, "SIGKILL", signal.SIGTERM)
|
||||
@@ -1016,7 +1016,7 @@ class TestInstallerTimeoutKillsProcessGroup:
|
||||
assert fake_proc.communicate.call_count == 2
|
||||
|
||||
def test_timeout_ceiling_exceeds_upstream_lock_window(self):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
# The upstream installer waits up to 600s before reclaiming a stale
|
||||
# lock; our ceiling must give that window room to complete.
|
||||
assert tools_config._CUA_INSTALLER_TIMEOUT > tools_config._CUA_LOCK_STALE_AFTER
|
||||
@@ -1024,7 +1024,7 @@ class TestInstallerTimeoutKillsProcessGroup:
|
||||
@pytest.mark.linux_only
|
||||
def test_installer_runs_in_new_session_on_posix(self):
|
||||
from unittest.mock import MagicMock
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
captured = {}
|
||||
fake_proc = MagicMock()
|
||||
@@ -1049,7 +1049,7 @@ class TestInstallerTimeoutKillsProcessGroup:
|
||||
def test_windows_timeout_kills_descendants_and_parent(self):
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
child = MagicMock()
|
||||
parent = MagicMock()
|
||||
@@ -1087,7 +1087,7 @@ class TestInstallerTimeoutKillsProcessGroup:
|
||||
import psutil
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
parent = MagicMock()
|
||||
parent.children.side_effect = psutil.AccessDenied(pid=12345)
|
||||
@@ -1130,7 +1130,7 @@ class TestInstallerTimeoutDrainIsBounded:
|
||||
"""
|
||||
|
||||
def test_drain_grace_is_short_relative_to_the_run_ceiling(self):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
# This is a grace period for a pipe that a live process is holding
|
||||
# open, not a second budget for the install itself — the install is
|
||||
@@ -1148,7 +1148,7 @@ class TestInstallerTimeoutDrainIsBounded:
|
||||
"""
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
fake_proc = MagicMock()
|
||||
fake_proc.pid = 12345
|
||||
@@ -1185,7 +1185,7 @@ class TestInstallerTimeoutDrainIsBounded:
|
||||
"""
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
fake_proc = MagicMock()
|
||||
fake_proc.pid = 12345
|
||||
@@ -1225,7 +1225,7 @@ class TestInstallerTimeoutDrainIsBounded:
|
||||
import psutil
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
child = MagicMock()
|
||||
child.kill.side_effect = psutil.AccessDenied(pid=999)
|
||||
@@ -1270,7 +1270,7 @@ class TestInstallerTimeoutDrainIsBounded:
|
||||
"""
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
parent = MagicMock()
|
||||
parent.children.return_value = []
|
||||
@@ -1310,7 +1310,7 @@ class TestInstallerNoShell:
|
||||
|
||||
def _run(self, download_rc=0):
|
||||
from unittest.mock import MagicMock
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
calls = []
|
||||
fake_proc = MagicMock()
|
||||
@@ -1363,7 +1363,7 @@ class TestInstallerNoShell:
|
||||
import os
|
||||
captured = {}
|
||||
from unittest.mock import MagicMock
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
fake_proc = MagicMock()
|
||||
fake_proc.pid = 1
|
||||
@@ -1407,7 +1407,7 @@ class TestConfirmedVersionPinning:
|
||||
"""Version pinning also applies to explicit installer runs."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
with patch.object(tools_config.shutil, "which",
|
||||
side_effect=lambda n: "/x/" + n
|
||||
@@ -1477,7 +1477,7 @@ class TestRunInstallerPinEnv:
|
||||
def _run(self, pin_version):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
captured = {}
|
||||
fake_proc = MagicMock()
|
||||
@@ -1521,7 +1521,7 @@ class TestWindowsAutostartRepair:
|
||||
True unconditionally off Windows, so only the fake made the schtasks
|
||||
probe run at all.
|
||||
"""
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
calls = []
|
||||
|
||||
@@ -1547,7 +1547,7 @@ class TestWindowsAutostartRepair:
|
||||
repair hook are both inside the ``is_windows`` branch, so on Linux the
|
||||
fake selected a branch whose `powershell` doesn't exist on PATH."""
|
||||
from unittest.mock import MagicMock
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
captured = {}
|
||||
fake_proc = MagicMock()
|
||||
@@ -1590,7 +1590,7 @@ class TestWindowsAutostartRepair:
|
||||
"""``windows_only``: same early return off Windows — the elevated
|
||||
PowerShell command string is only built on a real Windows host.
|
||||
"""
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
calls = []
|
||||
driver = (
|
||||
@@ -1639,7 +1639,7 @@ class TestCuaVersionSummary:
|
||||
|
||||
@staticmethod
|
||||
def _summary(raw, **kw):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
return tools_config._cua_version_summary(raw, **kw)
|
||||
|
||||
@@ -1679,7 +1679,7 @@ class TestUnattendedRefreshPreflights:
|
||||
system="Linux"):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_cua as tools_config
|
||||
|
||||
proc = MagicMock()
|
||||
proc.communicate.return_value = ("ok", None)
|
||||
|
||||
@@ -135,10 +135,10 @@ def test_refresh_uses_pre_rebuild_snapshot_when_provided(monkeypatch):
|
||||
|
||||
|
||||
def test_capture_active_tool_dependencies_uses_tools_status_probes(monkeypatch):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config_post_setup
|
||||
|
||||
monkeypatch.setattr(
|
||||
tools_config,
|
||||
tools_config_post_setup,
|
||||
"_module_installed",
|
||||
lambda module: module in {"langfuse", "ddgs"},
|
||||
)
|
||||
|
||||
@@ -64,8 +64,8 @@ class TestInstallDependenciesRunner:
|
||||
# tests/tools/test_lazy_deps.py uses.
|
||||
with _patch.dict(os.environ, {"HERMES_DISABLE_LAZY_INSTALLS": "0"}), \
|
||||
patch("plugins.memory.find_provider_dir", return_value=tmp_path), \
|
||||
patch("hermes_cli.tools_config.shutil.which", side_effect=which_side_effect), \
|
||||
patch("hermes_cli.tools_config.subprocess.run", fake_run):
|
||||
patch("hermes_cli.tools_config_cua.shutil.which", side_effect=which_side_effect), \
|
||||
patch("hermes_cli.tools_config_cua.subprocess.run", fake_run):
|
||||
memory_setup._install_dependencies("x")
|
||||
return calls, sys.executable
|
||||
|
||||
|
||||
@@ -21,25 +21,25 @@ class TestPostSetupGate:
|
||||
from hermes_cli import tools_config
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(tools_config.shutil, "which", lambda name, path=None: None)
|
||||
monkeypatch.setattr("shutil.which", lambda name, path=None: None)
|
||||
|
||||
assert tools_config._toolset_needs_configuration_prompt(
|
||||
"computer_use", {}
|
||||
) is True
|
||||
|
||||
def test_incompatible_cua_driver_forces_setup(self, monkeypatch):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config, tools_config_post_setup
|
||||
|
||||
monkeypatch.setattr(tools_config, "_cua_driver_install_ready", lambda: False)
|
||||
monkeypatch.setattr(tools_config_post_setup, "_cua_driver_install_ready", lambda: False)
|
||||
|
||||
assert tools_config._toolset_needs_configuration_prompt(
|
||||
"computer_use", {}
|
||||
) is True
|
||||
|
||||
def test_compatible_cua_driver_skips_setup(self, monkeypatch):
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config, tools_config_post_setup
|
||||
|
||||
monkeypatch.setattr(tools_config, "_cua_driver_install_ready", lambda: True)
|
||||
monkeypatch.setattr(tools_config_post_setup, "_cua_driver_install_ready", lambda: True)
|
||||
|
||||
assert tools_config._toolset_needs_configuration_prompt(
|
||||
"computer_use", {}
|
||||
|
||||
@@ -391,13 +391,13 @@ class TestAgentBrowserPostSetup:
|
||||
Chromium-branch tests never bootstrap uv / hit the network, and so
|
||||
their print/subprocess assertions stay scoped to the agent-browser
|
||||
logic under test."""
|
||||
with patch("hermes_cli.tools_config._ensure_browser_use_cli") as stub:
|
||||
with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as stub:
|
||||
yield stub
|
||||
|
||||
def test_warns_when_neither_npx_nor_agent_browser_on_path(self):
|
||||
with patch("shutil.which", return_value=None), patch(
|
||||
"subprocess.run"
|
||||
) as run, patch("hermes_cli.tools_config._print_warning") as warn:
|
||||
) as run, patch("hermes_cli.tools_config_post_setup._print_warning") as warn:
|
||||
_run_post_setup("agent_browser")
|
||||
|
||||
run.assert_not_called()
|
||||
@@ -425,7 +425,7 @@ class TestAgentBrowserPostSetup:
|
||||
) as run, patch(
|
||||
"tools.browser_tool._chromium_installed", return_value=True
|
||||
), patch(
|
||||
"hermes_cli.tools_config._print_success"
|
||||
"hermes_cli.tools_config_post_setup._print_success"
|
||||
) as success:
|
||||
_run_post_setup("agent_browser")
|
||||
|
||||
@@ -443,7 +443,7 @@ class TestAgentBrowserPostSetup:
|
||||
), patch(
|
||||
"tools.browser_tool._running_in_docker", return_value=True
|
||||
), patch(
|
||||
"hermes_cli.tools_config._print_warning"
|
||||
"hermes_cli.tools_config_post_setup._print_warning"
|
||||
) as warn:
|
||||
_run_post_setup("agent_browser")
|
||||
|
||||
@@ -464,7 +464,7 @@ class TestAgentBrowserPostSetup:
|
||||
"tools.browser_tool._find_agent_browser",
|
||||
side_effect=FileNotFoundError("agent-browser CLI not found"),
|
||||
), patch(
|
||||
"hermes_cli.tools_config._print_warning"
|
||||
"hermes_cli.tools_config_post_setup._print_warning"
|
||||
) as warn:
|
||||
_run_post_setup("agent_browser")
|
||||
|
||||
@@ -491,7 +491,7 @@ class TestAgentBrowserPostSetup:
|
||||
), patch(
|
||||
"tools.browser_tool._find_agent_browser", return_value="npx agent-browser"
|
||||
), patch(
|
||||
"hermes_cli.tools_config._print_success"
|
||||
"hermes_cli.tools_config_post_setup._print_success"
|
||||
):
|
||||
run.return_value = SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
_run_post_setup("agent_browser")
|
||||
@@ -519,7 +519,7 @@ class TestAgentBrowserPostSetup:
|
||||
), patch(
|
||||
"tools.browser_tool._resolve_npx_bin", return_value=hermes_npx
|
||||
), patch(
|
||||
"hermes_cli.tools_config._print_success"
|
||||
"hermes_cli.tools_config_post_setup._print_success"
|
||||
):
|
||||
run.return_value = SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
_run_post_setup("agent_browser")
|
||||
@@ -545,7 +545,7 @@ class TestAgentBrowserPostSetup:
|
||||
), patch(
|
||||
"tools.browser_tool._resolve_npx_bin", return_value=None
|
||||
), patch(
|
||||
"hermes_cli.tools_config._print_warning"
|
||||
"hermes_cli.tools_config_post_setup._print_warning"
|
||||
) as warn:
|
||||
_run_post_setup("agent_browser") # must not raise
|
||||
|
||||
@@ -566,7 +566,7 @@ class TestAgentBrowserPostSetup:
|
||||
"tools.browser_tool._find_agent_browser",
|
||||
return_value="/usr/local/bin/agent-browser",
|
||||
), patch(
|
||||
"hermes_cli.tools_config._print_success"
|
||||
"hermes_cli.tools_config_post_setup._print_success"
|
||||
):
|
||||
run.return_value = SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
_run_post_setup("agent_browser")
|
||||
@@ -591,7 +591,7 @@ class TestAgentBrowserPostSetup:
|
||||
), patch(
|
||||
"tools.browser_tool._find_agent_browser", return_value="npx agent-browser"
|
||||
), patch(
|
||||
"hermes_cli.tools_config._print_success"
|
||||
"hermes_cli.tools_config_post_setup._print_success"
|
||||
):
|
||||
_bt._cached_chromium_installed = True
|
||||
_run_post_setup("agent_browser")
|
||||
@@ -618,9 +618,9 @@ class TestAgentBrowserPostSetup:
|
||||
), patch(
|
||||
"tools.browser_tool._find_agent_browser", return_value="npx agent-browser"
|
||||
), patch(
|
||||
"hermes_cli.tools_config._print_warning"
|
||||
"hermes_cli.tools_config_post_setup._print_warning"
|
||||
) as warn, patch(
|
||||
"hermes_cli.tools_config._print_info"
|
||||
"hermes_cli.tools_config_post_setup._print_info"
|
||||
) as info:
|
||||
_bt._cached_chromium_installed = "sentinel"
|
||||
_run_post_setup("agent_browser")
|
||||
@@ -644,7 +644,7 @@ class TestAgentBrowserPostSetup:
|
||||
), patch(
|
||||
"tools.browser_tool._find_agent_browser", return_value="npx agent-browser"
|
||||
), patch(
|
||||
"hermes_cli.tools_config._print_warning"
|
||||
"hermes_cli.tools_config_post_setup._print_warning"
|
||||
) as warn:
|
||||
_run_post_setup("agent_browser") # must not raise
|
||||
|
||||
@@ -659,7 +659,7 @@ class TestBrowserUseCliInstalledForAllNonCamofoxBackends:
|
||||
|
||||
@pytest.mark.parametrize("key", ["agent_browser", "browserbase", "browser_use_cli"])
|
||||
def test_browser_post_setup_attempts_cli_install(self, key):
|
||||
with patch("hermes_cli.tools_config._ensure_browser_use_cli") as ensure, patch(
|
||||
with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as ensure, patch(
|
||||
"shutil.which", return_value=None
|
||||
), patch("subprocess.run"):
|
||||
_run_post_setup(key)
|
||||
@@ -669,7 +669,7 @@ class TestBrowserUseCliInstalledForAllNonCamofoxBackends:
|
||||
"""Camofox is Firefox-based with no CDP surface; the CDP-only
|
||||
browser-use harness cannot drive it, so its setup must not pull
|
||||
the CLI in."""
|
||||
with patch("hermes_cli.tools_config._ensure_browser_use_cli") as ensure, patch(
|
||||
with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as ensure, patch(
|
||||
"hermes_constants.find_node_executable", return_value=None
|
||||
), patch("subprocess.run"):
|
||||
_run_post_setup("camofox")
|
||||
@@ -680,7 +680,7 @@ class TestBrowserUseCliInstalledForAllNonCamofoxBackends:
|
||||
helper — install_cli() owns the managed-copy check and provisions
|
||||
$HERMES_HOME/bin when only side installs exist."""
|
||||
with patch(
|
||||
"hermes_cli.tools_config.shutil.which", return_value="/usr/bin/browser-use"
|
||||
"hermes_cli.tools_config_post_setup.shutil.which", return_value="/usr/bin/browser-use"
|
||||
), patch(
|
||||
"tools.browser_use_cli.install_cli",
|
||||
return_value=(True, "browser-use CLI already installed (/managed/bin/browser-use)"),
|
||||
@@ -696,11 +696,11 @@ class TestBrowserUseCliInstalledForAllNonCamofoxBackends:
|
||||
from hermes_cli.tools_config import _ensure_browser_use_cli
|
||||
|
||||
with patch(
|
||||
"hermes_cli.tools_config.shutil.which", return_value=None
|
||||
"hermes_cli.tools_config_post_setup.shutil.which", return_value=None
|
||||
), patch(
|
||||
"tools.browser_use_cli.install_cli",
|
||||
return_value=(False, "`uv tool install browser-use` failed:\nboom"),
|
||||
), patch("hermes_cli.tools_config._print_warning") as warn:
|
||||
), patch("hermes_cli.tools_config_post_setup._print_warning") as warn:
|
||||
_ensure_browser_use_cli() # must not raise
|
||||
|
||||
assert any("failed" in c.args[0] for c in warn.call_args_list)
|
||||
@@ -886,13 +886,14 @@ def test_vision_picker_custom_endpoint(tmp_path, monkeypatch):
|
||||
"""Custom endpoint writes base_url+model to config and the key to env."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
import hermes_cli.tools_config as tc
|
||||
import hermes_cli.tools_config_providers as tcp
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
seq = iter([2]) # Custom OpenAI-compatible endpoint
|
||||
prompts = iter(["https://my.endpoint/v1", "sk-secret", "my-vision-model"])
|
||||
with patch.object(tc, "_prompt_choice", side_effect=lambda *a, **k: next(seq)), \
|
||||
patch.object(tc, "_prompt", side_effect=lambda *a, **k: next(prompts)), \
|
||||
patch.object(tc, "save_env_value") as save_env, \
|
||||
patch.object(tcp, "_prompt", side_effect=lambda *a, **k: next(prompts)), \
|
||||
patch.object(tcp, "save_env_value") as save_env, \
|
||||
patch.object(tc, "_toolset_has_keys", return_value=False):
|
||||
tc._configure_vision_backend()
|
||||
|
||||
@@ -1251,15 +1252,15 @@ class TestLightpandaPostSetup:
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_browser_use_install(self):
|
||||
with patch("hermes_cli.tools_config._ensure_browser_use_cli") as stub:
|
||||
with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as stub:
|
||||
yield stub
|
||||
|
||||
def test_reports_binary_when_found(self, _stub_browser_use_install):
|
||||
from hermes_cli.tools_config import _run_post_setup
|
||||
|
||||
with patch("tools.browser_lightpanda.find_lightpanda_binary", return_value="/opt/lightpanda"), \
|
||||
patch("hermes_cli.tools_config._print_success") as ok, \
|
||||
patch("hermes_cli.tools_config._print_warning") as warn:
|
||||
patch("hermes_cli.tools_config_post_setup._print_success") as ok, \
|
||||
patch("hermes_cli.tools_config_post_setup._print_warning") as warn:
|
||||
_run_post_setup("lightpanda")
|
||||
_stub_browser_use_install.assert_called_once()
|
||||
assert "/opt/lightpanda" in ok.call_args.args[0]
|
||||
@@ -1270,8 +1271,8 @@ class TestLightpandaPostSetup:
|
||||
from tools.browser_lightpanda import LIGHTPANDA_INSTALL_URL
|
||||
|
||||
with patch("tools.browser_lightpanda.find_lightpanda_binary", return_value=None), \
|
||||
patch("hermes_cli.tools_config._print_warning") as warn, \
|
||||
patch("hermes_cli.tools_config._print_info") as info:
|
||||
patch("hermes_cli.tools_config_post_setup._print_warning") as warn, \
|
||||
patch("hermes_cli.tools_config_post_setup._print_info") as info:
|
||||
_run_post_setup("lightpanda")
|
||||
assert "not found" in warn.call_args.args[0]
|
||||
assert any(LIGHTPANDA_INSTALL_URL in c.args[0] for c in info.call_args_list)
|
||||
|
||||
@@ -85,7 +85,7 @@ class TestReconfigureWritesProvider:
|
||||
):
|
||||
"""Env vars present and user accepts current value → still writes
|
||||
video_gen.provider via the post-env-vars branch."""
|
||||
from hermes_cli import tools_config
|
||||
from hermes_cli import tools_config, tools_config_providers
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
video_gen_registry.register_provider(_FakeVideoProvider("xai_fake"))
|
||||
@@ -93,11 +93,11 @@ class TestReconfigureWritesProvider:
|
||||
# Picker prompts replaced — no TTY in tests.
|
||||
monkeypatch.setattr(tools_config, "_prompt_choice", lambda *a, **kw: 0)
|
||||
# User presses Enter to keep the existing key.
|
||||
monkeypatch.setattr(tools_config, "_prompt", lambda *a, **kw: "")
|
||||
monkeypatch.setattr(tools_config_providers, "_prompt", lambda *a, **kw: "")
|
||||
# Pretend the env var is already set so the reconfigure path
|
||||
# hits the "Kept current" branch.
|
||||
monkeypatch.setattr(
|
||||
tools_config,
|
||||
tools_config_providers,
|
||||
"get_env_value",
|
||||
lambda key: "sk-fake" if key == "XAI_FAKE_API_KEY" else "",
|
||||
)
|
||||
|
||||
@@ -49,6 +49,7 @@ class TestToggleToolsetInstallOnEnable:
|
||||
self, monkeypatch
|
||||
):
|
||||
import hermes_cli.tools_config as tools_config
|
||||
import hermes_cli.tools_config_post_setup as tools_config_post_setup
|
||||
|
||||
calls = self._spawn_recorder(monkeypatch)
|
||||
# Binary missing → the cua_driver predicate reports unsatisfied.
|
||||
@@ -56,7 +57,7 @@ class TestToggleToolsetInstallOnEnable:
|
||||
tools_config, "_resolved_cua_driver_cmd", lambda: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tools_config, "_cua_driver_install_ready", lambda: False
|
||||
tools_config_post_setup, "_cua_driver_install_ready", lambda: False
|
||||
)
|
||||
|
||||
resp = self.client.put(
|
||||
@@ -75,13 +76,14 @@ class TestToggleToolsetInstallOnEnable:
|
||||
self, monkeypatch
|
||||
):
|
||||
import hermes_cli.tools_config as tools_config
|
||||
import hermes_cli.tools_config_post_setup as tools_config_post_setup
|
||||
|
||||
calls = self._spawn_recorder(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
tools_config, "_resolved_cua_driver_cmd", lambda: "/usr/bin/cua-driver"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tools_config, "_cua_driver_install_ready", lambda: True
|
||||
tools_config_post_setup, "_cua_driver_install_ready", lambda: True
|
||||
)
|
||||
|
||||
resp = self.client.put(
|
||||
@@ -93,13 +95,14 @@ class TestToggleToolsetInstallOnEnable:
|
||||
|
||||
def test_disable_never_spawns_install(self, monkeypatch):
|
||||
import hermes_cli.tools_config as tools_config
|
||||
import hermes_cli.tools_config_post_setup as tools_config_post_setup
|
||||
|
||||
calls = self._spawn_recorder(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
tools_config, "_resolved_cua_driver_cmd", lambda: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tools_config, "_cua_driver_install_ready", lambda: False
|
||||
tools_config_post_setup, "_cua_driver_install_ready", lambda: False
|
||||
)
|
||||
|
||||
resp = self.client.put(
|
||||
@@ -111,13 +114,14 @@ class TestToggleToolsetInstallOnEnable:
|
||||
|
||||
def test_spawn_failure_does_not_fail_the_toggle(self, monkeypatch):
|
||||
import hermes_cli.tools_config as tools_config
|
||||
import hermes_cli.tools_config_post_setup as tools_config_post_setup
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
tools_config, "_resolved_cua_driver_cmd", lambda: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tools_config, "_cua_driver_install_ready", lambda: False
|
||||
tools_config_post_setup, "_cua_driver_install_ready", lambda: False
|
||||
)
|
||||
|
||||
def _boom(subcommand, name, **kwargs):
|
||||
|
||||
@@ -2839,9 +2839,11 @@ class TestNewEndpoints:
|
||||
),
|
||||
)
|
||||
# No xAI credentials → the Grok OAuth-backed row needs sign-in.
|
||||
import hermes_cli.tools_config_post_setup as tools_config_post_setup
|
||||
|
||||
monkeypatch.setattr(tools_config, "_xai_credentials_present", lambda: False)
|
||||
# Local TTS engines not installed → their rows need setup.
|
||||
monkeypatch.setattr(tools_config, "_module_installed", lambda name: False)
|
||||
monkeypatch.setattr(tools_config_post_setup, "_module_installed", lambda name: False)
|
||||
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)
|
||||
|
||||
resp = self.client.get("/api/tools/toolsets/tts/config")
|
||||
|
||||
Reference in New Issue
Block a user