From 3c7cd69abb87add0a31c842d89d4e1b24bb29d15 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:43:44 -0700 Subject: [PATCH 1/9] refactor(tools_config): extract cua-driver installer cluster into tools_config_cua --- hermes_cli/tools_config.py | 1131 +---------------- hermes_cli/tools_config_cua.py | 1076 ++++++++++++++++ tests/hermes_cli/test_install_cua_driver.py | 104 +- .../test_memory_setup_provider_arg.py | 4 +- 4 files changed, 1161 insertions(+), 1154 deletions(-) create mode 100644 hermes_cli/tools_config_cua.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 163bd705e9..24f9f6375e 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -32,28 +32,6 @@ from utils import base_url_hostname, is_truthy_value logger = logging.getLogger(__name__) -def _post_setup_no_window_flags(*, streams_to_console: bool = False) -> int: - """Win32 creationflags that stop post-setup children flashing a console. - - The GUI runs post-setup hooks via a console-less child; on Windows every console grandchild - (npm, pip, powershell) would flash a new window. CREATE_NO_WINDOW suppresses that 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 - # Platforms already warned about an all-invalid platform_toolsets list, so the # runtime check in _get_platform_tools warns once per platform instead of on # every tool resolution for a persistently-corrupt config (#38798). @@ -71,6 +49,37 @@ from hermes_cli.cli_output import ( # noqa: E402 — late import block print_warning as _print_warning, prompt as _prompt, ) +from hermes_cli.tools_config_cua import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches + _post_setup_no_window_flags, + _cua_driver_cmd, + _cua_version_summary, + _resolved_cua_driver_cmd, + _cua_driver_env, + _CUA_DRIVER_CONTRACT_CACHE, + _cua_driver_contract_status, + _cua_driver_install_ready, + _pip_install, + _cua_install_target_writable, + _cua_driver_version, + install_cua_driver, + _CUA_INSTALLER_TIMEOUT, + _CUA_INSTALLER_DRAIN_GRACE, + _CUA_BACKGROUND_UPDATE_TIMEOUT, + _CUA_LOCK_STALE_AFTER, + _cua_install_home, + _cua_install_lock_dir, + _cua_windows_install_lock_file, + _clear_stale_windows_cua_install_lock, + _clear_stale_cua_install_lock, + _cua_install_lock_held, + _cua_release_endpoint_reachable, + _ps_single_quote, + _cua_driver_autostart_registered_windows, + _repair_cua_driver_autostart_windows, + _remove_quietly, + _print_cua_platform_notes, + _run_cua_driver_installer, +) # ─── Toolset Registry ───────────────────────────────────────────────────────── @@ -584,1084 +593,6 @@ TOOLSET_ENV_REQUIREMENTS = { # ─── Post-Setup Hooks ───────────────────────────────────────────────────────── -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: - """Reduce a driver's ``--version`` output to one short status line. - - A ``HERMES_CUA_DRIVER_CMD`` override may print a multi-line banner (e.g. cmd.exe); interpolating - it verbatim shattered the one-line status summary, so keep the first non-empty line, bounded. - """ - 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. - - Delegates to ``cua_backend.cua_driver_child_env`` (telemetry disabled by default; user opt-in - via ``computer_use.cua_telemetry``). Falls back to the current environment if the helper can't - be imported, so install/status never break on a telemetry-helper error. - """ - 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. - - Strategy (in order): 1. ``uv pip install`` if uv is on PATH — fast, doesn't need pip in the - venv. 2. ``python -m pip install`` — works on stdlib venvs. 3. ``python -m ensurepip --upgrade`` - then retry pip — covers ``uv venv`` which creates a venv WITHOUT pip. - - Why this exists: the Windows installer creates the venv via ``uv venv``, which doesn't seed pip. - Post-setup hooks that shelled out to ``[sys.executable, '-m', 'pip', 'install', ...]`` failed - with ``No module named pip`` on every fresh install. uv-first sidesteps that. - """ - 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 and prefers a system one when both exist. - # ensure_uv() rather than a pure lookup because this runs during setup, - # where installing uv is in scope — and tier 2 is a pip that the Windows - # installer's `uv venv` does not seed, so failing to find uv here is the - # difference between a working post-setup hook and "No module named pip". - 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 an unrelated reason - # (resolution conflict, network), and pip might handle it. - 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 (it returned the Python -# `cua-agent` package with zero binary assets and made every non-arm64 host -# skip the install), and re-implementing upstream's tag resolution -# (`CUA_DRIVER_RS_BAKED_VERSION`) here would drift. Trust the installer: 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]: - """`` --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 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. We expose two modes: - - * ``upgrade=False`` — keep a compatible Cua Driver 0.20 installation, repair an old or - incomplete installation, and install when missing. Used by the toolset enable flow. * - ``upgrade=True`` — always re-run the installer (or call ``cua-driver update`` if the binary - supports it). - """ - import platform as _plat - - system = _plat.system() - if system not in ("Darwin", "Windows", "Linux"): - if upgrade: - # Silent on unsupported platforms — `hermes update` calls this - # for every user; only macOS/Windows/Linux users care. - return False - _print_warning(" Computer Use (cua-driver) is unsupported on this platform; skipping.") - return False - - is_windows = system == "Windows" - is_linux = system == "Linux" - - # The Windows installer (install.ps1) is fetched via PowerShell's `irm`, - # so it needs PowerShell rather than curl. 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 it is currently broken. - # Do not install or replace the standard system driver: that 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( - " HERMES_CUA_DRIVER_CMD does not resolve to an executable: " - f"{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") - - # An installed driver that fails Hermes' runtime contract (version floor, - # missing manifest verbs) is repaired regardless of the caller's mode. - # Hermes' own minimum requirement IS the confirmation that an upgrade is - # needed, so the ``upgrade=True`` path must not defer to the driver's - # ``check-update`` verb here — a cached/indeterminate "no update" answer - # would otherwise pin users on an unusable driver forever (observed: - # 0.19.3 installs hard-failing every computer_use call after the 0.20 - # contract landed, with `hermes update` declining to refresh). - contract = _cua_driver_contract_status(binary) if binary else None - repair_existing = bool(binary and contract and not contract.get("ready")) - - # A compatible existing installation needs no download. Finish the small - # host-specific setup that the upstream installer 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 " - f"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) - - # Skip the (network) re-install when the driver itself reports it's already - # on the latest release. Best-effort: an older driver (no check-update - # verb) or an offline check returns None. What happens then depends on the - # caller: `hermes update` (require_confirmed_update=True) keeps the - # installed version — an indeterminate check must never cost the user a - # multi-minute silent reinstall on every update. An explicit - # `hermes computer-use install --upgrade` falls through and re-runs the - # installer as before. - confirmed_version = None - if binary and not repair_existing: - _state = None - 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 True - 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 True - if _state is not None and _state.get("update_available"): - # Windows routine upgrades run UNATTENDED-SAFE rather than - # deferring: stdin is closed (a consent Read-Host can't block), - # the version is pinned, the ceiling is - # _CUA_BACKGROUND_UPDATE_TIMEOUT, and _run_cua_driver_installer's - # preflights skip in seconds when the install lock is held or - # GitHub is unreachable. Only contract repairs and fresh installs - # stay interactive-only (guards above/below) — those are the - # paths where upstream legitimately needs a human (first-time - # autostart elevation, SmartScreen). - # Pin the installer to the release check-update just confirmed. - # `latest_version` comes from the GitHub Releases API, so its - # assets are published — unlike the installer script's baked - # version on `main`, which Release Please bumps in the release - # PR *before* the release assets exist. Installing unpinned in - # that window 404s (observed: baked 0.14.0 vs latest published - # 0.13.1). 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 - - if is_windows and require_confirmed_update and not binary: - # Missing-binary path (driver enabled in config but never installed, - # or wiped by a failed install). Same rule as the repair and - # confirmed-update branches above: an automatic Windows update must - # never launch install.ps1, which can demand console/UAC consent the - # hidden updater cannot provide (#87703). - _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 - - # Show before/after version when we have a baseline. Best-effort. - 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 serializes concurrent installs -# with a lock dir at ~/.cua-driver/packages/.install.lock.d and only -# force-releases a dead holder's lock after LOCK_STALE_AFTER_SECONDS=600 of -# waiting. With a shorter Python-side timeout, a stale lock means every run -# gets killed before the installer's recovery can fire — a permanent -# "always times out" wedge (issue #58762). 660s = 600s lock window + 60s -# headroom for the actual download/swap. -_CUA_INSTALLER_TIMEOUT = 660 - -# Grace period for draining the installer's pipes after a timeout kill. The -# kill is best-effort (see _reap_after_timeout), so this drain has to be -# bounded: a descendant that survived the kill still holds the inherited -# stdout handle, and an unbounded read waits on an EOF that never comes, -# which turns the ceiling above into no ceiling at all (issue #87703). A -# successful kill closes the pipe immediately, so this costs nothing in the -# normal case; it only caps how long a failed one can stall the update. -_CUA_INSTALLER_DRAIN_GRACE = 15 - -# Optional refreshes launched by ``hermes update`` are quiet and unattended. -# Keep their interruption bounded even when upstream waits on Read-Host or a -# consent prompt. Explicit ``computer-use install --upgrade`` runs retain the -# full installer ceiling above. (The lock/network preflights below make a -# legitimate long wait impossible on this path, so a short ceiling is safe.) -_CUA_BACKGROUND_UPDATE_TIMEOUT = 120 - -# Upstream installer's stale-lock threshold (LOCK_STALE_AFTER_SECONDS in -# _install-rust.sh). Used by the pre-clear below to avoid yanking a lock -# that 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. - - The POSIX installer stamps its holder pid into ``~/.cua-driver/packages/.install.lock.d/info``. - The Windows installer instead 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 - # Holder alive → a concurrent install is running; don't touch. - return - except ProcessLookupError: - pass # dead holder → stale, clear below - except PermissionError: - # Alive but owned by someone else — treat as live. - return - else: - # No readable pid. Only clear if the lock is old enough that the - # upstream installer itself would consider it reclaimable. - 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 open with FileShare::None — any - # open attempt fails with a sharing violation while it's held. - # _clear_stale_windows_cua_install_lock() already deleted it if - # it was unheld, so surviving = 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 - lock_dir = _cua_install_lock_dir() - return 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") - - -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" - - if is_windows: - # Mirror the one-liner printed by cua_driver_install_hint(). - ps_oneliner = ( - "irm https://raw.githubusercontent.com/trycua/cua/main/" - "libs/cua-driver/scripts/install.ps1 | iex" - ) - install_cmd = [ - "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", - "-Command", ps_oneliner, - ] - manual_hint = ( - 'powershell -NoProfile -ExecutionPolicy Bypass -Command ' - f'"{ps_oneliner}"' - ) - script_path = None - else: - # 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 the shell-injection surface and a symlink/TOCTOU race on - # multi-user machines. The manual hint stays the upstream one-liner - # since that's what the docs/README teach. - 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 False - install_cmd = ["/bin/bash", script_path] - - 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 (install.sh and install.ps1) honour - # CUA_DRIVER_RS_VERSION over their baked default. - installer_env["CUA_DRIVER_RS_VERSION"] = pin_version - - # A previous timed-out install can leave the upstream installer's - # concurrent-install lock behind; clear it when provably stale so the - # refresh doesn't wedge waiting on a dead holder (issue #58762). - _clear_stale_cua_install_lock() - - # Unattended refreshes (installer_timeout set by `hermes update`) fail - # FAST on the two conditions that otherwise consume the whole 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. That is - # the 11-minute silent hang class (#87703; observed live 2026-08-25: - # "cua-driver refreshing timed out after 660s"). Skip in ~0s instead. - # 2. Release host unreachable (outage/DNS/firewall) — the installer - # would die slowly inside its own retries. A 5s HEAD answers now. - # - # Explicit `computer-use install --upgrade` runs keep upstream's full - # lock-recovery semantics — a human is watching and can wait or Ctrl-C. - if installer_timeout is not None: - 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 False - if not _cua_release_endpoint_reachable(): - _print_info( - " github.com is unreachable — skipping cua-driver " - "refresh (will retry on the next update)." - ) - return False - if is_windows: - # -NoAutoStart skips Register-CuaDriverAutostart entirely — 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 re-registers it. - # scriptblock invocation (instead of `| iex`) is what lets us - # pass the parameter to a piped script. - install_cmd = [ - "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", - "-Command", - "$sc = irm https://raw.githubusercontent.com/trycua/cua/" - "main/libs/cua-driver/scripts/install.ps1; " - "& ([scriptblock]::Create($sc)) -NoAutoStart", - ] - - # POSIX: run the installer in its 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. Otherwise the surviving - # grandchildren keep holding the install lock, wedging every later run. - popen_kwargs = {} - if not is_windows: - popen_kwargs["start_new_session"] = True - - def _kill_installer_tree(proc): - 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. Those descendants inherit stdout - # and can keep both communicate() and install.lock wedged, so - # collect the tree first and kill it 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): - """Kill the installer tree, then drain its pipes under a deadline. - - Draining with no deadline then blocks on an EOF that only arrives when someone kills that - process by hand, so the ``_CUA_INSTALLER_TIMEOUT`` ceiling stops bounding anything and - ``hermes update`` hangs past its own timeout warning (#87703). - """ - _kill_installer_tree(proc) - try: - drained_out, _ = proc.communicate(timeout=_CUA_INSTALLER_DRAIN_GRACE) - # Diagnosability (#87703 post-mortem): the partial output names - # WHERE the installer was stuck (lock wait, consent prompt, - # download) — before this, the answer died with the process and - # the timeout line was unactionable. - 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 here. communicate()'s - # reader threads are still blocked on that handle and closing it - # underneath them races; they are daemon threads, so abandoning - # them does not keep the interpreter alive. - 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) - - try: - # When not verbose (e.g. `hermes update`'s refresh), capture the - # installer's chatty "Next steps" wall instead of dumping it to the - # terminal. The combined output is logged so a failure stays - # debuggable. Verbose installs (interactive `computer-use install`) - # keep streaming 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) - 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) - raise - result = subprocess.CompletedProcess( - install_cmd, proc.returncode, stdout=out, stderr=None - ) - # Preserve the full installer output. During `hermes update`, - # sys.stdout is the mirroring _UpdateOutputStream whose `_log` - # handle is ~/.hermes/logs/update.log — write straight to it so - # the captured "Next steps" wall is kept in full (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 " - f"{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) - - def _ensure_browser_use_cli(*, verbose_hints: bool = False) -> None: """Install the Browser Use CLI if it isn't already runnable. diff --git a/hermes_cli/tools_config_cua.py b/hermes_cli/tools_config_cua.py new file mode 100644 index 0000000000..e6cd4ee73c --- /dev/null +++ b/hermes_cli/tools_config_cua.py @@ -0,0 +1,1076 @@ +"""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. + + The GUI runs post-setup hooks via a console-less child; on Windows every console grandchild + (npm, pip, powershell) would flash a new window. CREATE_NO_WINDOW suppresses that 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: + """Reduce a driver's ``--version`` output to one short status line. + + A ``HERMES_CUA_DRIVER_CMD`` override may print a multi-line banner (e.g. cmd.exe); interpolating + it verbatim shattered the one-line status summary, so keep the first non-empty line, bounded. + """ + 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. + + Delegates to ``cua_backend.cua_driver_child_env`` (telemetry disabled by default; user opt-in + via ``computer_use.cua_telemetry``). Falls back to the current environment if the helper can't + be imported, so install/status never break on a telemetry-helper error. + """ + 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. + + Strategy (in order): 1. ``uv pip install`` if uv is on PATH — fast, doesn't need pip in the + venv. 2. ``python -m pip install`` — works on stdlib venvs. 3. ``python -m ensurepip --upgrade`` + then retry pip — covers ``uv venv`` which creates a venv WITHOUT pip. + + Why this exists: the Windows installer creates the venv via ``uv venv``, which doesn't seed pip. + Post-setup hooks that shelled out to ``[sys.executable, '-m', 'pip', 'install', ...]`` failed + with ``No module named pip`` on every fresh install. uv-first sidesteps that. + """ + 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, and + # tier 2 is a pip that the Windows installer's `uv venv` does not seed. + 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 (it returned a package with zero binary assets and made every non-arm64 +# host skip the install), and re-implementing upstream's tag resolution here would drift. Trust the +# installer: 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]: + """`` --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 the caller should stop with + success (already latest, or indeterminate under ``require_confirmed_update``). Best-effort: an + older driver (no check-update verb) or an offline check yields None; then `hermes update` + keeps the installed version — an indeterminate check must never cost a multi-minute silent + reinstall on every update — while an explicit `computer-use install --upgrade` falls through. + """ + _state = None + 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 rather than deferring: stdin is closed (a + # consent Read-Host can't block), the version is pinned, the ceiling is + # _CUA_BACKGROUND_UPDATE_TIMEOUT, and the installer preflights skip in seconds when the + # lock is held or GitHub is unreachable. Only contract repairs and fresh installs stay + # interactive-only — there upstream legitimately needs a human (autostart elevation, + # SmartScreen). + # Pin to the release check-update just 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 the assets are published and 404s when installed 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( + " HERMES_CUA_DRIVER_CMD does not resolve to an executable: " + f"{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 " + f"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 serializes installs with ~/.cua-driver/packages/.install.lock.d and only +# force-releases a dead holder's lock after LOCK_STALE_AFTER_SECONDS=600. A shorter Python-side +# timeout kills every run before that recovery fires — a permanent "always times out" wedge. +# 660s = 600s lock window + 60s headroom for the download/swap. +_CUA_INSTALLER_TIMEOUT = 660 + +# Grace for draining the installer's pipes after a timeout kill. The kill is best-effort (see +# _reap_after_timeout), so the drain must be bounded: a surviving descendant still holds the +# inherited stdout handle and an unbounded read waits on an EOF that never comes, turning the +# ceiling above into no ceiling. A successful kill closes the pipe immediately, 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. (The +# lock/network preflights make a legitimate long wait impossible here, so a short ceiling is safe.) +_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. + + The POSIX installer stamps its holder pid into ``~/.cua-driver/packages/.install.lock.d/info``. + The Windows installer instead 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. The stale-clear already deleted an unheld file, so surviving + # = 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 + lock_dir = _cua_install_lock_dir() + return 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 both communicate() and install.lock wedged, + # so collect the tree first and kill it 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); without it the timeout line is unactionable. + 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 = ( + 'powershell -NoProfile -ExecutionPolicy Bypass -Command ' + f'"{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 on multi-user machines. The + # manual hint stays the upstream one-liner the docs teach. + 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 now. + + 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 (instead of + # `| iex`) is what lets us pass the parameter to a piped script. + 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 " + f"{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) diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py index 9740fc5c9f..e6764abda4 100644 --- a/tests/hermes_cli/test_install_cua_driver.py +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -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) diff --git a/tests/hermes_cli/test_memory_setup_provider_arg.py b/tests/hermes_cli/test_memory_setup_provider_arg.py index 7df85c3156..01fadb1306 100644 --- a/tests/hermes_cli/test_memory_setup_provider_arg.py +++ b/tests/hermes_cli/test_memory_setup_provider_arg.py @@ -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 From c11fa53d92af210a9e752dcae3ef3d85dcc2bd73 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:05:46 -0700 Subject: [PATCH 2/9] refactor(tools_config): extract provider selection cluster into tools_config_providers --- hermes_cli/tools_config.py | 1452 +---------------- hermes_cli/tools_config_providers.py | 1403 ++++++++++++++++ .../test_imagegen_managed_gateway.py | 12 +- tests/hermes_cli/test_tools_config.py | 5 +- tests/hermes_cli/test_video_gen_picker.py | 6 +- 5 files changed, 1461 insertions(+), 1417 deletions(-) create mode 100644 hermes_cli/tools_config_providers.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 24f9f6375e..6fda493328 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -80,6 +80,51 @@ from hermes_cli.tools_config_cua import ( # noqa: F401 — re-exported for herm _print_cua_platform_notes, _run_cua_driver_installer, ) +from hermes_cli.tools_config_providers import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches + _plugin_provider_rows, + _plugin_image_gen_providers, + _plugin_video_gen_providers, + _plugin_web_search_providers, + _plugin_browser_providers, + _plugin_tts_providers, + web_provider_capabilities, + _PLUGIN_ROW_BUILDERS, + _visible_providers, + provider_readiness_status, + _toolset_needs_configuration_prompt, + _any_plugin_provider_available, + _configure_tool_category, + _web_tier_matches, + _is_provider_active, + _detect_active_provider_index, + _fal_model_catalog, + IMAGEGEN_BACKENDS, + _plugin_model_catalog, + _plugin_image_gen_catalog, + _plugin_video_gen_catalog, + _pick_model_from_catalog, + _configure_imagegen_model, + _configure_imagegen_model_for_plugin, + _configure_videogen_model_for_plugin, + _configure_xai_imagine_storage, + _select_plugin_gen_provider, + _select_plugin_image_gen_provider, + _select_plugin_video_gen_provider, + STT_MODEL_CATALOG, + _STT_MODEL_CONFIG_KEY, + _configure_stt_model, + _PROVIDER_MARKER_SECTIONS, + _write_provider_config, + apply_provider_selection, + _nous_provider_gate, + _finish_provider_selection, + _print_provider_selection, + _configure_provider, + _reconfigure_provider, + _configure_vision_backend, + _configure_vision_provider_model, + _configure_simple_requirements, +) # ─── Toolset Registry ───────────────────────────────────────────────────────── @@ -1800,207 +1845,6 @@ def _configure_toolset( _configure_simple_requirements(ts_key) -def _plugin_provider_rows( - registry_module: str, - marker_keys: tuple[str, ...], - *, - require_name: bool = True, - skip_builtin: bool = False, - flatten_variants: bool = False, -) -> list[dict]: - """Build picker-row dicts from a plugin registry's providers. - - Each row looks like a hardcoded ``TOOL_CATEGORIES`` provider row plus the ``marker_keys`` - (all set to the provider's registry name) that tell downstream config-writing / model-picker - code to route through the plugin registry. ``skip_builtin`` drops providers whose name shadows - the registry's ``_BUILTIN_NAMES`` (defence in depth — the registry already rejects them). - ``flatten_variants`` expands a schema's tier ``variants`` (e.g. Exa/Parallel free keyless vs - paid SDK) into separate rows sharing one backend name, distinguished by ``web_tier``. - """ - try: - import importlib - - registry = importlib.import_module(registry_module) - from hermes_cli.plugins import _ensure_plugins_discovered - - _ensure_plugins_discovered() - providers = registry.list_providers() - builtin = registry._BUILTIN_NAMES if skip_builtin else frozenset() - except Exception: - return [] - - rows: list[dict] = [] - for provider in providers: - if require_name: - name = getattr(provider, "name", None) - if not name: - continue - if skip_builtin and name.lower().strip() in builtin: - continue - try: - schema = provider.get_setup_schema() - except Exception: - continue - if not isinstance(schema, dict): - continue - if not require_name: - name = provider.name - entries = [schema] - if flatten_variants: - entries += [v for v in (schema.get("variants") or []) if isinstance(v, dict)] - for entry in entries: - row = { - "name": entry.get("name", provider.display_name), - "badge": entry.get("badge", ""), - "tag": entry.get("tag", ""), - "env_vars": entry.get("env_vars", []), - } - for key in marker_keys: - row[key] = name - if flatten_variants and entry.get("web_tier"): - row["web_tier"] = entry["web_tier"] - if entry.get("post_setup"): - row["post_setup"] = entry["post_setup"] - rows.append(row) - return rows - - -def _plugin_image_gen_providers() -> list[dict]: - """Picker rows for plugin-registered image gen providers (``image_gen_plugin_name`` marker).""" - return _plugin_provider_rows( - "agent.image_gen_registry", ("image_gen_plugin_name",), require_name=False - ) - - -def _plugin_video_gen_providers() -> list[dict]: - """Picker rows for plugin-registered video gen providers — the *only* source of rows for the - Video Generation category (every video backend is a plugin).""" - return _plugin_provider_rows( - "agent.video_gen_registry", ("video_gen_plugin_name",), require_name=False - ) - - -def _plugin_web_search_providers() -> list[dict]: - """Picker rows for plugin-registered web search providers. - - After PR #25182 all bundled web providers (brave-free, ddgs, searxng, exa, parallel, tavily, - firecrawl, keenable) are plugins, so this is the sole source of provider rows for Web Search & - Extract; only the two non-provider firecrawl setup-flow rows stay in ``TOOL_CATEGORIES``. - """ - return _plugin_provider_rows( - "agent.web_search_registry", - ("web_backend", "web_search_plugin_name"), - flatten_variants=True, - ) - - -def _plugin_browser_providers() -> list[dict]: - """Picker rows for plugin-registered cloud browser providers (Browserbase / Browser Use / - Firecrawl, PR #25214). ``browser_provider`` is the legacy key written to - ``browser.cloud_provider``; ``browser_plugin_name`` marks the row as plugin-backed.""" - return _plugin_provider_rows( - "agent.browser_registry", ("browser_provider", "browser_plugin_name") - ) - - -def _plugin_tts_providers() -> list[dict]: - """Picker rows for plugin-registered TTS providers, rendered below the hardcoded built-in rows. - Selecting one writes ``tts.provider: `` like the built-in rows do.""" - return _plugin_provider_rows( - "agent.tts_registry", ("tts_provider", "tts_plugin_name"), skip_builtin=True - ) - - -def web_provider_capabilities(backend: str) -> list: - """Return the capabilities (``search`` / ``extract``) a web backend supports. - - Consults the plugin registry's provider instance (``supports_search`` / ``supports_extract``) so - the Capabilities GUI can offer per-capability selection (``web.search_backend`` / - ``web.extract_backend``) only where it makes sense — e.g. ddgs and brave-free are search-only. - """ - try: - from agent.web_search_registry import get_provider - - provider = get_provider(backend) - if provider is not None: - caps = [] - if provider.supports_search(): - caps.append("search") - if provider.supports_extract(): - caps.append("extract") - return caps - except Exception: - pass - return ["search", "extract"] - - -# TOOL_CATEGORIES[]["name"] -> builder of plugin-registered picker rows. -_PLUGIN_ROW_BUILDERS = { - "Image Generation": _plugin_image_gen_providers, - "Video Generation": _plugin_video_gen_providers, - "Web Search & Extract": _plugin_web_search_providers, - "Browser Automation": _plugin_browser_providers, - "Text-to-Speech": _plugin_tts_providers, -} - - -def _visible_providers( - cat: dict, - config: dict, - *, - force_fresh: bool = False, - features: Optional[NousSubscriptionFeatures] = None, -) -> list[dict]: - """Return provider entries visible for the current auth/config state. - - Nous-managed Tool Gateway rows (``managed_nous_feature``) are always shown — even to logged-out - / unentitled users — so the picker advertises that the capability exists. - """ - if features is None: - features = get_nous_subscription_features(config, force_fresh=force_fresh) - acct = features.account_info - # Pool-only users (entitled to managed tools via the free tool pool but with - # no paid access) get image gen but NOT video gen — the pool doesn't fund - # `fal-video`. Rather than advertise a managed video row that would be denied - # on select, hide it for them. Logged-out users still see it (advertising) - # and paid users are entitled to it. - pool_only = bool( - acct - and acct.logged_in - and acct.paid_service_access is not True - and acct.tool_gateway_entitled - ) - visible = [] - for provider in cat.get("providers", []): - # Nous-managed Tool Gateway rows stay visible regardless of auth — - # selecting one drives an inline Portal login. A `requires_nous_auth` - # row that is NOT a managed gateway feature (pure pre-auth UX) is - # still hidden until the user is logged in. - if ( - provider.get("requires_nous_auth") - and not provider.get("managed_nous_feature") - and not features.nous_auth_present - ): - continue - # Hide the managed video-gen row from pool-only users — their free tool - # pool doesn't cover video, so showing it would only lead to a denial. - if ( - pool_only - and provider.get("managed_nous_feature") == "video_gen" - and not (acct and acct.tool_gateway_entitled_for("fal-video")) - ): - continue - visible.append(provider) - - # Plugin-registered rows render BELOW the hardcoded rows. For web - # (PR #25182) and browser (PR #25214) they are the only real provider - # rows — the hardcoded entries left are non-provider UX setup flows - # (Nous Subscription, Firecrawl Self-Hosted, Local Browser, Camofox). - builder = _PLUGIN_ROW_BUILDERS.get(cat.get("name")) - if builder is not None: - visible.extend(builder()) - - return visible _POST_SETUP_INSTALLED: dict = { @@ -2140,1214 +1984,8 @@ _POST_SETUP_READY: dict = { } -def provider_readiness_status( - provider: dict, - config: dict, - *, - features=None, - is_active: Optional[bool] = None, -) -> str: - """Compute an honest readiness state for a provider picker row. - ``features`` (a ``NousSubscriptionFeatures``) can be passed to avoid re-fetching portal state - per row. ``is_active`` is the completed-setup fallback signal for post_setup hooks with no - registered installed-check (selecting a row runs its hook, so the active row has been set up). - """ - if provider.get("env_vars", []): - return "ready" if _provider_env_ready(provider) else "needs_keys" - managed_feature = provider.get("managed_nous_feature") - if provider.get("requires_nous_auth") or managed_feature: - if features is None: - features = get_nous_subscription_features(config) - if not features.nous_auth_present: - return "needs_auth" - if managed_feature: - # Same per-category entitlement gate the CLI applies at selection - # time (free tool-pool users get image gen but not video gen). - acct = features.account_info - category = MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature) - entitled = bool( - acct - and acct.logged_in - and ( - acct.tool_gateway_entitled_for(category) - if category - else acct.tool_gateway_entitled - ) - ) - if not entitled: - return "needs_auth" - # Signed in and entitled — fall through: a managed row may still - # carry a local install hook (e.g. the managed browser row needs - # the agent-browser CLI on this machine). - - post_setup = provider.get("post_setup") - if post_setup: - if post_setup == "xai_grok": - return "ready" if _xai_credentials_present() else "needs_auth" - predicate = _POST_SETUP_READY.get(post_setup) - if predicate is not None: - try: - return "ready" if predicate() else "needs_setup" - except Exception: - # Flaky detection must not manufacture a warning state. - return "ready" - # No reliable installed-check registered → treat the active-provider - # signal as "setup completed" (selecting the row runs the hook). - if is_active is None: - is_active = _is_provider_active(provider, config) - return "ready" if is_active else "needs_setup" - - return "ready" - - -def _toolset_needs_configuration_prompt( - ts_key: str, - config: dict, - *, - force_fresh: bool = False, -) -> bool: - """Return True when enabling this toolset should open provider setup.""" - cat = TOOL_CATEGORIES.get(ts_key) - if not cat: - return not _toolset_has_keys(ts_key, config, force_fresh=force_fresh) - - # If any visible provider has a registered post_setup install-state - # check that hasn't been satisfied (e.g. cua-driver binary not on - # PATH yet), force the configuration flow so `_configure_provider` - # invokes `_run_post_setup` and the install actually runs. - for provider in _visible_providers(cat, config, force_fresh=force_fresh): - post_setup = provider.get("post_setup") - if post_setup and not _post_setup_already_installed(post_setup): - return True - - # Categories whose "configured" signal is a selected provider key. - selection_key = {"tts": "provider", "web": "backend", "browser": "cloud_provider"}.get(ts_key) - if selection_key: - section = config.get(ts_key, {}) - return not isinstance(section, dict) or selection_key not in section - if ts_key == "image_gen": - # Satisfied when the in-tree FAL backend is configured OR any - # plugin-registered image gen provider is available. - return not fal_key_is_configured() and not _any_plugin_provider_available( - "agent.image_gen_registry" - ) - if ts_key == "video_gen": - # No in-tree fallback — every video backend is a plugin. - return not _any_plugin_provider_available("agent.video_gen_registry") - - return not _toolset_has_keys(ts_key, config, force_fresh=force_fresh) - - -def _any_plugin_provider_available(registry_module: str) -> bool: - """True when any provider in the plugin registry reports ``is_available()``.""" - try: - import importlib - - registry = importlib.import_module(registry_module) - from hermes_cli.plugins import _ensure_plugins_discovered - - _ensure_plugins_discovered() - for provider in registry.list_providers(): - try: - if provider.is_available(): - return True - except Exception: - continue - except Exception: - pass - return False - - -def _configure_tool_category( - ts_key: str, - cat: dict, - config: dict, - *, - force_fresh: bool = True, - reconfigure: bool = False, -): - """Provider selection for a tool category, then API-key setup for the chosen row. - - ``reconfigure`` is the "Reconfigure an existing tool" flow: no setup note / skip row / Nous - marker, and the chosen provider goes through the key-update prompts instead of the new-enable - prompts. - """ - icon = cat.get("icon", "") - name = cat["name"] - providers = _visible_providers(cat, config, force_fresh=force_fresh) - - if len(providers) == 1: - # Single provider - configure directly - provider = providers[0] - print() - print(color(f" --- {icon} {name} ({provider['name']}) ---", Colors.CYAN)) - if not reconfigure: - if provider.get("tag"): - _print_info(f" {provider['tag']}") - if cat.get("setup_note"): - _print_info(f" {cat['setup_note']}") - _configure_provider(provider, config, force_fresh=force_fresh, reconfigure=reconfigure) - return - - # Multiple providers - let user choose - print() - # Use custom title if provided (e.g. "Select Search Provider") - title = "Choose a provider" if reconfigure else cat.get("setup_title", "Choose a provider") - print(color(f" --- {icon} {name} - {title} ---", Colors.CYAN)) - if not reconfigure and cat.get("setup_note"): - _print_info(f" {cat['setup_note']}") - print() - - # When the user is logged into Nous, surface a marker on providers whose - # access is included in their subscription so it's visually obvious - # which options cost extra vs. cost nothing on top of Nous. - _nous_logged_in = False - if not reconfigure: - try: - _nous_logged_in = bool( - get_nous_subscription_features( - config, - force_fresh=force_fresh, - ).nous_auth_present - ) - except Exception: - _nous_logged_in = False - - # Plain text labels only (no ANSI codes in menu items) - provider_choices = [] - for p in providers: - badge = f" [{p['badge']}]" if p.get("badge") else "" - tag = f" — {p['tag']}" if p.get("tag") else "" - configured = "" - if _provider_env_ready(p): - if _is_provider_active(p, config, force_fresh=force_fresh): - configured = " [active]" - elif p.get("env_vars", []): - configured = " [configured]" - # Mark Nous-managed entries. Logged-in paid subscribers get the - # "included" star; everyone else gets a "via Nous Portal" hint so - # it's clear selecting the row triggers a Portal login. - sub_marker = "" - if not reconfigure and p.get("managed_nous_feature"): - if _nous_logged_in: - sub_marker = " ★ Included with your Nous subscription" - else: - sub_marker = " ★ via Nous Portal (login on select)" - provider_choices.append(f"{p['name']}{badge}{tag}{configured}{sub_marker}") - - if not reconfigure: - provider_choices.append("Skip — keep defaults / configure later") - - # Detect current provider as default - default_idx = _detect_active_provider_index( - providers, - config, - force_fresh=force_fresh, - ) - - question = " Select provider:" if reconfigure else f" {title}:" - provider_idx = _prompt_choice(question, provider_choices, default_idx) - - if provider_idx >= len(providers): - _print_info(f" Skipped {name}") - return - - _configure_provider( - providers[provider_idx], config, force_fresh=force_fresh, reconfigure=reconfigure - ) - - -def _web_tier_matches(provider: dict, config: dict) -> bool: - """Return True when a web picker row's tier matches the configured tier. - - Tiered rows (Exa/Parallel Free vs Paid) share one ``web_backend`` name and differ only in - ``web_tier``. The configured tier lives at ``web.provider_tier.`` (set on selection). - Matching rules: - - - row has no ``web_tier`` → tier-agnostic row, matches (legacy rows) - configured tier set → - must equal the row's tier - configured tier unset → "auto": the effective tier is paid when the - row's env vars are all present, free otherwise — highlight the row the runtime would actually - use - """ - row_tier = provider.get("web_tier") - if not row_tier: - return True - web_cfg = config.get("web") - if not isinstance(web_cfg, dict): - web_cfg = {} - tiers = web_cfg.get("provider_tier") - if not isinstance(tiers, dict): - tiers = {} - configured = str(tiers.get(provider["web_backend"], "") or "").lower().strip() - if configured in ("free", "paid"): - return configured == row_tier - # Auto: mirror plugins.web.keyless_mcp.use_keyless — key present → paid. - try: - from agent.web_search_provider import get_provider_env - - key_var = {"exa": "EXA_API_KEY", "parallel": "PARALLEL_API_KEY"}.get( - provider["web_backend"] - ) - has_key = bool(get_provider_env(key_var)) if key_var else False - except Exception: - has_key = False - return row_tier == ("paid" if has_key else "free") - - -def _is_provider_active( - provider: dict, - config: dict, - *, - force_fresh: bool = False, -) -> bool: - """Check if a provider entry matches the currently active config.""" - plugin_name = provider.get("image_gen_plugin_name") - if plugin_name and not provider.get("managed_nous_feature"): - # Managed (Nous-subscription) entries fall through to the - # managed_feature branch below, which also checks use_gateway — - # otherwise a managed FAL pick and a direct-key FAL pick would both - # report active for the same provider name (video already guards). - image_cfg = config.get("image_gen", {}) - if not (isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name): - return False - # A direct-key entry is only active when the managed route is OFF — - # mirror of the managed branch's use_gateway check. - return not is_truthy_value(image_cfg.get("use_gateway"), default=False) - - video_plugin_name = provider.get("video_gen_plugin_name") - if video_plugin_name and not provider.get("managed_nous_feature"): - video_cfg = config.get("video_gen", {}) - return isinstance(video_cfg, dict) and video_cfg.get("provider") == video_plugin_name - - managed_feature = provider.get("managed_nous_feature") - if managed_feature: - features = get_nous_subscription_features(config, force_fresh=force_fresh) - feature = features.features.get(managed_feature) - if feature is None: - return False - if managed_feature in ("image_gen", "video_gen"): - gen_cfg = config.get(managed_feature, {}) - if isinstance(gen_cfg, dict): - configured_provider = gen_cfg.get("provider") - if configured_provider not in {None, "", "fal", NOUS_MANAGED_PROVIDER}: - return False - if ( - configured_provider != NOUS_MANAGED_PROVIDER - and gen_cfg.get("use_gateway") is not None - and not is_truthy_value(gen_cfg.get("use_gateway"), default=False) - ): - return False - return feature.managed_by_nous - if provider.get("tts_provider"): - current = cfg_get(config, "tts", "provider") - return feature.managed_by_nous and current in {provider["tts_provider"], NOUS_MANAGED_PROVIDER} - if provider.get("stt_provider"): - current = cfg_get(config, "stt", "provider") - return feature.managed_by_nous and current in {provider["stt_provider"], NOUS_MANAGED_PROVIDER} - if "browser_provider" in provider: - # Browser Use mode is a driver on top of the provider (it attaches - # to the provider's CDP endpoint), so the provider row stays - # active alongside the Browser Use row. - current = cfg_get(config, "browser", "cloud_provider") - return feature.managed_by_nous and current in {provider["browser_provider"], NOUS_MANAGED_PROVIDER} - if provider.get("web_backend"): - current = cfg_get(config, "web", "backend") - return ( - feature.managed_by_nous - and current in {provider["web_backend"], NOUS_MANAGED_PROVIDER} - and _web_tier_matches(provider, config) - ) - return feature.managed_by_nous - - if provider.get("tts_provider"): - return cfg_get(config, "tts", "provider") == provider["tts_provider"] - if provider.get("stt_provider"): - # Default stt.provider is "local" — an unset key means Local Whisper. - current = cfg_get(config, "stt", "provider") or "local" - return current == provider["stt_provider"] - if "browser_provider" in provider: - # Browser Use mode composes with the provider (driver over the - # provider's CDP endpoint) — don't deactivate the provider row. - current = cfg_get(config, "browser", "cloud_provider") - if provider["browser_provider"] != current: - return False - # Two local rows differ only by engine ("Local Browser" vs - # "Lightpanda"): config.yaml is the picker's source of truth here, - # the AGENT_BROWSER_ENGINE env var is not consulted. - if provider.get("browser_engine"): - engine = str(cfg_get(config, "browser", "engine") or "auto").strip().lower() - return engine == provider["browser_engine"] - return True - if provider.get("browser_backend"): - backend = cfg_get(config, "browser", "backend") - if backend is False: - backend = "off" # YAML 1.1: unquoted `off` parses as boolean False - if backend == provider["browser_backend"]: - return True - if backend: - return False # explicit other choice ("off", …) wins - if provider["browser_backend"] != "browser-use": - return False - # Backend unset: Browser Use mode is the default — the row is active - # whenever the effective mode resolves on (legacy direct-API cloud - # config, or CLI runnable and no Camofox). - browser_cfg = config.get("browser") if isinstance(config, dict) else None - try: - from tools.browser_use_cli import ( - _find_cli, - is_legacy_browser_use_cloud_config, - ) - - if is_legacy_browser_use_cloud_config(browser_cfg or {}): - return True - try: - from tools.browser_camofox import is_camofox_mode - - if is_camofox_mode(): - return False - except Exception: - pass - return _find_cli() is not None - except Exception: - return False - if provider.get("web_backend"): - current = cfg_get(config, "web", "backend") - if current != provider["web_backend"]: - return False - return _web_tier_matches(provider, config) - if provider.get("computer_use_backend"): - current = cfg_get(config, "computer_use", "backend") - return current == provider["computer_use_backend"] - if provider.get("imagegen_backend"): - image_cfg = config.get("image_gen", {}) - if not isinstance(image_cfg, dict): - return False - configured_provider = image_cfg.get("provider") - return ( - provider["imagegen_backend"] == "fal" - and configured_provider in {None, "", "fal"} - and not is_truthy_value(image_cfg.get("use_gateway"), default=False) - ) - return False - - -def _detect_active_provider_index( - providers: list, - config: dict, - *, - force_fresh: bool = False, -) -> int: - """Return the index of the currently active provider, or 0.""" - for i, p in enumerate(providers): - if _is_provider_active(p, config, force_fresh=force_fresh): - return i - # Fallback: env vars present → likely configured - if p.get("env_vars", []) and _provider_env_ready(p): - return i - return 0 - - -# ─── Image Generation Model Pickers ─────────────────────────────────────────── -# -# IMAGEGEN_BACKENDS is a per-backend catalog. Each entry exposes: -# - config_key: top-level config.yaml key for this backend's settings -# - model_catalog_fn: returns an OrderedDict-like {model_id: metadata} -# - default_model: fallback when nothing is configured -# -# This prepares for future imagegen backends (Replicate, Stability, etc.): -# each new backend registers its own entry; the FAL provider entry in -# TOOL_CATEGORIES tags itself with `imagegen_backend: "fal"` to select the -# right catalog at picker time. - - -def _fal_model_catalog(): - """Lazy-load the FAL model catalog from the tool module.""" - from tools.image_generation_tool import FAL_MODELS, DEFAULT_MODEL - return FAL_MODELS, DEFAULT_MODEL - - -IMAGEGEN_BACKENDS = { - "fal": { - "display": "FAL.ai", - "config_key": "image_gen", - "catalog_fn": _fal_model_catalog, - }, -} - - -def _plugin_model_catalog(registry_module: str, plugin_name: str): - """Return ``(catalog_dict, default_model_id)`` for a plugin provider. - - ``catalog_dict`` is shaped like the legacy ``FAL_MODELS`` table so the picker code path is - shared. Returns ``({}, None)`` if the provider is unregistered or has no models. - """ - try: - import importlib - - registry = importlib.import_module(registry_module) - from hermes_cli.plugins import _ensure_plugins_discovered - - _ensure_plugins_discovered() - provider = registry.get_provider(plugin_name) - except Exception: - return {}, None - if provider is None: - return {}, None - try: - models = provider.list_models() or [] - default = provider.default_model() - except Exception: - return {}, None - catalog = {m["id"]: m for m in models if isinstance(m, dict) and "id" in m} - return catalog, default - - -def _plugin_image_gen_catalog(plugin_name: str): - """``(catalog, default_model)`` for a plugin-registered image gen provider.""" - return _plugin_model_catalog("agent.image_gen_registry", plugin_name) - - -def _plugin_video_gen_catalog(plugin_name: str): - """``(catalog, default_model)`` for a plugin-registered video gen provider.""" - return _plugin_model_catalog("agent.video_gen_registry", plugin_name) - - -def _pick_model_from_catalog( - catalog: dict, - default_model, - cfg_key: str, - display: str, - config: dict, - *, - row_indent: str = "", -) -> None: - """Column-aligned model picker shared by the FAL, plugin image gen and video gen flows. - - Writes the choice to ``config[cfg_key]["model"]``. The current model is listed first so the - cursor lands on it; a saved model that belongs to another provider (shared config key) or a - drifted catalog default never indexes the catalog. Safe when stdin is not a TTY — - curses_radiolist keeps the current selection. - """ - if not catalog: - return - cur_cfg = _cfg_section(config, cfg_key) - current_model = cur_cfg.get("model") or default_model - if current_model not in catalog: - current_model = default_model if default_model in catalog else next(iter(catalog)) - - model_ids = list(catalog.keys()) - ordered = [current_model] + [m for m in model_ids if m != current_model] - - widths = { - "model": max(len(m) for m in model_ids), - "speed": max((len(catalog[m].get("speed", "")) for m in model_ids), default=6), - "strengths": max((len(catalog[m].get("strengths", "")) for m in model_ids), default=0), - } - - print() - header = ( - f" {'Model':<{widths['model']}} " - f"{'Speed':<{widths['speed']}} " - f"{'Strengths':<{widths['strengths']}} " - f"Price" - ) - print(color(header, Colors.CYAN)) - - rows = [] - for mid in ordered: - meta = catalog[mid] - row = ( - f"{row_indent}{mid:<{widths['model']}} " - f"{meta.get('speed', ''):<{widths['speed']}} " - f"{meta.get('strengths', ''):<{widths['strengths']}} " - f"{meta.get('price', '')}" - ) - if mid == current_model: - row += " ← currently in use" - rows.append(row) - - idx = _prompt_choice(f" Choose {display} model:", rows, default=0) - - chosen = ordered[idx] - cur_cfg["model"] = chosen - _print_success(f" Model set to: {chosen}") - - -def _configure_imagegen_model(backend_name: str, config: dict) -> None: - """Prompt for a model of an in-tree imagegen backend (``IMAGEGEN_BACKENDS``).""" - backend = IMAGEGEN_BACKENDS.get(backend_name) - if not backend: - return - catalog, default_model = backend["catalog_fn"]() - _pick_model_from_catalog(catalog, default_model, backend["config_key"], backend["display"], config) - - -def _configure_imagegen_model_for_plugin(plugin_name: str, config: dict) -> None: - """Prompt for a model from a plugin-registered image gen backend's catalog.""" - catalog, default_model = _plugin_image_gen_catalog(plugin_name) - _pick_model_from_catalog(catalog, default_model, "image_gen", plugin_name, config) - - -def _configure_videogen_model_for_plugin(plugin_name: str, config: dict) -> None: - """Prompt for a video gen model from a plugin's catalog.""" - catalog, default_model = _plugin_video_gen_catalog(plugin_name) - # Video rows historically carry a two-space indent; kept for output parity. - _pick_model_from_catalog( - catalog, default_model, "video_gen", plugin_name, config, row_indent=" " - ) - - -def _configure_xai_imagine_storage(section_name: str, config: dict) -> None: - """Prompt for xAI Imagine stored public URL behavior.""" - section = _cfg_section(config, section_name) - xai_cfg = _cfg_section(section, "xai") - storage_cfg = _cfg_section(xai_cfg, "storage") - - _print_warning( - " xAI Imagine can store generated media and create reusable public URLs. " - "xAI may bill for stored files and public URL hosting." - ) - idx = _prompt_choice( - " Stored public URLs:", - [ - "Enable public URLs without automatic expiry (recommended)", - "Disable stored public URLs", - "Enable public URLs for 2 days", - ], - default=0, - ) - if idx == 1: - storage_cfg["enabled"] = False - _print_success(" xAI stored public URLs disabled") - elif idx == 2: - storage_cfg["enabled"] = True - storage_cfg["public_url"] = True - storage_cfg["expires_after"] = 2 * 24 * 60 * 60 - _print_success(" xAI stored public URLs enabled for 2 days") - else: - storage_cfg["enabled"] = True - storage_cfg["public_url"] = True - storage_cfg["expires_after"] = None - _print_success(" xAI stored public URLs enabled without automatic expiry") - - -def _select_plugin_gen_provider(section: str, plugin_name: str, config: dict, *, use_gateway: bool) -> None: - """Persist a plugin-backed image/video generation provider selection and run its model picker. - - ``use_gateway=True`` (Nous-managed pick) stores ``
.provider: nous``; BYOK picks store - the plugin name. Any legacy ``use_gateway`` key is removed so old read-time shims cannot - override the fresh selection. - """ - cfg = _cfg_section(config, section) - cfg["provider"] = NOUS_MANAGED_PROVIDER if use_gateway else plugin_name - cfg.pop("use_gateway", None) - _print_success(f" {section}.provider set to: {cfg['provider']}") - if section == "image_gen": - _configure_imagegen_model_for_plugin(plugin_name, config) - else: - _configure_videogen_model_for_plugin(plugin_name, config) - if plugin_name == "xai": - _configure_xai_imagine_storage(section, config) - - -def _select_plugin_image_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None: - _select_plugin_gen_provider("image_gen", plugin_name, config, use_gateway=use_gateway) - - -def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None: - _select_plugin_gen_provider("video_gen", plugin_name, config, use_gateway=use_gateway) - - -# Per-provider STT model catalogs for the interactive picker. Keys are -# ``stt.`` config sections; the first entry is the default. -# Kept in sync with the dashboard selects (hermes_cli/web_server.py -# _CONFIG_FIELD_META) and the desktop settings enums -# (apps/desktop/src/app/settings/constants.ts). -STT_MODEL_CATALOG = { - "local": ["base", "tiny", "small", "medium", "large-v3"], - "groq": ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"], - "openai": ["whisper-1", "gpt-4o-mini-transcribe", "gpt-4o-transcribe", "gpt-transcribe"], - "elevenlabs": ["scribe_v2", "scribe_v1"], -} - -# ElevenLabs historically uses ``model_id`` instead of ``model``. -_STT_MODEL_CONFIG_KEY = {"elevenlabs": "model_id"} - - -def _configure_stt_model(stt_provider: str, config: dict) -> None: - """Prompt for the STT model after a provider pick (when a catalog exists).""" - catalog = STT_MODEL_CATALOG.get(stt_provider) - if not catalog: - return - stt_cfg = _cfg_section(config, "stt") - prov_cfg = _cfg_section(stt_cfg, stt_provider) - model_key = _STT_MODEL_CONFIG_KEY.get(stt_provider, "model") - current = str(prov_cfg.get(model_key) or "").strip() - ordered = list(catalog) - default_idx = ordered.index(current) if current in ordered else 0 - idx = _prompt_choice(" Select STT model:", ordered, default_idx) - chosen = ordered[idx] - prov_cfg[model_key] = chosen - _print_success(f" STT model set to: {chosen}") - - -# Provider-row marker key -> config section it selects into. -_PROVIDER_MARKER_SECTIONS = { - "tts_provider": "tts", - "stt_provider": "stt", - "browser_provider": "browser", - "web_backend": "web", - "image_gen_plugin_name": "image_gen", - "imagegen_backend": "image_gen", - "video_gen_plugin_name": "video_gen", -} - - -def _write_provider_config(provider: dict, config: dict, *, managed_feature) -> None: - """Persist the provider/backend config keys for a selected provider. - - Pure, non-interactive core of :func:`_configure_provider`: no env prompts, post-setup hooks, - Nous auth gating or model pickers. Both the CLI and the GUI ``PUT .../provider`` endpoint call - through here so there is one code path. Each pick writes exactly ONE provider string per - category (``nous`` for managed rows) and removes any legacy ``use_gateway`` key so the read-time - shim cannot override the new choice. - """ - def _set_selection(section_key: str, name_key: str, vendor_value) -> None: - section = _cfg_section(config, section_key) - section[name_key] = ( - NOUS_MANAGED_PROVIDER if managed_feature else vendor_value - ) - section.pop("use_gateway", None) - - # Set TTS provider in config if applicable - if provider.get("tts_provider"): - _set_selection("tts", "provider", provider["tts_provider"]) - - # Set STT provider in config if applicable - if provider.get("stt_provider"): - _set_selection("stt", "provider", provider["stt_provider"]) - - # Set browser cloud provider in config if applicable - if "browser_provider" in provider: - bp = provider["browser_provider"] - browser_cfg = config.setdefault("browser", {}) - if bp or managed_feature: - # Browser Use mode (browser.backend) composes with the provider — - # switching providers keeps the driver choice intact. - _set_selection("browser", "cloud_provider", bp) - else: - browser_cfg.pop("use_gateway", None) - - if provider.get("browser_backend"): - browser_cfg = config.setdefault("browser", {}) - browser_cfg["backend"] = provider["browser_backend"] - - # Local engine rows ("Local Browser" resets to auto, "Lightpanda" sets - # lightpanda). Composes with browser.backend like the provider does. - if provider.get("browser_engine"): - browser_cfg = config.setdefault("browser", {}) - browser_cfg["engine"] = provider["browser_engine"] - - # Set web search backend in config if applicable - if provider.get("web_backend"): - _set_selection("web", "backend", provider["web_backend"]) - web_cfg = config.get("web") - if isinstance(web_cfg, dict): - if provider.get("web_tier"): - tiers = web_cfg.setdefault("provider_tier", {}) - if isinstance(tiers, dict): - tiers[provider["web_backend"]] = provider["web_tier"] - else: - stale_tiers = web_cfg.get("provider_tier") - if isinstance(stale_tiers, dict): - stale_tiers.pop(provider["web_backend"], None) - - # Set computer_use backend in config if applicable - if provider.get("computer_use_backend"): - cu_cfg = config.setdefault("computer_use", {}) - cu_cfg["backend"] = provider["computer_use_backend"] - - # Managed rows for categories without a marker handled above (e.g. the - # image_gen/video_gen "Nous Subscription" rows carry only - # managed_nous_feature) still persist the "nous" selection. - if managed_feature and managed_feature not in {"web", "tts", "stt", "browser"}: - section = config.setdefault(managed_feature, {}) - if isinstance(section, dict): - section["provider"] = NOUS_MANAGED_PROVIDER - section.pop("use_gateway", None) - elif not managed_feature: - # User picked a non-gateway provider — clear any stale legacy - # use_gateway key on the category so the read-time shim cannot - # override the fresh selection. Resolve the category from the - # provider's own markers first (plugin-injected rows are NOT in - # TOOL_CATEGORIES' hardcoded provider lists and previously skipped - # this clear), then fall back to the category-membership walk. - sections = [ - section_key for marker, section_key in _PROVIDER_MARKER_SECTIONS.items() - if marker in provider - ] - if not sections: - sections = [ - cat_key for cat_key, cat in TOOL_CATEGORIES.items() - if provider in cat.get("providers", []) - ][:1] - for section_key in sections: - section = config.get(section_key) - if isinstance(section, dict): - section.pop("use_gateway", None) - - -def apply_provider_selection(ts_key: str, provider_name: str, config: dict) -> None: - """Non-interactively persist a provider selection for a toolset. - - Resolves ``provider_name`` among the rows the picker shows (:func:`_visible_providers`) and - writes the config keys only — API keys, post-setup hooks, auth gating and model pickers are - handled by separate GUI endpoints. Raises ``KeyError`` for an unknown toolset or provider. - """ - cat = TOOL_CATEGORIES.get(ts_key) - if cat is None: - raise KeyError(f"Toolset has no configurable category: {ts_key}") - - providers = _visible_providers(cat, config, force_fresh=True) - provider = next((p for p in providers if p.get("name") == provider_name), None) - if provider is None: - raise KeyError(f"Unknown provider {provider_name!r} for toolset {ts_key!r}") - - managed_feature = provider.get("managed_nous_feature") - _write_provider_config(provider, config, managed_feature=managed_feature) - - # Plugin-registered image/video gen backends record the provider name in - # their own config section (without the interactive model picker the CLI - # runs afterwards — model choice is a separate GUI flow). Managed picks - # store the "nous" selection. The in-tree FAL BYOK row always persists an - # explicit ``image_gen.provider: fal`` — historically it could leave the - # key unset, making a deliberate pick indistinguishable from a - # never-configured install. - selections = [ - ("image_gen", provider.get("image_gen_plugin_name")), - ("video_gen", provider.get("video_gen_plugin_name")), - ("image_gen", "fal" if provider.get("imagegen_backend") and not managed_feature else None), - ] - for section_key, vendor in selections: - if vendor: - section = _cfg_section(config, section_key) - section["provider"] = NOUS_MANAGED_PROVIDER if managed_feature else vendor - section.pop("use_gateway", None) - - -def _nous_provider_gate(provider: dict, config: dict, managed_feature, *, force_fresh: bool) -> bool: - """Return False (after printing why) when a Nous-gated row cannot be selected. - - Managed Tool Gateway rows are always listed (see ``_visible_providers``) but only *activate* - with paid Nous Portal access — selecting one runs an inline Portal login (auth + entitlement - only, no inference-provider switch). Pure pre-auth UX rows (``requires_nous_auth`` without a - managed feature) keep the older logged-in + entitled gate. - """ - if managed_feature: - from hermes_cli.nous_subscription import ( - MANAGED_FEATURE_COVERAGE_CATEGORY, - ensure_nous_portal_access, - ) - - if not ensure_nous_portal_access( - capability=f"{provider.get('name', 'the Nous Tool Gateway')}", - coverage_category=MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature), - ): - _print_warning( - " Not enabled — Nous Portal access is required for this backend." - ) - return False - return True - - if provider.get("requires_nous_auth"): - features = get_nous_subscription_features(config, force_fresh=force_fresh) - entitled = bool( - features.account_info and features.account_info.paid_service_access is True - ) - if not features.nous_auth_present or not entitled: - message = format_nous_portal_entitlement_message( - features.account_info, - capability=f"{provider.get('name', 'Nous Subscription')}", - ) - _print_warning( - f" {message or 'Nous Subscription is only available after logging into Nous Portal.'}" - ) - return False - return True - - -def _finish_provider_selection(provider: dict, config: dict, managed_feature) -> None: - """Model pickers that follow a provider pick: plugin image/video gen, in-tree FAL, STT.""" - plugin_name = provider.get("image_gen_plugin_name") - if plugin_name: - _select_plugin_image_gen_provider(plugin_name, config, use_gateway=bool(managed_feature)) - return - video_plugin = provider.get("video_gen_plugin_name") - if video_plugin: - _select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature)) - return - backend = provider.get("imagegen_backend") - if backend: - _configure_imagegen_model(backend, config) - # In-tree FAL is the only non-plugin backend today. Persist the - # explicit selection: "nous" for a managed row, "fal" for BYOK, and - # drop any legacy use_gateway key — never clobber a Nous-managed pick - # back onto direct keys. - img_cfg = _cfg_section(config, "image_gen") - img_cfg["provider"] = NOUS_MANAGED_PROVIDER if managed_feature else "fal" - img_cfg.pop("use_gateway", None) - # STT providers prompt for model selection after the backend pick - # (skipped for managed rows — the gateway pins the model). - if provider.get("stt_provider") and not managed_feature: - _configure_stt_model(provider["stt_provider"], config) - - -def _print_provider_selection(provider: dict, managed_feature, *, reconfigure: bool) -> None: - """Status lines announcing which backend/provider keys a pick writes.""" - if reconfigure and provider.get("tts_provider"): - _print_success(f" TTS provider set to: {provider['tts_provider']}") - if provider.get("stt_provider"): - _print_success(f" STT provider set to: {provider['stt_provider']}") - if "browser_provider" in provider: - bp = provider["browser_provider"] - if reconfigure and managed_feature: - _print_success(f" Browser cloud provider set to: {bp or 'nous'}") - elif bp == "local": - _print_success(" Browser set to local mode") - elif bp: - _print_success(f" Browser cloud provider set to: {bp}") - if provider.get("browser_backend"): - _print_success(" Browser set to Browser Use (browser_exec via CLI 3.0)") - if provider.get("browser_engine") and provider["browser_engine"] != "auto": - _print_success(f" Browser engine set to: {provider['browser_engine']}") - if provider.get("web_backend"): - if reconfigure and provider.get("web_tier"): - _print_success( - f" Web backend set to: {provider['web_backend']} " - f"({provider['web_tier']} tier)" - ) - else: - _print_success(f" Web backend set to: {provider['web_backend']}") - if reconfigure and provider.get("computer_use_backend"): - _print_success(f" Computer Use backend set to: {provider['computer_use_backend']}") - - -def _configure_provider( - provider: dict, - config: dict, - *, - force_fresh: bool = True, - reconfigure: bool = False, -): - """Configure a single provider - prompt for API keys and set config. - - ``reconfigure=False`` is the new-enable flow: already-set keys are kept without asking, and - the post-setup hook only runs when every key was provided. ``reconfigure=True`` re-prompts - every key ("Enter to keep current") and always runs the post-setup hook. - """ - env_vars = provider.get("env_vars", []) - managed_feature = provider.get("managed_nous_feature") - - if not _nous_provider_gate(provider, config, managed_feature, force_fresh=force_fresh): - return - - _print_provider_selection(provider, managed_feature, reconfigure=reconfigure) - - # Persist the provider/backend config keys + use_gateway flags. Shared - # with the GUI provider-select endpoint via apply_provider_selection so - # there is a single source of truth for these writes. - _write_provider_config(provider, config, managed_feature=managed_feature) - - if not env_vars: - if provider.get("post_setup"): - _run_post_setup(provider["post_setup"]) - _print_success(f" {provider['name']} - no configuration needed!") - if managed_feature: - _print_info(" Requests for this tool will be billed to your Nous subscription.") - _finish_provider_selection(provider, config, managed_feature) - return - - if reconfigure: - for var in env_vars: - existing = get_env_value(var["key"]) - if existing: - _print_info(f" {var['key']}: configured ({existing[:8]}...)") - url = var.get("url", "") - if url: - _print_info(f" Get yours at: {url}") - default_val = var.get("default", "") - value = _prompt(f" {var.get('prompt', var['key'])} (Enter to keep current)", password=not default_val) - if value and value.strip(): - save_env_value(var["key"], value.strip()) - _print_success(" Updated") - else: - _print_info(" Kept current") - - if provider.get("post_setup"): - _run_post_setup(provider["post_setup"]) - _finish_provider_selection(provider, config, managed_feature) - return - - # If this BYOK provider lives in a category that ALSO has a - # Nous-managed sibling, show a single dim hint so users know - # they can avoid the key entirely via a Portal subscription. - # Suppressed when the user is already authed to Nous. - _show_portal_hint = False - if not managed_feature and not provider.get("requires_nous_auth"): - try: - _has_managed_sibling = False - for _cat_key, _cat in TOOL_CATEGORIES.items(): - _providers = _cat.get("providers", []) - if provider in _providers and any( - sib.get("managed_nous_feature") for sib in _providers - ): - _has_managed_sibling = True - break - if _has_managed_sibling: - _features = get_nous_subscription_features( - config, - force_fresh=force_fresh, - ) - _show_portal_hint = not _features.nous_auth_present - except Exception: - _show_portal_hint = False - - if _show_portal_hint: - _print_info(" Available through Nous Portal subscription.") - - # Prompt for each required env var - all_configured = True - for var in env_vars: - existing = get_env_value(var["key"]) - if existing: - # Don't ask to update - this is a new enable flow. - _print_success(f" {var['key']}: already configured") - else: - url = var.get("url", "") - if url: - _print_info(f" Get yours at: {url}") - - default_val = var.get("default", "") - if default_val: - value = _prompt(f" {var.get('prompt', var['key'])}", default_val) - else: - value = _prompt(f" {var.get('prompt', var['key'])}", password=True) - - if value: - save_env_value(var["key"], value) - _print_success(" Saved") - else: - _print_warning(" Skipped") - all_configured = False - - # Run post-setup hooks if needed - if provider.get("post_setup") and all_configured: - _run_post_setup(provider["post_setup"]) - - if all_configured: - _print_success(f" {provider['name']} configured!") - _finish_provider_selection(provider, config, managed_feature) - - -def _reconfigure_provider(provider: dict, config: dict, *, force_fresh: bool = True): - """Reconfigure a provider - update API keys.""" - _configure_provider(provider, config, force_fresh=force_fresh, reconfigure=True) - - -def _configure_vision_backend() -> None: - """Interactive vision-backend configuration. - - Vision resolves from ``auxiliary.vision.{provider,model,base_url}``. Rather than forcing - OpenRouter, offer any authenticated provider + model (same surface as ``hermes model``) or a - custom endpoint. "Auto" leaves the keys empty so the resolver uses the main-model fallback - chain. - """ - print() - print(color(" Vision / Image Analysis needs a multimodal model.", Colors.YELLOW)) - print(color( - " Pick any provider + model (like /model), or let it auto-detect.", - Colors.DIM, - )) - - choices = [ - "Auto — use your main model / aggregator fallback (recommended)", - "Pick a provider and model", - "Custom OpenAI-compatible endpoint — base URL, API key, model", - "Skip", - ] - idx = _prompt_choice(" Configure vision backend", choices, 0) - - config = load_config() - vision_cfg = _cfg_section(_cfg_section(config, "auxiliary"), "vision") - - if idx == 0: - # Auto: clear any pinned override so the resolver auto-detects. - for key in ("provider", "model", "base_url", "api_key", "api_mode"): - vision_cfg.pop(key, None) - save_config(config) - _print_success(" Vision set to auto (main model / aggregator fallback)") - return - - if idx == 1: - _configure_vision_provider_model(config, vision_cfg) - return - - if idx == 2: - base_url = _prompt(" Base URL (blank for OpenAI)").strip() or "https://api.openai.com/v1" - is_native_openai = base_url_hostname(base_url) == "api.openai.com" - key_label = " OPENAI_API_KEY" if is_native_openai else " API key" - api_key = _prompt(key_label, password=True) - if not (api_key and api_key.strip()): - _print_warning(" Skipped") - return - default_model = "gpt-4o-mini" if is_native_openai else "" - model = _prompt( - f" Vision model{f' (blank for {default_model})' if default_model else ''}" - ).strip() or default_model - save_env_value("OPENAI_API_KEY", api_key.strip()) - # Only base_url + model go to config.yaml; the key is the secret. - # Pin provider="custom" so the resolver routes through this endpoint — - # leaving it at the "auto" default would make _resolve_task_provider_model - # ignore the base_url (it only honors base_url when paired with an - # api_key in config or a non-auto provider). - vision_cfg["provider"] = "custom" - vision_cfg["base_url"] = base_url - if model: - vision_cfg["model"] = model - else: - vision_cfg.pop("model", None) - save_config(config) - _print_success(f" Vision set to custom endpoint{f' ({model})' if model else ''}") - return - - # Skip - _print_info(" Skipped vision configuration") - - -def _configure_vision_provider_model(config: dict, vision_cfg: dict) -> None: - """Provider + model picker for vision, mirroring the ``/model`` surface. - - Rows come from ``build_aux_picker_rows()`` so this lists exactly what the ``hermes model`` - aux-task picker lists, including user-defined ``providers:`` / ``custom_providers:`` endpoints. - Persists ``auxiliary.vision.provider`` + ``.model``. - """ - try: - from hermes_cli.inventory import ( - build_aux_picker_rows, - format_aux_picker_entries, - ) - except Exception as exc: # pragma: no cover - import guard - _print_warning(f" Could not load provider list: {exc}") - return - - current_provider = str(vision_cfg.get("provider") or "").strip() - current_model = str(vision_cfg.get("model") or "").strip() - current_base_url = str(vision_cfg.get("base_url") or "").strip() - - try: - providers = build_aux_picker_rows( - current_provider=current_provider, - current_model=current_model, - current_base_url=current_base_url, - max_models=40, - ) - except Exception as exc: - _print_warning(f" Could not detect providers: {exc}") - providers = [] - - if not providers: - _print_warning( - " No authenticated providers found. Configure a provider first " - "with `hermes model`, then re-run this." - ) - return - - provider_labels = [ - label - for _slug, label, _models in format_aux_picker_entries( - providers, - current_provider=current_provider, - current_base_url=current_base_url, - ) - ] - provider_labels.append("Cancel") - - pidx = _prompt_choice(" Choose vision provider:", provider_labels, 0) - if pidx >= len(providers): - _print_info(" Cancelled") - return - - chosen = providers[pidx] - slug = chosen.get("slug") - models = list(chosen.get("models", [])) - - model_choices = list(models) + ["Type a custom model id…"] - midx = _prompt_choice( - f" Choose vision model for {chosen.get('name') or slug}:", - model_choices, - 0, - ) - if midx < len(models): - model = models[midx] - else: - model = _prompt(" Model id").strip() - if not model: - _print_warning(" No model entered — cancelled") - return - - vision_cfg["provider"] = slug - vision_cfg["model"] = model - # A provider selection supersedes any prior custom endpoint override. - vision_cfg.pop("base_url", None) - vision_cfg.pop("api_key", None) - save_config(config) - _print_success(f" Vision set to {slug} / {model}") - - -def _configure_simple_requirements(ts_key: str, *, reconfigure: bool = False): - """Fallback for toolsets that just need env vars (no provider selection). - - Vision has its own provider/model picker (any provider, like ``hermes model``) — run it - directly so neither flow falls back to the generic single-key prompt (which would re-ask for - OPENROUTER_API_KEY). - """ - if ts_key == "vision": - if reconfigure or not _toolset_has_keys("vision"): - _configure_vision_backend() - return - - requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, []) - if not reconfigure: - requirements = [(var, url) for var, url in requirements if not get_env_value(var)] - if not requirements: - return - - ts_label = _toolset_label(ts_key) - print() - if reconfigure: - print(color(f" {ts_label}:", Colors.CYAN)) - else: - print(color(f" {ts_label} requires configuration:", Colors.YELLOW)) - - for var, url in requirements: - if reconfigure: - existing = get_env_value(var) - if existing: - _print_info(f" {var}: configured ({existing[:8]}...)") - if url: - _print_info(f" Get key at: {url}") - suffix = " (Enter to keep current)" if reconfigure else "" - value = _prompt(f" {var}{suffix}", password=True) - if value and value.strip(): - save_env_value(var, value.strip()) - _print_success(" Updated" if reconfigure else " Saved") - else: - if reconfigure: - _print_info(" Kept current") - else: - _print_warning(" Skipped") def _reconfigure_tool( diff --git a/hermes_cli/tools_config_providers.py b/hermes_cli/tools_config_providers.py new file mode 100644 index 0000000000..eb73eae5f4 --- /dev/null +++ b/hermes_cli/tools_config_providers.py @@ -0,0 +1,1403 @@ +"""Provider rows, active-provider detection, model pickers and provider-selection persistence for `hermes tools`.""" + +from __future__ import annotations + +import importlib +import logging +from typing import Callable, Optional + +from hermes_cli.cli_output import ( + print_info as _print_info, + print_success as _print_success, + print_warning as _print_warning, + prompt as _prompt, +) +from hermes_cli.colors import Colors, color +from hermes_cli.config import cfg_get, get_env_value, load_config, save_config, save_env_value +from hermes_cli.nous_account import format_nous_portal_entitlement_message +from hermes_cli.nous_subscription import MANAGED_FEATURE_COVERAGE_CATEGORY, NousSubscriptionFeatures +from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, fal_key_is_configured +from utils import base_url_hostname, is_truthy_value + +logger = logging.getLogger("hermes_cli.tools_config") + +# NOTE: tools_config-internal names (TOOL_CATEGORIES, _cfg_section, _prompt_choice, +# get_nous_subscription_features, post-setup hooks, ...) are imported lazily inside the +# functions that need them: tools_config re-imports this module, and tests patch those +# names on ``hermes_cli.tools_config``. + + +def _plugin_registry(module: str): + """Import a plugin registry module after plugin discovery; ``None`` on any failure.""" + try: + registry = importlib.import_module(module) + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + return registry + except Exception: + return None + + +def _plugin_provider_rows( + registry_module: str, + marker_keys: tuple[str, ...], + *, + require_name: bool = True, + skip_builtin: bool = False, + flatten_variants: bool = False, +) -> list[dict]: + """Build picker-row dicts from a plugin registry's providers. + + Each row looks like a hardcoded ``TOOL_CATEGORIES`` provider row plus the ``marker_keys`` + (all set to the provider's registry name) that route downstream config-writing / model-picker + code through the plugin registry. ``skip_builtin`` drops providers whose name shadows the + registry's ``_BUILTIN_NAMES`` (defence in depth). ``flatten_variants`` expands a schema's tier + ``variants`` (e.g. Exa/Parallel free keyless vs paid SDK) into separate rows sharing one + backend name, distinguished by ``web_tier``. + """ + registry = _plugin_registry(registry_module) + if registry is None: + return [] + try: + providers = registry.list_providers() + builtin = registry._BUILTIN_NAMES if skip_builtin else frozenset() + except Exception: + return [] + + rows: list[dict] = [] + for provider in providers: + if require_name: + name = getattr(provider, "name", None) + if not name: + continue + if skip_builtin and name.lower().strip() in builtin: + continue + try: + schema = provider.get_setup_schema() + except Exception: + continue + if not isinstance(schema, dict): + continue + if not require_name: + name = provider.name + entries = [schema] + if flatten_variants: + entries += [v for v in (schema.get("variants") or []) if isinstance(v, dict)] + for entry in entries: + row = { + "name": entry.get("name", provider.display_name), + "badge": entry.get("badge", ""), + "tag": entry.get("tag", ""), + "env_vars": entry.get("env_vars", []), + } + for key in marker_keys: + row[key] = name + if flatten_variants and entry.get("web_tier"): + row["web_tier"] = entry["web_tier"] + if entry.get("post_setup"): + row["post_setup"] = entry["post_setup"] + rows.append(row) + return rows + + +# Category -> (registry module, marker keys, _plugin_provider_rows kwargs). Marker semantics: +# image/video ``*_plugin_name`` route config writes + model pickers through the registry (video +# has no in-tree backend, so this is the only row source); web ``web_backend`` + ``web_search_plugin_name`` +# (all bundled web providers are plugins; only the firecrawl setup-flow rows stay hardcoded); +# browser ``browser_provider`` is the legacy key written to ``browser.cloud_provider``; TTS rows +# render below the built-in rows and write ``tts.provider: `` like them. +_PLUGIN_PROVIDER_ROW_SPECS = { + "image_gen": ("agent.image_gen_registry", ("image_gen_plugin_name",), {"require_name": False}), + "video_gen": ("agent.video_gen_registry", ("video_gen_plugin_name",), {"require_name": False}), + "web": ("agent.web_search_registry", ("web_backend", "web_search_plugin_name"), {"flatten_variants": True}), + "browser": ("agent.browser_registry", ("browser_provider", "browser_plugin_name"), {}), + "tts": ("agent.tts_registry", ("tts_provider", "tts_plugin_name"), {"skip_builtin": True}), +} + + +def _plugin_rows_for(category: str) -> list[dict]: + module, markers, kwargs = _PLUGIN_PROVIDER_ROW_SPECS[category] + return _plugin_provider_rows(module, markers, **kwargs) + + +def _plugin_image_gen_providers() -> list[dict]: + """Picker rows for plugin-registered image gen providers.""" + return _plugin_rows_for("image_gen") + + +def _plugin_video_gen_providers() -> list[dict]: + """Picker rows for plugin-registered video gen providers (the only Video Generation rows).""" + return _plugin_rows_for("video_gen") + + +def _plugin_web_search_providers() -> list[dict]: + """Picker rows for plugin-registered web search providers.""" + return _plugin_rows_for("web") + + +def _plugin_browser_providers() -> list[dict]: + """Picker rows for plugin-registered cloud browser providers.""" + return _plugin_rows_for("browser") + + +def _plugin_tts_providers() -> list[dict]: + """Picker rows for plugin-registered TTS providers.""" + return _plugin_rows_for("tts") + + +def web_provider_capabilities(backend: str) -> list: + """Return the capabilities (``search`` / ``extract``) a web backend supports. + + Consults the plugin registry's provider instance (``supports_search`` / ``supports_extract``) so + the Capabilities GUI can offer per-capability selection (``web.search_backend`` / + ``web.extract_backend``) only where it makes sense — e.g. ddgs and brave-free are search-only. + """ + try: + from agent.web_search_registry import get_provider + + provider = get_provider(backend) + if provider is not None: + caps = [] + if provider.supports_search(): + caps.append("search") + if provider.supports_extract(): + caps.append("extract") + return caps + except Exception: + pass + return ["search", "extract"] + + +# TOOL_CATEGORIES[]["name"] -> builder of plugin-registered picker rows. +_PLUGIN_ROW_BUILDERS = { + "Image Generation": _plugin_image_gen_providers, + "Video Generation": _plugin_video_gen_providers, + "Web Search & Extract": _plugin_web_search_providers, + "Browser Automation": _plugin_browser_providers, + "Text-to-Speech": _plugin_tts_providers, +} + + +def _visible_providers( + cat: dict, + config: dict, + *, + force_fresh: bool = False, + features: Optional[NousSubscriptionFeatures] = None, +) -> list[dict]: + """Return provider entries visible for the current auth/config state. + + Nous-managed Tool Gateway rows (``managed_nous_feature``) are always shown — even to logged-out + / unentitled users — so the picker advertises that the capability exists. + """ + from hermes_cli.tools_config import get_nous_subscription_features + + if features is None: + features = get_nous_subscription_features(config, force_fresh=force_fresh) + acct = features.account_info + # Pool-only users (free tool pool, no paid access) get image gen but NOT video gen — the pool + # doesn't fund `fal-video`, so hide the managed video row rather than advertise a denial. + # Logged-out users still see it (advertising) and paid users are entitled to it. + pool_only = bool( + acct + and acct.logged_in + and acct.paid_service_access is not True + and acct.tool_gateway_entitled + ) + visible = [] + for provider in cat.get("providers", []): + # Managed rows stay visible regardless of auth (selecting one drives an inline Portal + # login); a `requires_nous_auth` row without a managed feature hides until logged in. + if ( + provider.get("requires_nous_auth") + and not provider.get("managed_nous_feature") + and not features.nous_auth_present + ): + continue + if ( + pool_only + and provider.get("managed_nous_feature") == "video_gen" + and not (acct and acct.tool_gateway_entitled_for("fal-video")) + ): + continue + visible.append(provider) + + # Plugin-registered rows render BELOW the hardcoded rows. For web and browser they are the + # only real provider rows — the hardcoded leftovers are non-provider UX setup flows. + builder = _PLUGIN_ROW_BUILDERS.get(cat.get("name")) + if builder is not None: + visible.extend(builder()) + + return visible + + +def provider_readiness_status( + provider: dict, + config: dict, + *, + features=None, + is_active: Optional[bool] = None, +) -> str: + """Compute an honest readiness state for a provider picker row. + + ``features`` (a ``NousSubscriptionFeatures``) can be passed to avoid re-fetching portal state + per row. ``is_active`` is the completed-setup fallback signal for post_setup hooks with no + registered installed-check (selecting a row runs its hook, so the active row has been set up). + """ + from hermes_cli.tools_config import ( + _POST_SETUP_READY, + _provider_env_ready, + _xai_credentials_present, + get_nous_subscription_features, + ) + + if provider.get("env_vars", []): + return "ready" if _provider_env_ready(provider) else "needs_keys" + + managed_feature = provider.get("managed_nous_feature") + if provider.get("requires_nous_auth") or managed_feature: + if features is None: + features = get_nous_subscription_features(config) + if not features.nous_auth_present: + return "needs_auth" + if managed_feature: + # Same per-category entitlement gate the CLI applies at selection time. + acct = features.account_info + category = MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature) + entitled = bool( + acct + and acct.logged_in + and ( + acct.tool_gateway_entitled_for(category) + if category + else acct.tool_gateway_entitled + ) + ) + if not entitled: + return "needs_auth" + # Signed in and entitled — fall through: a managed row may still carry a local install + # hook (e.g. the managed browser row needs the agent-browser CLI on this machine). + + post_setup = provider.get("post_setup") + if post_setup: + if post_setup == "xai_grok": + return "ready" if _xai_credentials_present() else "needs_auth" + predicate = _POST_SETUP_READY.get(post_setup) + if predicate is not None: + try: + return "ready" if predicate() else "needs_setup" + except Exception: + # Flaky detection must not manufacture a warning state. + return "ready" + # No installed-check registered → the active-provider signal means "setup completed". + if is_active is None: + is_active = _is_provider_active(provider, config) + return "ready" if is_active else "needs_setup" + + return "ready" + + +def _toolset_needs_configuration_prompt( + ts_key: str, + config: dict, + *, + force_fresh: bool = False, +) -> bool: + """Return True when enabling this toolset should open provider setup.""" + from hermes_cli.tools_config import TOOL_CATEGORIES, _post_setup_already_installed, _toolset_has_keys + + cat = TOOL_CATEGORIES.get(ts_key) + if not cat: + return not _toolset_has_keys(ts_key, config, force_fresh=force_fresh) + + # An unsatisfied post_setup install-state check (e.g. cua-driver not on PATH yet) forces the + # configuration flow so `_configure_provider` runs the hook and the install actually happens. + for provider in _visible_providers(cat, config, force_fresh=force_fresh): + post_setup = provider.get("post_setup") + if post_setup and not _post_setup_already_installed(post_setup): + return True + + # Categories whose "configured" signal is a selected provider key. + selection_key = {"tts": "provider", "web": "backend", "browser": "cloud_provider"}.get(ts_key) + if selection_key: + section = config.get(ts_key, {}) + return not isinstance(section, dict) or selection_key not in section + if ts_key == "image_gen": + # Satisfied by the in-tree FAL backend OR any available plugin image gen provider. + return not fal_key_is_configured() and not _any_plugin_provider_available("agent.image_gen_registry") + if ts_key == "video_gen": + # No in-tree fallback — every video backend is a plugin. + return not _any_plugin_provider_available("agent.video_gen_registry") + + return not _toolset_has_keys(ts_key, config, force_fresh=force_fresh) + + +def _any_plugin_provider_available(registry_module: str) -> bool: + """True when any provider in the plugin registry reports ``is_available()``.""" + registry = _plugin_registry(registry_module) + if registry is None: + return False + try: + for provider in registry.list_providers(): + try: + if provider.is_available(): + return True + except Exception: + continue + except Exception: + pass + return False + + +def _configure_tool_category( + ts_key: str, + cat: dict, + config: dict, + *, + force_fresh: bool = True, + reconfigure: bool = False, +): + """Provider selection for a tool category, then API-key setup for the chosen row. + + ``reconfigure`` is the "Reconfigure an existing tool" flow: no setup note / skip row / Nous + marker, and the chosen provider goes through the key-update prompts instead of the new-enable + prompts. + """ + from hermes_cli.tools_config import _prompt_choice, _provider_env_ready, get_nous_subscription_features + + icon = cat.get("icon", "") + name = cat["name"] + providers = _visible_providers(cat, config, force_fresh=force_fresh) + + if len(providers) == 1: + provider = providers[0] + print() + print(color(f" --- {icon} {name} ({provider['name']}) ---", Colors.CYAN)) + if not reconfigure: + if provider.get("tag"): + _print_info(f" {provider['tag']}") + if cat.get("setup_note"): + _print_info(f" {cat['setup_note']}") + _configure_provider(provider, config, force_fresh=force_fresh, reconfigure=reconfigure) + return + + print() + title = "Choose a provider" if reconfigure else cat.get("setup_title", "Choose a provider") + print(color(f" --- {icon} {name} - {title} ---", Colors.CYAN)) + if not reconfigure and cat.get("setup_note"): + _print_info(f" {cat['setup_note']}") + print() + + # Logged-in Nous users get a marker on rows included in their subscription so it is obvious + # which options cost extra vs. nothing on top of Nous. + _nous_logged_in = False + if not reconfigure: + try: + _nous_logged_in = bool(get_nous_subscription_features(config, force_fresh=force_fresh).nous_auth_present) + except Exception: + _nous_logged_in = False + + # Plain text labels only (no ANSI codes in menu items) + provider_choices = [] + for p in providers: + badge = f" [{p['badge']}]" if p.get("badge") else "" + tag = f" — {p['tag']}" if p.get("tag") else "" + configured = "" + if _provider_env_ready(p): + if _is_provider_active(p, config, force_fresh=force_fresh): + configured = " [active]" + elif p.get("env_vars", []): + configured = " [configured]" + # Logged-in subscribers get the "included" star; everyone else a "via Nous Portal" hint + # so it is clear selecting the row triggers a Portal login. + sub_marker = "" + if not reconfigure and p.get("managed_nous_feature"): + if _nous_logged_in: + sub_marker = " ★ Included with your Nous subscription" + else: + sub_marker = " ★ via Nous Portal (login on select)" + provider_choices.append(f"{p['name']}{badge}{tag}{configured}{sub_marker}") + + if not reconfigure: + provider_choices.append("Skip — keep defaults / configure later") + + default_idx = _detect_active_provider_index(providers, config, force_fresh=force_fresh) + + question = " Select provider:" if reconfigure else f" {title}:" + provider_idx = _prompt_choice(question, provider_choices, default_idx) + + if provider_idx >= len(providers): + _print_info(f" Skipped {name}") + return + + _configure_provider(providers[provider_idx], config, force_fresh=force_fresh, reconfigure=reconfigure) + + +def _web_tier_matches(provider: dict, config: dict) -> bool: + """Return True when a web picker row's tier matches the configured tier. + + Tiered rows (Exa/Parallel Free vs Paid) share one ``web_backend`` name and differ only in + ``web_tier``; the configured tier lives at ``web.provider_tier.`` (set on selection). + Rules: no ``web_tier`` on the row → tier-agnostic, matches; configured tier set → must equal the + row's tier; unset → "auto": paid when the row's key is present, free otherwise (highlight the + row the runtime would actually use). + """ + row_tier = provider.get("web_tier") + if not row_tier: + return True + web_cfg = config.get("web") + if not isinstance(web_cfg, dict): + web_cfg = {} + tiers = web_cfg.get("provider_tier") + if not isinstance(tiers, dict): + tiers = {} + configured = str(tiers.get(provider["web_backend"], "") or "").lower().strip() + if configured in ("free", "paid"): + return configured == row_tier + # Auto: mirror plugins.web.keyless_mcp.use_keyless — key present → paid. + try: + from agent.web_search_provider import get_provider_env + + key_var = {"exa": "EXA_API_KEY", "parallel": "PARALLEL_API_KEY"}.get(provider["web_backend"]) + has_key = bool(get_provider_env(key_var)) if key_var else False + except Exception: + has_key = False + return row_tier == ("paid" if has_key else "free") + + +def _managed_provider_active(provider: dict, config: dict, managed_feature: str, force_fresh: bool) -> bool: + """Active check for a Nous-managed row: the feature must be managed AND the category's selected + provider must be the row's vendor or ``nous``.""" + from hermes_cli.tools_config import get_nous_subscription_features + + features = get_nous_subscription_features(config, force_fresh=force_fresh) + feature = features.features.get(managed_feature) + if feature is None: + return False + if managed_feature in ("image_gen", "video_gen"): + gen_cfg = config.get(managed_feature, {}) + if isinstance(gen_cfg, dict): + configured_provider = gen_cfg.get("provider") + if configured_provider not in {None, "", "fal", NOUS_MANAGED_PROVIDER}: + return False + if ( + configured_provider != NOUS_MANAGED_PROVIDER + and gen_cfg.get("use_gateway") is not None + and not is_truthy_value(gen_cfg.get("use_gateway"), default=False) + ): + return False + return feature.managed_by_nous + if provider.get("tts_provider"): + current = cfg_get(config, "tts", "provider") + return feature.managed_by_nous and current in {provider["tts_provider"], NOUS_MANAGED_PROVIDER} + if provider.get("stt_provider"): + current = cfg_get(config, "stt", "provider") + return feature.managed_by_nous and current in {provider["stt_provider"], NOUS_MANAGED_PROVIDER} + if "browser_provider" in provider: + # Browser Use mode is a driver on top of the provider (attaches to its CDP endpoint), so + # the provider row stays active alongside the Browser Use row. + current = cfg_get(config, "browser", "cloud_provider") + return feature.managed_by_nous and current in {provider["browser_provider"], NOUS_MANAGED_PROVIDER} + if provider.get("web_backend"): + current = cfg_get(config, "web", "backend") + return ( + feature.managed_by_nous + and current in {provider["web_backend"], NOUS_MANAGED_PROVIDER} + and _web_tier_matches(provider, config) + ) + return feature.managed_by_nous + + +def _browser_use_default_active(config: dict) -> bool: + """``browser.backend`` unset: Browser Use mode is the default, so the row is active whenever + the effective mode resolves on (legacy direct-API cloud config, or CLI runnable and no Camofox).""" + browser_cfg = config.get("browser") if isinstance(config, dict) else None + try: + from tools.browser_use_cli import _find_cli, is_legacy_browser_use_cloud_config + + if is_legacy_browser_use_cloud_config(browser_cfg or {}): + return True + try: + from tools.browser_camofox import is_camofox_mode + + if is_camofox_mode(): + return False + except Exception: + pass + return _find_cli() is not None + except Exception: + return False + + +def _tts_active(provider: dict, config: dict) -> bool: + return cfg_get(config, "tts", "provider") == provider["tts_provider"] + + +def _stt_active(provider: dict, config: dict) -> bool: + # Default stt.provider is "local" — an unset key means Local Whisper. + return (cfg_get(config, "stt", "provider") or "local") == provider["stt_provider"] + + +def _browser_provider_active(provider: dict, config: dict) -> bool: + # Browser Use mode composes with the provider (driver over its CDP endpoint) — don't + # deactivate the provider row. + if provider["browser_provider"] != cfg_get(config, "browser", "cloud_provider"): + return False + # Two local rows differ only by engine ("Local Browser" vs "Lightpanda"): config.yaml is the + # picker's source of truth here, the AGENT_BROWSER_ENGINE env var is not consulted. + if provider.get("browser_engine"): + engine = str(cfg_get(config, "browser", "engine") or "auto").strip().lower() + return engine == provider["browser_engine"] + return True + + +def _browser_backend_active(provider: dict, config: dict) -> bool: + backend = cfg_get(config, "browser", "backend") + if backend is False: + backend = "off" # YAML 1.1: unquoted `off` parses as boolean False + if backend == provider["browser_backend"]: + return True + if backend: + return False # explicit other choice ("off", …) wins + if provider["browser_backend"] != "browser-use": + return False + return _browser_use_default_active(config) + + +def _web_backend_active(provider: dict, config: dict) -> bool: + if cfg_get(config, "web", "backend") != provider["web_backend"]: + return False + return _web_tier_matches(provider, config) + + +def _computer_use_active(provider: dict, config: dict) -> bool: + return cfg_get(config, "computer_use", "backend") == provider["computer_use_backend"] + + +def _imagegen_backend_active(provider: dict, config: dict) -> bool: + image_cfg = config.get("image_gen", {}) + if not isinstance(image_cfg, dict): + return False + return ( + provider["imagegen_backend"] == "fal" + and image_cfg.get("provider") in {None, "", "fal"} + and not is_truthy_value(image_cfg.get("use_gateway"), default=False) + ) + + +# Non-managed active checks, evaluated in order; the first marker the row carries decides. +# ``browser_provider`` is a membership test (a local row carries ``browser_provider: ""``), +# every other marker is a truthiness test. +_ACTIVE_CHECKS: tuple[tuple[str, Callable[[dict, dict], bool]], ...] = ( + ("tts_provider", _tts_active), + ("stt_provider", _stt_active), + ("browser_provider", _browser_provider_active), + ("browser_backend", _browser_backend_active), + ("web_backend", _web_backend_active), + ("computer_use_backend", _computer_use_active), + ("imagegen_backend", _imagegen_backend_active), +) +_MEMBERSHIP_MARKERS = frozenset({"browser_provider"}) + + +def _is_provider_active( + provider: dict, + config: dict, + *, + force_fresh: bool = False, +) -> bool: + """Check if a provider entry matches the currently active config.""" + managed_feature = provider.get("managed_nous_feature") + plugin_name = provider.get("image_gen_plugin_name") + if plugin_name and not managed_feature: + # Managed entries fall through to the managed branch, which also checks use_gateway — + # otherwise a managed FAL pick and a direct-key FAL pick would both report active. + image_cfg = config.get("image_gen", {}) + if not (isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name): + return False + # A direct-key entry is only active when the managed route is OFF. + return not is_truthy_value(image_cfg.get("use_gateway"), default=False) + + video_plugin_name = provider.get("video_gen_plugin_name") + if video_plugin_name and not managed_feature: + video_cfg = config.get("video_gen", {}) + return isinstance(video_cfg, dict) and video_cfg.get("provider") == video_plugin_name + + if managed_feature: + return _managed_provider_active(provider, config, managed_feature, force_fresh) + + for marker, check in _ACTIVE_CHECKS: + present = marker in provider if marker in _MEMBERSHIP_MARKERS else provider.get(marker) + if present: + return check(provider, config) + return False + + +def _detect_active_provider_index( + providers: list, + config: dict, + *, + force_fresh: bool = False, +) -> int: + """Return the index of the currently active provider, or 0.""" + from hermes_cli.tools_config import _provider_env_ready + + for i, p in enumerate(providers): + if _is_provider_active(p, config, force_fresh=force_fresh): + return i + # Fallback: env vars present → likely configured + if p.get("env_vars", []) and _provider_env_ready(p): + return i + return 0 + + +# ─── Image Generation Model Pickers ─────────────────────────────────────────── +# +# IMAGEGEN_BACKENDS: per-backend catalog (config_key = top-level config.yaml section, catalog_fn -> +# ({model_id: metadata}, default_model)). A TOOL_CATEGORIES row tagged `imagegen_backend: ""` +# selects the catalog at picker time. + + +def _fal_model_catalog(): + """Lazy-load the FAL model catalog from the tool module.""" + from tools.image_generation_tool import FAL_MODELS, DEFAULT_MODEL + return FAL_MODELS, DEFAULT_MODEL + + +IMAGEGEN_BACKENDS = { + "fal": { + "display": "FAL.ai", + "config_key": "image_gen", + "catalog_fn": _fal_model_catalog, + }, +} + + +def _plugin_model_catalog(registry_module: str, plugin_name: str): + """Return ``(catalog_dict, default_model_id)`` for a plugin provider. + + ``catalog_dict`` is shaped like the legacy ``FAL_MODELS`` table so the picker code path is + shared. Returns ``({}, None)`` if the provider is unregistered or has no models. + """ + registry = _plugin_registry(registry_module) + if registry is None: + return {}, None + try: + provider = registry.get_provider(plugin_name) + except Exception: + return {}, None + if provider is None: + return {}, None + try: + models = provider.list_models() or [] + default = provider.default_model() + except Exception: + return {}, None + catalog = {m["id"]: m for m in models if isinstance(m, dict) and "id" in m} + return catalog, default + + +def _plugin_image_gen_catalog(plugin_name: str): + """``(catalog, default_model)`` for a plugin-registered image gen provider.""" + return _plugin_model_catalog("agent.image_gen_registry", plugin_name) + + +def _plugin_video_gen_catalog(plugin_name: str): + """``(catalog, default_model)`` for a plugin-registered video gen provider.""" + return _plugin_model_catalog("agent.video_gen_registry", plugin_name) + + +def _pick_model_from_catalog( + catalog: dict, + default_model, + cfg_key: str, + display: str, + config: dict, + *, + row_indent: str = "", +) -> None: + """Column-aligned model picker shared by the FAL, plugin image gen and video gen flows. + + Writes the choice to ``config[cfg_key]["model"]``. The current model is listed first so the + cursor lands on it; a saved model that belongs to another provider (shared config key) or a + drifted catalog default never indexes the catalog. Safe when stdin is not a TTY — + curses_radiolist keeps the current selection. + """ + from hermes_cli.tools_config import _cfg_section, _prompt_choice + + if not catalog: + return + cur_cfg = _cfg_section(config, cfg_key) + current_model = cur_cfg.get("model") or default_model + if current_model not in catalog: + current_model = default_model if default_model in catalog else next(iter(catalog)) + + model_ids = list(catalog.keys()) + ordered = [current_model] + [m for m in model_ids if m != current_model] + + widths = { + "model": max(len(m) for m in model_ids), + "speed": max((len(catalog[m].get("speed", "")) for m in model_ids), default=6), + "strengths": max((len(catalog[m].get("strengths", "")) for m in model_ids), default=0), + } + + print() + header = ( + f" {'Model':<{widths['model']}} " + f"{'Speed':<{widths['speed']}} " + f"{'Strengths':<{widths['strengths']}} " + f"Price" + ) + print(color(header, Colors.CYAN)) + + rows = [] + for mid in ordered: + meta = catalog[mid] + row = ( + f"{row_indent}{mid:<{widths['model']}} " + f"{meta.get('speed', ''):<{widths['speed']}} " + f"{meta.get('strengths', ''):<{widths['strengths']}} " + f"{meta.get('price', '')}" + ) + if mid == current_model: + row += " ← currently in use" + rows.append(row) + + idx = _prompt_choice(f" Choose {display} model:", rows, default=0) + + chosen = ordered[idx] + cur_cfg["model"] = chosen + _print_success(f" Model set to: {chosen}") + + +def _configure_imagegen_model(backend_name: str, config: dict) -> None: + """Prompt for a model of an in-tree imagegen backend (``IMAGEGEN_BACKENDS``).""" + backend = IMAGEGEN_BACKENDS.get(backend_name) + if not backend: + return + catalog, default_model = backend["catalog_fn"]() + _pick_model_from_catalog(catalog, default_model, backend["config_key"], backend["display"], config) + + +def _configure_imagegen_model_for_plugin(plugin_name: str, config: dict) -> None: + """Prompt for a model from a plugin-registered image gen backend's catalog.""" + catalog, default_model = _plugin_image_gen_catalog(plugin_name) + _pick_model_from_catalog(catalog, default_model, "image_gen", plugin_name, config) + + +def _configure_videogen_model_for_plugin(plugin_name: str, config: dict) -> None: + """Prompt for a video gen model from a plugin's catalog.""" + catalog, default_model = _plugin_video_gen_catalog(plugin_name) + # Video rows historically carry a two-space indent; kept for output parity. + _pick_model_from_catalog(catalog, default_model, "video_gen", plugin_name, config, row_indent=" ") + + +def _configure_xai_imagine_storage(section_name: str, config: dict) -> None: + """Prompt for xAI Imagine stored public URL behavior.""" + from hermes_cli.tools_config import _cfg_section, _prompt_choice + + section = _cfg_section(config, section_name) + xai_cfg = _cfg_section(section, "xai") + storage_cfg = _cfg_section(xai_cfg, "storage") + + _print_warning( + " xAI Imagine can store generated media and create reusable public URLs. " + "xAI may bill for stored files and public URL hosting." + ) + idx = _prompt_choice( + " Stored public URLs:", + [ + "Enable public URLs without automatic expiry (recommended)", + "Disable stored public URLs", + "Enable public URLs for 2 days", + ], + default=0, + ) + if idx == 1: + storage_cfg["enabled"] = False + _print_success(" xAI stored public URLs disabled") + elif idx == 2: + storage_cfg["enabled"] = True + storage_cfg["public_url"] = True + storage_cfg["expires_after"] = 2 * 24 * 60 * 60 + _print_success(" xAI stored public URLs enabled for 2 days") + else: + storage_cfg["enabled"] = True + storage_cfg["public_url"] = True + storage_cfg["expires_after"] = None + _print_success(" xAI stored public URLs enabled without automatic expiry") + + +def _select_plugin_gen_provider(section: str, plugin_name: str, config: dict, *, use_gateway: bool) -> None: + """Persist a plugin-backed image/video generation provider selection and run its model picker. + + ``use_gateway=True`` (Nous-managed pick) stores ``
.provider: nous``; BYOK picks store + the plugin name. Any legacy ``use_gateway`` key is removed so old read-time shims cannot + override the fresh selection. + """ + from hermes_cli.tools_config import _cfg_section + + cfg = _cfg_section(config, section) + cfg["provider"] = NOUS_MANAGED_PROVIDER if use_gateway else plugin_name + cfg.pop("use_gateway", None) + _print_success(f" {section}.provider set to: {cfg['provider']}") + if section == "image_gen": + _configure_imagegen_model_for_plugin(plugin_name, config) + else: + _configure_videogen_model_for_plugin(plugin_name, config) + if plugin_name == "xai": + _configure_xai_imagine_storage(section, config) + + +def _select_plugin_image_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None: + _select_plugin_gen_provider("image_gen", plugin_name, config, use_gateway=use_gateway) + + +def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None: + _select_plugin_gen_provider("video_gen", plugin_name, config, use_gateway=use_gateway) + + +# Per-provider STT model catalogs for the picker; keys are ``stt.`` sections, first entry +# is the default. Kept in sync with the dashboard selects (web_server _CONFIG_FIELD_META) and the +# desktop settings enums (apps/desktop/src/app/settings/constants.ts). +STT_MODEL_CATALOG = { + "local": ["base", "tiny", "small", "medium", "large-v3"], + "groq": ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"], + "openai": ["whisper-1", "gpt-4o-mini-transcribe", "gpt-4o-transcribe", "gpt-transcribe"], + "elevenlabs": ["scribe_v2", "scribe_v1"], +} + +# ElevenLabs historically uses ``model_id`` instead of ``model``. +_STT_MODEL_CONFIG_KEY = {"elevenlabs": "model_id"} + + +def _configure_stt_model(stt_provider: str, config: dict) -> None: + """Prompt for the STT model after a provider pick (when a catalog exists).""" + from hermes_cli.tools_config import _cfg_section, _prompt_choice + + catalog = STT_MODEL_CATALOG.get(stt_provider) + if not catalog: + return + stt_cfg = _cfg_section(config, "stt") + prov_cfg = _cfg_section(stt_cfg, stt_provider) + model_key = _STT_MODEL_CONFIG_KEY.get(stt_provider, "model") + current = str(prov_cfg.get(model_key) or "").strip() + ordered = list(catalog) + default_idx = ordered.index(current) if current in ordered else 0 + idx = _prompt_choice(" Select STT model:", ordered, default_idx) + chosen = ordered[idx] + prov_cfg[model_key] = chosen + _print_success(f" STT model set to: {chosen}") + + +# Provider-row marker key -> config section it selects into. +_PROVIDER_MARKER_SECTIONS = { + "tts_provider": "tts", + "stt_provider": "stt", + "browser_provider": "browser", + "web_backend": "web", + "image_gen_plugin_name": "image_gen", + "imagegen_backend": "image_gen", + "video_gen_plugin_name": "video_gen", +} + + +def _write_provider_config(provider: dict, config: dict, *, managed_feature) -> None: + """Persist the provider/backend config keys for a selected provider. + + Pure, non-interactive core of :func:`_configure_provider`: no env prompts, post-setup hooks, + Nous auth gating or model pickers. Both the CLI and the GUI ``PUT .../provider`` endpoint call + through here so there is one code path. Each pick writes exactly ONE provider string per + category (``nous`` for managed rows) and removes any legacy ``use_gateway`` key so the read-time + shim cannot override the new choice. + """ + from hermes_cli.tools_config import TOOL_CATEGORIES, _cfg_section + + def _drop_use_gateway(section) -> None: + if isinstance(section, dict): + section.pop("use_gateway", None) + + def _set_selection(section_key: str, name_key: str, vendor_value) -> None: + section = _cfg_section(config, section_key) + section[name_key] = NOUS_MANAGED_PROVIDER if managed_feature else vendor_value + _drop_use_gateway(section) + + if provider.get("tts_provider"): + _set_selection("tts", "provider", provider["tts_provider"]) + if provider.get("stt_provider"): + _set_selection("stt", "provider", provider["stt_provider"]) + + if "browser_provider" in provider: + bp = provider["browser_provider"] + browser_cfg = config.setdefault("browser", {}) + if bp or managed_feature: + # Browser Use mode (browser.backend) composes with the provider — switching providers + # keeps the driver choice intact. + _set_selection("browser", "cloud_provider", bp) + else: + browser_cfg.pop("use_gateway", None) + if provider.get("browser_backend"): + config.setdefault("browser", {})["backend"] = provider["browser_backend"] + # Local engine rows ("Local Browser" resets to auto, "Lightpanda" sets lightpanda). Composes + # with browser.backend like the provider does. + if provider.get("browser_engine"): + config.setdefault("browser", {})["engine"] = provider["browser_engine"] + + if provider.get("web_backend"): + _set_selection("web", "backend", provider["web_backend"]) + web_cfg = config.get("web") + if isinstance(web_cfg, dict): + if provider.get("web_tier"): + tiers = web_cfg.setdefault("provider_tier", {}) + if isinstance(tiers, dict): + tiers[provider["web_backend"]] = provider["web_tier"] + else: + stale_tiers = web_cfg.get("provider_tier") + if isinstance(stale_tiers, dict): + stale_tiers.pop(provider["web_backend"], None) + + if provider.get("computer_use_backend"): + config.setdefault("computer_use", {})["backend"] = provider["computer_use_backend"] + + if managed_feature and managed_feature not in {"web", "tts", "stt", "browser"}: + # Managed rows for categories without a marker above (image_gen/video_gen "Nous + # Subscription" rows carry only managed_nous_feature) still persist the "nous" selection. + section = config.setdefault(managed_feature, {}) + if isinstance(section, dict): + section["provider"] = NOUS_MANAGED_PROVIDER + _drop_use_gateway(section) + elif not managed_feature: + # Non-gateway pick — clear any stale legacy use_gateway key on the category so the + # read-time shim cannot override it. Resolve the category from the row's own markers first + # (plugin-injected rows are NOT in TOOL_CATEGORIES' hardcoded lists), then fall back to the + # category-membership walk. + sections = [section_key for marker, section_key in _PROVIDER_MARKER_SECTIONS.items() if marker in provider] + if not sections: + sections = [cat_key for cat_key, cat in TOOL_CATEGORIES.items() if provider in cat.get("providers", [])][:1] + for section_key in sections: + _drop_use_gateway(config.get(section_key)) + + +def apply_provider_selection(ts_key: str, provider_name: str, config: dict) -> None: + """Non-interactively persist a provider selection for a toolset. + + Resolves ``provider_name`` among the rows the picker shows (:func:`_visible_providers`) and + writes the config keys only — API keys, post-setup hooks, auth gating and model pickers are + handled by separate GUI endpoints. Raises ``KeyError`` for an unknown toolset or provider. + """ + from hermes_cli.tools_config import TOOL_CATEGORIES, _cfg_section + + cat = TOOL_CATEGORIES.get(ts_key) + if cat is None: + raise KeyError(f"Toolset has no configurable category: {ts_key}") + + providers = _visible_providers(cat, config, force_fresh=True) + provider = next((p for p in providers if p.get("name") == provider_name), None) + if provider is None: + raise KeyError(f"Unknown provider {provider_name!r} for toolset {ts_key!r}") + + managed_feature = provider.get("managed_nous_feature") + _write_provider_config(provider, config, managed_feature=managed_feature) + + # Plugin image/video gen backends record the provider name in their own section (model choice + # is a separate GUI flow); managed picks store "nous". The in-tree FAL BYOK row always persists + # an explicit ``image_gen.provider: fal`` so a deliberate pick is distinguishable from a + # never-configured install. + selections = [ + ("image_gen", provider.get("image_gen_plugin_name")), + ("video_gen", provider.get("video_gen_plugin_name")), + ("image_gen", "fal" if provider.get("imagegen_backend") and not managed_feature else None), + ] + for section_key, vendor in selections: + if vendor: + section = _cfg_section(config, section_key) + section["provider"] = NOUS_MANAGED_PROVIDER if managed_feature else vendor + section.pop("use_gateway", None) + + +def _nous_provider_gate(provider: dict, config: dict, managed_feature, *, force_fresh: bool) -> bool: + """Return False (after printing why) when a Nous-gated row cannot be selected. + + Managed Tool Gateway rows are always listed (see ``_visible_providers``) but only *activate* + with paid Nous Portal access — selecting one runs an inline Portal login (auth + entitlement + only, no inference-provider switch). Pure pre-auth UX rows (``requires_nous_auth`` without a + managed feature) keep the older logged-in + entitled gate. + """ + from hermes_cli.tools_config import get_nous_subscription_features + + if managed_feature: + from hermes_cli.nous_subscription import ensure_nous_portal_access + + if not ensure_nous_portal_access( + capability=f"{provider.get('name', 'the Nous Tool Gateway')}", + coverage_category=MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature), + ): + _print_warning(" Not enabled — Nous Portal access is required for this backend.") + return False + return True + + if provider.get("requires_nous_auth"): + features = get_nous_subscription_features(config, force_fresh=force_fresh) + entitled = bool(features.account_info and features.account_info.paid_service_access is True) + if not features.nous_auth_present or not entitled: + message = format_nous_portal_entitlement_message( + features.account_info, + capability=f"{provider.get('name', 'Nous Subscription')}", + ) + _print_warning(f" {message or 'Nous Subscription is only available after logging into Nous Portal.'}") + return False + return True + + +def _finish_provider_selection(provider: dict, config: dict, managed_feature) -> None: + """Model pickers that follow a provider pick: plugin image/video gen, in-tree FAL, STT.""" + from hermes_cli.tools_config import _cfg_section + + plugin_name = provider.get("image_gen_plugin_name") + if plugin_name: + _select_plugin_image_gen_provider(plugin_name, config, use_gateway=bool(managed_feature)) + return + video_plugin = provider.get("video_gen_plugin_name") + if video_plugin: + _select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature)) + return + backend = provider.get("imagegen_backend") + if backend: + _configure_imagegen_model(backend, config) + # In-tree FAL is the only non-plugin backend. Persist "nous" for a managed row, "fal" for + # BYOK, and drop legacy use_gateway — never clobber a managed pick back onto direct keys. + img_cfg = _cfg_section(config, "image_gen") + img_cfg["provider"] = NOUS_MANAGED_PROVIDER if managed_feature else "fal" + img_cfg.pop("use_gateway", None) + # STT rows prompt for a model after the pick (skipped for managed rows — the gateway pins it). + if provider.get("stt_provider") and not managed_feature: + _configure_stt_model(provider["stt_provider"], config) + + +def _print_provider_selection(provider: dict, managed_feature, *, reconfigure: bool) -> None: + """Status lines announcing which backend/provider keys a pick writes.""" + if reconfigure and provider.get("tts_provider"): + _print_success(f" TTS provider set to: {provider['tts_provider']}") + if provider.get("stt_provider"): + _print_success(f" STT provider set to: {provider['stt_provider']}") + if "browser_provider" in provider: + bp = provider["browser_provider"] + if reconfigure and managed_feature: + _print_success(f" Browser cloud provider set to: {bp or 'nous'}") + elif bp == "local": + _print_success(" Browser set to local mode") + elif bp: + _print_success(f" Browser cloud provider set to: {bp}") + if provider.get("browser_backend"): + _print_success(" Browser set to Browser Use (browser_exec via CLI 3.0)") + if provider.get("browser_engine") and provider["browser_engine"] != "auto": + _print_success(f" Browser engine set to: {provider['browser_engine']}") + if provider.get("web_backend"): + if reconfigure and provider.get("web_tier"): + _print_success(f" Web backend set to: {provider['web_backend']} ({provider['web_tier']} tier)") + else: + _print_success(f" Web backend set to: {provider['web_backend']}") + if reconfigure and provider.get("computer_use_backend"): + _print_success(f" Computer Use backend set to: {provider['computer_use_backend']}") + + +def _show_portal_hint(provider: dict, config: dict, managed_feature, force_fresh: bool) -> bool: + """True when a BYOK row shares its category with a Nous-managed sibling and the user is not + authed to Nous — a single dim hint tells them the key is avoidable via a Portal subscription.""" + from hermes_cli.tools_config import TOOL_CATEGORIES, get_nous_subscription_features + + if managed_feature or provider.get("requires_nous_auth"): + return False + try: + for _cat in TOOL_CATEGORIES.values(): + _providers = _cat.get("providers", []) + if provider in _providers and any(sib.get("managed_nous_feature") for sib in _providers): + return not get_nous_subscription_features(config, force_fresh=force_fresh).nous_auth_present + except Exception: + pass + return False + + +def _prompt_env_vars(env_vars: list, *, reconfigure: bool) -> bool: + """Prompt for a provider's env vars; returns True when every key ended up configured. + + Reconfigure mode re-prompts every key ("Enter to keep current") and always returns True; the + new-enable flow keeps already-set keys without asking and reports False on any skipped key. + """ + all_configured = True + for var in env_vars: + existing = get_env_value(var["key"]) + if reconfigure: + if existing: + _print_info(f" {var['key']}: configured ({existing[:8]}...)") + url = var.get("url", "") + if url: + _print_info(f" Get yours at: {url}") + default_val = var.get("default", "") + value = _prompt(f" {var.get('prompt', var['key'])} (Enter to keep current)", password=not default_val) + if value and value.strip(): + save_env_value(var["key"], value.strip()) + _print_success(" Updated") + else: + _print_info(" Kept current") + continue + if existing: + _print_success(f" {var['key']}: already configured") + continue + url = var.get("url", "") + if url: + _print_info(f" Get yours at: {url}") + default_val = var.get("default", "") + if default_val: + value = _prompt(f" {var.get('prompt', var['key'])}", default_val) + else: + value = _prompt(f" {var.get('prompt', var['key'])}", password=True) + if value: + save_env_value(var["key"], value) + _print_success(" Saved") + else: + _print_warning(" Skipped") + all_configured = False + return all_configured + + +def _configure_provider( + provider: dict, + config: dict, + *, + force_fresh: bool = True, + reconfigure: bool = False, +): + """Configure a single provider - prompt for API keys and set config. + + ``reconfigure=False`` is the new-enable flow: already-set keys are kept without asking, and + the post-setup hook only runs when every key was provided. ``reconfigure=True`` re-prompts + every key ("Enter to keep current") and always runs the post-setup hook. + """ + from hermes_cli.tools_config import _run_post_setup + + env_vars = provider.get("env_vars", []) + managed_feature = provider.get("managed_nous_feature") + + if not _nous_provider_gate(provider, config, managed_feature, force_fresh=force_fresh): + return + + _print_provider_selection(provider, managed_feature, reconfigure=reconfigure) + # Shared with the GUI provider-select endpoint (apply_provider_selection): single source of + # truth for the config-key writes. + _write_provider_config(provider, config, managed_feature=managed_feature) + + if not env_vars: + if provider.get("post_setup"): + _run_post_setup(provider["post_setup"]) + _print_success(f" {provider['name']} - no configuration needed!") + if managed_feature: + _print_info(" Requests for this tool will be billed to your Nous subscription.") + _finish_provider_selection(provider, config, managed_feature) + return + + if not reconfigure and _show_portal_hint(provider, config, managed_feature, force_fresh): + _print_info(" Available through Nous Portal subscription.") + + all_configured = _prompt_env_vars(env_vars, reconfigure=reconfigure) + + if provider.get("post_setup") and all_configured: + _run_post_setup(provider["post_setup"]) + if all_configured: + if not reconfigure: + _print_success(f" {provider['name']} configured!") + _finish_provider_selection(provider, config, managed_feature) + + +def _reconfigure_provider(provider: dict, config: dict, *, force_fresh: bool = True): + """Reconfigure a provider - update API keys.""" + _configure_provider(provider, config, force_fresh=force_fresh, reconfigure=True) + + +def _configure_vision_backend() -> None: + """Interactive vision-backend configuration. + + Vision resolves from ``auxiliary.vision.{provider,model,base_url}``. Rather than forcing + OpenRouter, offer any authenticated provider + model (same surface as ``hermes model``) or a + custom endpoint. "Auto" leaves the keys empty so the resolver uses the main-model fallback + chain. + """ + from hermes_cli.tools_config import _cfg_section, _prompt_choice + + print() + print(color(" Vision / Image Analysis needs a multimodal model.", Colors.YELLOW)) + print(color(" Pick any provider + model (like /model), or let it auto-detect.", Colors.DIM)) + + choices = [ + "Auto — use your main model / aggregator fallback (recommended)", + "Pick a provider and model", + "Custom OpenAI-compatible endpoint — base URL, API key, model", + "Skip", + ] + idx = _prompt_choice(" Configure vision backend", choices, 0) + + config = load_config() + vision_cfg = _cfg_section(_cfg_section(config, "auxiliary"), "vision") + + if idx == 0: + # Auto: clear any pinned override so the resolver auto-detects. + for key in ("provider", "model", "base_url", "api_key", "api_mode"): + vision_cfg.pop(key, None) + save_config(config) + _print_success(" Vision set to auto (main model / aggregator fallback)") + return + + if idx == 1: + _configure_vision_provider_model(config, vision_cfg) + return + + if idx == 2: + base_url = _prompt(" Base URL (blank for OpenAI)").strip() or "https://api.openai.com/v1" + is_native_openai = base_url_hostname(base_url) == "api.openai.com" + key_label = " OPENAI_API_KEY" if is_native_openai else " API key" + api_key = _prompt(key_label, password=True) + if not (api_key and api_key.strip()): + _print_warning(" Skipped") + return + default_model = "gpt-4o-mini" if is_native_openai else "" + model = _prompt(f" Vision model{f' (blank for {default_model})' if default_model else ''}").strip() or default_model + save_env_value("OPENAI_API_KEY", api_key.strip()) + # Only base_url + model go to config.yaml; the key is the secret. Pin provider="custom" so + # the resolver routes through this endpoint — at the "auto" default + # _resolve_task_provider_model ignores base_url unless paired with a config api_key. + vision_cfg["provider"] = "custom" + vision_cfg["base_url"] = base_url + if model: + vision_cfg["model"] = model + else: + vision_cfg.pop("model", None) + save_config(config) + _print_success(f" Vision set to custom endpoint{f' ({model})' if model else ''}") + return + + # Skip + _print_info(" Skipped vision configuration") + + +def _configure_vision_provider_model(config: dict, vision_cfg: dict) -> None: + """Provider + model picker for vision, mirroring the ``/model`` surface. + + Rows come from ``build_aux_picker_rows()`` so this lists exactly what the ``hermes model`` + aux-task picker lists, including user-defined ``providers:`` / ``custom_providers:`` endpoints. + Persists ``auxiliary.vision.provider`` + ``.model``. + """ + from hermes_cli.tools_config import _prompt_choice + + try: + from hermes_cli.inventory import build_aux_picker_rows, format_aux_picker_entries + except Exception as exc: # pragma: no cover - import guard + _print_warning(f" Could not load provider list: {exc}") + return + + current_provider = str(vision_cfg.get("provider") or "").strip() + current_model = str(vision_cfg.get("model") or "").strip() + current_base_url = str(vision_cfg.get("base_url") or "").strip() + + try: + providers = build_aux_picker_rows( + current_provider=current_provider, + current_model=current_model, + current_base_url=current_base_url, + max_models=40, + ) + except Exception as exc: + _print_warning(f" Could not detect providers: {exc}") + providers = [] + + if not providers: + _print_warning( + " No authenticated providers found. Configure a provider first " + "with `hermes model`, then re-run this." + ) + return + + provider_labels = [ + label + for _slug, label, _models in format_aux_picker_entries( + providers, + current_provider=current_provider, + current_base_url=current_base_url, + ) + ] + provider_labels.append("Cancel") + + pidx = _prompt_choice(" Choose vision provider:", provider_labels, 0) + if pidx >= len(providers): + _print_info(" Cancelled") + return + + chosen = providers[pidx] + slug = chosen.get("slug") + models = list(chosen.get("models", [])) + + model_choices = list(models) + ["Type a custom model id…"] + midx = _prompt_choice(f" Choose vision model for {chosen.get('name') or slug}:", model_choices, 0) + if midx < len(models): + model = models[midx] + else: + model = _prompt(" Model id").strip() + if not model: + _print_warning(" No model entered — cancelled") + return + + vision_cfg["provider"] = slug + vision_cfg["model"] = model + # A provider selection supersedes any prior custom endpoint override. + vision_cfg.pop("base_url", None) + vision_cfg.pop("api_key", None) + save_config(config) + _print_success(f" Vision set to {slug} / {model}") + + +def _configure_simple_requirements(ts_key: str, *, reconfigure: bool = False): + """Fallback for toolsets that just need env vars (no provider selection). + + Vision has its own provider/model picker (any provider, like ``hermes model``) — run it + directly so neither flow falls back to the generic single-key prompt (which would re-ask for + OPENROUTER_API_KEY). + """ + from hermes_cli.tools_config import TOOLSET_ENV_REQUIREMENTS, _toolset_has_keys, _toolset_label + + if ts_key == "vision": + if reconfigure or not _toolset_has_keys("vision"): + _configure_vision_backend() + return + + requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, []) + if not reconfigure: + requirements = [(var, url) for var, url in requirements if not get_env_value(var)] + if not requirements: + return + + ts_label = _toolset_label(ts_key) + print() + if reconfigure: + print(color(f" {ts_label}:", Colors.CYAN)) + else: + print(color(f" {ts_label} requires configuration:", Colors.YELLOW)) + + for var, url in requirements: + if reconfigure: + existing = get_env_value(var) + if existing: + _print_info(f" {var}: configured ({existing[:8]}...)") + if url: + _print_info(f" Get key at: {url}") + suffix = " (Enter to keep current)" if reconfigure else "" + value = _prompt(f" {var}{suffix}", password=True) + if value and value.strip(): + save_env_value(var, value.strip()) + _print_success(" Updated" if reconfigure else " Saved") + else: + if reconfigure: + _print_info(" Kept current") + else: + _print_warning(" Skipped") + + diff --git a/tests/hermes_cli/test_imagegen_managed_gateway.py b/tests/hermes_cli/test_imagegen_managed_gateway.py index 7daf364112..e9ddc67018 100644 --- a/tests/hermes_cli/test_imagegen_managed_gateway.py +++ b/tests/hermes_cli/test_imagegen_managed_gateway.py @@ -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 diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index ce19955cc8..9137ded6e6 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -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() diff --git a/tests/hermes_cli/test_video_gen_picker.py b/tests/hermes_cli/test_video_gen_picker.py index c740ef3942..92ea7e8e90 100644 --- a/tests/hermes_cli/test_video_gen_picker.py +++ b/tests/hermes_cli/test_video_gen_picker.py @@ -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 "", ) From 3f0e1165b382754daeefe859d314d86a84dda56f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:11:26 -0700 Subject: [PATCH 3/9] refactor(tools_config): extract post-setup hooks into tools_config_post_setup --- hermes_cli/tools_config.py | 656 +----------------- hermes_cli/tools_config_post_setup.py | 652 +++++++++++++++++ .../test_lazy_refresh_venv_repair.py | 4 +- tests/hermes_cli/test_post_setup_gating.py | 8 +- tests/hermes_cli/test_tools_config.py | 46 +- ...est_web_routers_tools_install_on_enable.py | 12 +- tests/hermes_cli/test_web_server.py | 4 +- 7 files changed, 719 insertions(+), 663 deletions(-) create mode 100644 hermes_cli/tools_config_post_setup.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 24f9f6375e..afaf100dfc 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -80,6 +80,33 @@ from hermes_cli.tools_config_cua import ( # noqa: F401 — re-exported for herm _print_cua_platform_notes, _run_cua_driver_installer, ) +from hermes_cli.tools_config_post_setup import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches + _ensure_browser_use_cli, + _post_setup_lightpanda, + _post_setup_agent_browser, + _post_setup_camofox, + _KITTENTTS_WHEEL_URL, + _PIP_POST_SETUP_HOOKS, + _post_setup_pip, + _post_setup_spotify, + _post_setup_langfuse, + _post_setup_xai_grok, + _POST_SETUP_HOOKS, + _run_post_setup, + valid_post_setup_keys, + run_post_setup_command, + _POST_SETUP_INSTALLED, + _post_setup_already_installed, + _module_installed, + _RESTORABLE_PYTHON_TOOL_DEPENDENCIES, + active_restorable_python_tool_dependencies, + restorable_python_tool_dependency, + _agent_browser_installed, + _camofox_installed, + _lightpanda_installed, + _cloud_agent_browser_installed, + _POST_SETUP_READY, +) # ─── Toolset Registry ───────────────────────────────────────────────────────── @@ -590,499 +617,6 @@ TOOLSET_ENV_REQUIREMENTS = { } -# ─── Post-Setup Hooks ───────────────────────────────────────────────────────── - - -def _ensure_browser_use_cli(*, verbose_hints: bool = False) -> None: - """Install the Browser Use CLI if it isn't already runnable. - - The Browser Use CLI 3.0 is the primary driver engine for EVERY browser backend except Camofox - (which is Firefox-based with no CDP surface, so the CDP-only browser-use harness cannot drive - it). - - 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 drives Lightpanda directly (Hermes spawns - # ``lightpanda serve``); the built-in tools go through agent-browser. - # Neither needs a Chromium build. - _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 _post_setup_agent_browser(post_setup_key: str) -> None: - """``agent_browser`` (local Chromium) and ``browserbase`` (cloud rows) hooks. - - agent-browser is no longer a root package.json dependency (#43564) — it resolves lazily via npx - (or a global/Hermes-managed install), so there is no ``npm install`` step here. - """ - # Every non-Camofox browser backend drives through the Browser Use - # CLI when it's runnable — install it here too, not only on the - # explicit "Browser Use" picker row. - _ensure_browser_use_cli() - try: - # Import lazily so the tools_config UI doesn't pull in the full - # browser_tool module 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 same resolution cascade browser tools use at runtime - # (PATH -> Homebrew/Hermes-managed node -> npx) rather than a bare - # shutil.which — Hermes-managed-Node-only setups resolve agent-browser - # / npx only through the extended fallback path. - 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 browser provider actually needs Chromium on disk. Cloud - # providers (Browserbase, Browser Use, Firecrawl) host their own Chromium. - if post_setup_key != "agent_browser": - return - - # Ensure the Chromium / headless-shell build agent-browser drives is - # installed. Without it the CLI hangs on first use until the command - # timeout fires. Skip inside Docker — the image bakes Chromium in at - # build time, and runtime users usually can't write to - # PLAYWRIGHT_BROWSERS_PATH anyway. - 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 via the same PATH + extended-PATH 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"] - - _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" result so subsequent - # check_browser_requirements() calls see the new 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_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 .cmd shim executes on Windows. - result = subprocess.run( - # --workspaces=false avoids resolving apps/desktop. See #38772. - [_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 (already installed → skip the install) -# label package name used in status lines -# installing progress line printed before the install -# args _pip_install arguments (keep in sync with -# _RESTORABLE_PYTHON_TOOL_DEPENDENCIES) -# manual the "Run manually:" command shown on failure/timeout -# on_install info lines printed only after a fresh successful install -# always info lines printed whenever the package ends up 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: - # Run the full `hermes auth spotify` flow — if the user has no - # client_id yet, this drops them into the interactive wizard - # (opens the Spotify dashboard, prompts for client_id, persists - # to ~/.hermes/.env), then continues straight into PKCE. If they - # already have an app, it skips the wizard and just does OAuth. - 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; they can retry with `hermes auth spotify`. - _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: - # Install the langfuse SDK. - 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") - # Opt the bundled observability/langfuse plugin into plugins.enabled. - # The plugin ships in the repo but doesn't load until the user enables - # it (standalone plugins are opt-in). - 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. - - Collected from ``TOOL_CATEGORIES`` plus plugin-registered web/image/video/browser providers. - This is the allowlist the ``post-setup`` command and dashboard endpoint validate against, so a - caller cannot drive ``_run_post_setup`` with an arbitrary key. - """ - 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) - # Plugin-registered providers can declare their own post_setup hooks. - 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 `` — non-interactive post-setup runner. - - Stable, scriptable target the dashboard spawns so the GUI can drive backend setup without - re-implementing install logic. Returns a process exit code (0 ok, 2 unknown key). - """ - key = getattr(args, "post_setup_key", None) - if not key: - _print_error("Usage: hermes tools post-setup ") - return 2 - valid = valid_post_setup_keys() - if key not in valid: - _print_error( - f"Unknown post-setup key: {key!r}. " - f"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 - # ─── Platform / Toolset Helpers ─────────────────────────────────────────────── @@ -2003,142 +1537,6 @@ def _visible_providers( return visible -_POST_SETUP_INSTALLED: dict = { - # post_setup_key -> predicate(): True when the install side-effect - # is already satisfied. Used by `_toolset_needs_configuration_prompt` - # to force the provider-setup flow when a no-key provider still needs - # a binary/dependency install (otherwise an already-configured user - # who toggles the toolset on via `hermes tools` gets a silent no-op - # because the gate sees "no env vars to ask about" and skips the - # provider-setup flow that would have run the post_setup hook). - # - # Only entries here are gated; other post_setup hooks (kittentts, - # piper, agent_browser, etc.) keep their existing behaviour. Add an - # entry when (a) the post_setup is the ONLY install side-effect for - # a no-key provider, and (b) an installed-state check is local, bounded, - # and doesn't trigger a heavy import. - "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.""" - predicate = _POST_SETUP_INSTALLED.get(post_setup_key) - if predicate is None: - # No install-state check registered → assume satisfied (don't - # change behaviour for hooks we haven't explicitly opted in). - 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 dependencies installed explicitly through ``hermes tools`` are not -# part of the managed runtime's locked ``all`` sync. A runtime replacement -# therefore needs a small, static allowlist that can be snapshotted before the -# old site-packages disappears and restored afterward. Keep these install -# arguments in sync with the corresponding ``_run_post_setup`` branches. -_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). - Mirrors the hook so "Run setup" flips to an installed state only when re-running it would be a - no-op. - """ - from hermes_cli.nous_subscription import _local_browser_runnable - - # The install hook runs in a spawned ``hermes tools post-setup`` process, - # but this probe runs in the long-lived web-server/CLI process, whose - # browser_tool module may have cached a stale "Chromium missing" result - # from before the install. Drop the cache (when the module is loaded) so - # the readiness pill flips to Ready right after a successful setup run. - 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 already in node_modules.""" - return (PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser").exists() - - -# post_setup_key -> predicate(): True when the install side-effect is already -# satisfied. Used by ``provider_readiness_status`` to decide whether a keyless -# post_setup row (KittenTTS, Piper, Local Browser, …) is honestly "ready" or -# still "needs_setup". Mirrors the installed-checks ``_run_post_setup`` itself -# performs before installing. ``xai_grok`` is intentionally absent — it is a -# credential bootstrap, not an install, and is handled as an auth check. -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 provider rows). - - Cloud providers host their own Chromium, so their hook only installs the agent-browser npm - package — presence of the CLI is the whole contract. - """ - from hermes_cli.nous_subscription import _has_agent_browser - - return _has_agent_browser() - - -# 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(), -} - def provider_readiness_status( provider: dict, diff --git a/hermes_cli/tools_config_post_setup.py b/hermes_cli/tools_config_post_setup.py new file mode 100644 index 0000000000..6305595ec5 --- /dev/null +++ b/hermes_cli/tools_config_post_setup.py @@ -0,0 +1,652 @@ +"""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() + + +# ─── Post-Setup Hooks ───────────────────────────────────────────────────────── + + +def _ensure_browser_use_cli(*, verbose_hints: bool = False) -> None: + """Install the Browser Use CLI if it isn't already runnable. + + The Browser Use CLI 3.0 is the primary driver engine for EVERY browser backend except Camofox + (which is Firefox-based with no CDP surface, so the CDP-only browser-use harness cannot drive + it). + + 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 drives Lightpanda directly (Hermes spawns + # ``lightpanda serve``); the built-in tools go through agent-browser. + # Neither needs a Chromium build. + _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 when runnable — install it here + # too, not only on the explicit "Browser Use" picker row. + _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 (already installed → skip the install) +# label package name used in status lines +# installing progress line printed before the install +# args _pip_install arguments (keep in sync with +# _RESTORABLE_PYTHON_TOOL_DEPENDENCIES) +# manual the "Run manually:" command shown on failure/timeout +# on_install info lines printed only after a fresh successful install +# always info lines printed whenever the package ends up 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: + # Run the full `hermes auth spotify` flow — if the user has no + # client_id yet, this drops them into the interactive wizard + # (opens the Spotify dashboard, prompts for client_id, persists + # to ~/.hermes/.env), then continues straight into PKCE. If they + # already have an app, it skips the wizard and just does OAuth. + 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; they can retry with `hermes auth spotify`. + _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: + # Install the langfuse SDK. + 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") + # Opt the bundled observability/langfuse plugin into plugins.enabled. + # The plugin ships in the repo but doesn't load until the user enables + # it (standalone plugins are opt-in). + 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. + + Collected from ``TOOL_CATEGORIES`` plus plugin-registered web/image/video/browser providers. + This is the allowlist the ``post-setup`` command and 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) + # Plugin-registered providers can declare their own post_setup hooks. + 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 `` — non-interactive post-setup runner. + + Stable, scriptable target the dashboard spawns so the GUI can drive backend setup without + re-implementing install logic. Returns a process exit code (0 ok, 2 unknown key). + """ + key = getattr(args, "post_setup_key", None) + if not key: + _print_error("Usage: hermes tools post-setup ") + return 2 + valid = valid_post_setup_keys() + if key not in valid: + _print_error( + f"Unknown post-setup key: {key!r}. " + f"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 is a silent no-op that 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; other hooks keep their existing behaviour. +_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.""" + predicate = _POST_SETUP_INSTALLED.get(post_setup_key) + if predicate is None: + # No install-state check registered → assume satisfied (don't + # change behaviour for hooks we haven't explicitly opted in). + 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 dependencies installed explicitly through ``hermes tools`` are not +# part of the managed runtime's locked ``all`` sync. A runtime replacement +# therefore needs a small, static allowlist that can be snapshotted before the +# old site-packages disappears and restored afterward. Keep these install +# arguments in sync with the corresponding ``_run_post_setup`` branches. +_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). + Mirrors the hook so "Run setup" flips to an installed state only when re-running it would be a + no-op. + """ + from hermes_cli.nous_subscription import _local_browser_runnable + + # The install hook runs in a spawned ``hermes tools post-setup`` process, + # but this probe runs in the long-lived web-server/CLI process, whose + # browser_tool module may have cached a stale "Chromium missing" result + # from before the install. Drop the cache (when the module is loaded) so + # the readiness pill flips to Ready right after a successful setup run. + 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 already 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 provider rows). + + Cloud providers host their own Chromium, so their hook only installs the agent-browser npm + package — presence of the 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(), +} + diff --git a/tests/hermes_cli/test_lazy_refresh_venv_repair.py b/tests/hermes_cli/test_lazy_refresh_venv_repair.py index a7e7ff9a90..f71c82515c 100644 --- a/tests/hermes_cli/test_lazy_refresh_venv_repair.py +++ b/tests/hermes_cli/test_lazy_refresh_venv_repair.py @@ -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"}, ) diff --git a/tests/hermes_cli/test_post_setup_gating.py b/tests/hermes_cli/test_post_setup_gating.py index 713df383ec..8b625b8d95 100644 --- a/tests/hermes_cli/test_post_setup_gating.py +++ b/tests/hermes_cli/test_post_setup_gating.py @@ -28,18 +28,18 @@ class TestPostSetupGate: ) 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", {} diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index ce19955cc8..76de5169e6 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -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) @@ -1251,15 +1251,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 +1270,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) diff --git a/tests/hermes_cli/test_web_routers_tools_install_on_enable.py b/tests/hermes_cli/test_web_routers_tools_install_on_enable.py index f4d840d6a2..dea6df2922 100644 --- a/tests/hermes_cli/test_web_routers_tools_install_on_enable.py +++ b/tests/hermes_cli/test_web_routers_tools_install_on_enable.py @@ -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): diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 9f7a6d810a..776ada2cc9 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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") From ffed5b15036c1255322f15f4714641ac50ce1fb3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:14:49 -0700 Subject: [PATCH 4/9] refactor(tools_config): split tools_command into flow helpers; drop unused imports --- hermes_cli/tools_config.py | 321 +++++++++------------ tests/hermes_cli/test_post_setup_gating.py | 2 +- 2 files changed, 139 insertions(+), 184 deletions(-) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index eccbed0454..30e8fa0c36 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -3,31 +3,24 @@ import json as _json import logging import os -import shutil -import subprocess -import sys from pathlib import Path from typing import Dict, List, Optional, Set from hermes_cli.config import ( cfg_get, - load_config, save_config, get_env_value, save_env_value, + load_config, save_config, get_env_value, ) from hermes_cli.colors import Colors, color from hermes_cli.nous_subscription import ( - MANAGED_FEATURE_COVERAGE_CATEGORY, NousSubscriptionFeatures, apply_nous_managed_defaults, get_nous_subscription_features, ) -from hermes_cli.nous_account import format_nous_portal_entitlement_message from hermes_cli.toolset_scope import ( _TOOLSET_PLATFORM_RESTRICTIONS, toolset_allowed_for_platform as _toolset_allowed_for_platform, ) -from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, fal_key_is_configured -from utils import base_url_hostname, is_truthy_value logger = logging.getLogger(__name__) @@ -47,7 +40,6 @@ from hermes_cli.cli_output import ( # noqa: E402 — late import block print_info as _print_info, print_success as _print_success, print_warning as _print_warning, - prompt as _prompt, ) from hermes_cli.tools_config_cua import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches _post_setup_no_window_flags, @@ -1520,6 +1512,122 @@ def _platform_menu_label(config: dict, pkey: str) -> str: return f"Configure {PLATFORMS[pkey]['label']} ({count}/{total} enabled)" +def _print_tools_summary(config: dict, enabled_platforms: List[str]) -> None: + """``hermes tools --summary``: enabled toolsets per platform, non-interactive.""" + total = len(_get_effective_configurable_toolsets()) + print(color("⚕ Tool Summary", Colors.CYAN, Colors.BOLD)) + print() + summary = _platform_toolset_summary(config, enabled_platforms) + for pkey in enabled_platforms: + enabled = summary.get(pkey, set()) + print(color(f" {PLATFORMS[pkey]['label']}", Colors.BOLD) + color(f" ({len(enabled)}/{total})", Colors.DIM)) + if enabled: + for ts_key in sorted(enabled): + print(color(f" ✓ {_toolset_label(ts_key)}", Colors.GREEN)) + else: + print(color(" (none enabled)", Colors.DIM)) + print() + + +def _configure_list(to_configure: List[str], config: dict, *, selected: bool = True) -> None: + """Announce then configure each toolset in ``to_configure``.""" + if not to_configure: + return + print() + what = "selected tool(s)" if selected else "tool(s)" + print(color(f" Configuring {len(to_configure)} {what}:", Colors.YELLOW)) + for ts_key in to_configure: + print(color(f" • {_toolset_label(ts_key)}", Colors.DIM)) + print(color(" You can skip any tool you don't need right now.", Colors.DIM)) + print() + for ts_key in to_configure: + _configure_toolset(ts_key, config) + + +def _checklist_diff(new_enabled: Set[str], prev: Set[str], platform: str) -> tuple[Set[str], Set[str]]: + """``(added, removed)`` scoped to the checklist's universe. + + The resolved ``prev`` can include non-configurable toolsets (``kanban``, recovered platform + composites) the user was never shown a checkbox for; without this scope the summary would print + spurious ``- kanban`` removals even though the config keeps them (see _checklist_toolset_keys). + """ + universe = _checklist_toolset_keys(platform) + return (new_enabled - prev) & universe, (prev - new_enabled) & universe + + +def _first_install_flow(config: dict, enabled_platforms: List[str]) -> None: + """Fresh install: one checklist per platform, no menu, keys prompted for every enabled tool.""" + for pkey in enabled_platforms: + pinfo = PLATFORMS[pkey] + current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False) + new_enabled = _prompt_toolset_checklist(pinfo["label"], current_enabled - _DEFAULT_OFF_TOOLSETS, pkey) + _print_toolset_diff(*_checklist_diff(new_enabled, current_enabled, pkey)) + + auto_configured = apply_nous_managed_defaults(config, enabled_toolsets=new_enabled, force_fresh=True) + for ts_key in sorted(auto_configured): + label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) + print(color(f" ✓ {label}: using your Nous subscription defaults", Colors.GREEN)) + + # Walk through ALL selected tools with provider options or key requirements, so browser + # (Local vs Browserbase), TTS (Edge vs OpenAI vs ElevenLabs), etc. are shown even when a + # free provider exists. + _configure_list( + [ts for ts in sorted(new_enabled) if _is_configurable(ts) and ts not in auto_configured], + config, selected=False, + ) + _save_platform_tools(config, pkey, new_enabled) + save_config(config) + print(color(f" ✓ Saved {pinfo['label']} tool configuration", Colors.GREEN)) + print() + + +def _configure_all_platforms(config: dict, platform_keys: List[str]) -> bool: + """'Configure all platforms (global)' menu entry. Returns True when config was saved.""" + all_current: Set[str] = set() + for pk in platform_keys: + all_current |= _get_platform_tools(config, pk, include_default_mcp_servers=False) + new_enabled = _prompt_toolset_checklist("All platforms", all_current, force_fresh=True) + selected_to_configure = _toolsets_needing_setup(new_enabled, config) + _configure_list(selected_to_configure, config) + + if new_enabled == all_current and not selected_to_configure: + print(color(" No changes", Colors.DIM)) + return False + for pk in platform_keys: + prev = _get_platform_tools(config, pk, include_default_mcp_servers=False) + added, removed = _checklist_diff(new_enabled, prev, pk) + if added or removed: + print(color(f" {PLATFORMS[pk]['label']}:", Colors.DIM)) + _print_toolset_diff(added, removed, indent=" ") + # Keys for newly enabled tools not already handled by the global selected-tool pass, so a + # tool already enabled globally but lacking provider config doesn't drop the user back to + # the main menu. + _configure_newly_added(added, set(selected_to_configure), config) + _save_platform_tools(config, pk, new_enabled) + save_config(config) + print(color(" ✓ Saved configuration for all platforms", Colors.GREEN)) + return True + + +def _configure_one_platform(config: dict, pkey: str) -> None: + """Per-platform checklist + key setup + save.""" + pinfo = PLATFORMS[pkey] + current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False) + new_enabled = _prompt_toolset_checklist(pinfo["label"], current_enabled, force_fresh=True) + selected_to_configure = _toolsets_needing_setup(new_enabled, config) + _configure_list(selected_to_configure, config) + + if new_enabled != current_enabled or selected_to_configure: + added, removed = _checklist_diff(new_enabled, current_enabled, pkey) + _print_toolset_diff(added, removed) + _configure_newly_added(added, set(selected_to_configure), config) + _save_platform_tools(config, pkey, new_enabled) + save_config(config) + print(color(f" ✓ Saved {pinfo['label']} configuration", Colors.GREEN)) + else: + print(color(f" No changes to {pinfo['label']}", Colors.DIM)) + + def tools_command(args=None, first_install: bool = False, config: dict = None): """Entry point for `hermes tools` and `hermes setup tools`. @@ -1532,24 +1640,8 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): enabled_platforms = _get_enabled_platforms() print() - - # Non-interactive summary mode for CLI usage if getattr(args, "summary", False): - total = len(_get_effective_configurable_toolsets()) - print(color("⚕ Tool Summary", Colors.CYAN, Colors.BOLD)) - print() - summary = _platform_toolset_summary(config, enabled_platforms) - for pkey in enabled_platforms: - pinfo = PLATFORMS[pkey] - enabled = summary.get(pkey, set()) - count = len(enabled) - print(color(f" {pinfo['label']}", Colors.BOLD) + color(f" ({count}/{total})", Colors.DIM)) - if enabled: - for ts_key in sorted(enabled): - print(color(f" ✓ {_toolset_label(ts_key)}", Colors.GREEN)) - else: - print(color(" (none enabled)", Colors.DIM)) - print() + _print_tools_summary(config, enabled_platforms) return print(color("⚕ Hermes Tool Configuration", Colors.CYAN, Colors.BOLD)) print(color(" Enable or disable tools per platform.", Colors.DIM)) @@ -1557,185 +1649,48 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): print(color(" Guide: https://hermes-agent.nousresearch.com/docs/user-guide/features/tools", Colors.DIM)) print() - def _configure_list(to_configure, *, selected=True): - if not to_configure: - return - print() - what = "selected tool(s)" if selected else "tool(s)" - print(color(f" Configuring {len(to_configure)} {what}:", Colors.YELLOW)) - for ts_key in to_configure: - print(color(f" • {_toolset_label(ts_key)}", Colors.DIM)) - print(color(" You can skip any tool you don't need right now.", Colors.DIM)) - print() - for ts_key in to_configure: - _configure_toolset(ts_key, config) - - # ── First-time install: linear flow, no platform menu ── if first_install: - for pkey in enabled_platforms: - pinfo = PLATFORMS[pkey] - current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False) - - # Uncheck toolsets that should be off by default - checklist_preselected = current_enabled - _DEFAULT_OFF_TOOLSETS - - # Show checklist - new_enabled = _prompt_toolset_checklist(pinfo["label"], checklist_preselected, pkey) - - # Only diff against toolsets the checklist actually offered. The - # resolved ``current_enabled`` can include non-configurable toolsets - # (e.g. ``kanban``, recovered platform composites) the user was - # never shown a checkbox for; without this scope the summary would - # print spurious ``- kanban`` removals even though the config keeps - # them. See _checklist_toolset_keys. - _diff_universe = _checklist_toolset_keys(pkey) - _print_toolset_diff( - (new_enabled - current_enabled) & _diff_universe, - (current_enabled - new_enabled) & _diff_universe, - ) - - auto_configured = apply_nous_managed_defaults( - config, - enabled_toolsets=new_enabled, - force_fresh=True, - ) - for ts_key in sorted(auto_configured): - label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) - print(color(f" ✓ {label}: using your Nous subscription defaults", Colors.GREEN)) - - # Walk through ALL selected tools that have provider options or - # need API keys. This ensures browser (Local vs Browserbase), - # TTS (Edge vs OpenAI vs ElevenLabs), etc. are shown even when - # a free provider exists. - _configure_list( - [ - ts_key for ts_key in sorted(new_enabled) - if _is_configurable(ts_key) and ts_key not in auto_configured - ], - selected=False, - ) - - _save_platform_tools(config, pkey, new_enabled) - save_config(config) - print(color(f" ✓ Saved {pinfo['label']} tool configuration", Colors.GREEN)) - print() - + _first_install_flow(config, enabled_platforms) return - # ── Returning user: platform menu loop ── + # Returning user: platform menu loop. Per-platform rows first, then the extras in this order. platform_keys = list(enabled_platforms) platform_choices = [_platform_menu_label(config, pkey) for pkey in platform_keys] - - if len(platform_keys) > 1: + has_global = len(platform_keys) > 1 + if has_global: platform_choices.append("Configure all platforms (global)") platform_choices.append("Reconfigure an existing tool's provider or API key") platform_choices.append(_shared_metrics_menu_label(config)) - - # Show MCP option if any MCP servers are configured - _has_mcp = bool(config.get("mcp_servers")) - if _has_mcp: + has_mcp = bool(config.get("mcp_servers")) + if has_mcp: platform_choices.append("Configure MCP server tools") - platform_choices.append("Done") - # Index offsets for the extra options after per-platform entries - _global_idx = len(platform_keys) if len(platform_keys) > 1 else -1 - _reconfig_idx = len(platform_keys) + (1 if len(platform_keys) > 1 else 0) - _metrics_idx = _reconfig_idx + 1 - _mcp_idx = (_metrics_idx + 1) if _has_mcp else -1 - _done_idx = _metrics_idx + (2 if _has_mcp else 1) + global_idx = len(platform_keys) if has_global else -1 + reconfig_idx = len(platform_keys) + (1 if has_global else 0) + metrics_idx = reconfig_idx + 1 + mcp_idx = (metrics_idx + 1) if has_mcp else -1 + done_idx = metrics_idx + (2 if has_mcp else 1) while True: idx = _prompt_choice("Select an option:", platform_choices, default=0) - - if idx == _done_idx: + if idx == done_idx: break - - if idx == _reconfig_idx: + if idx == reconfig_idx: _reconfigure_tool(config, force_fresh=True) - print() - continue - - if idx == _metrics_idx: + elif idx == metrics_idx: _configure_shared_metrics_interactive(config) - platform_choices[_metrics_idx] = _shared_metrics_menu_label(config) - print() - continue - - if idx == _mcp_idx: + platform_choices[metrics_idx] = _shared_metrics_menu_label(config) + elif idx == mcp_idx: _configure_mcp_tools_interactive(config) - print() - continue - - if idx == _global_idx: - # Use the union of all platforms' current tools as the starting state - all_current = set() - for pk in platform_keys: - all_current |= _get_platform_tools(config, pk, include_default_mcp_servers=False) - new_enabled = _prompt_toolset_checklist( - "All platforms", - all_current, - force_fresh=True, - ) - selected_to_configure = _toolsets_needing_setup(new_enabled, config) - _configure_list(selected_to_configure) - - if new_enabled != all_current or selected_to_configure: - for pk in platform_keys: - prev = _get_platform_tools(config, pk, include_default_mcp_servers=False) - # Scope the printed diff to the checklist's universe (see - # _checklist_toolset_keys) so non-configurable toolsets like - # ``kanban`` aren't reported as added/removed. - _diff_universe = _checklist_toolset_keys(pk) - added = (new_enabled - prev) & _diff_universe - removed = (prev - new_enabled) & _diff_universe - if added or removed: - print(color(f" {PLATFORMS[pk]['label']}:", Colors.DIM)) - _print_toolset_diff(added, removed, indent=" ") - # Configure API keys for newly enabled tools not already - # handled by the global selected-tool pass above, so a - # tool that was already enabled globally but lacked - # provider configuration doesn't drop the user back to the - # main menu. - _configure_newly_added(added, set(selected_to_configure), config) - _save_platform_tools(config, pk, new_enabled) - save_config(config) - print(color(" ✓ Saved configuration for all platforms", Colors.GREEN)) + elif idx == global_idx: + if _configure_all_platforms(config, platform_keys): for ci, pk in enumerate(platform_keys): platform_choices[ci] = _platform_menu_label(config, pk) - else: - print(color(" No changes", Colors.DIM)) - print() - continue - - pkey = platform_keys[idx] - pinfo = PLATFORMS[pkey] - - current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False) - new_enabled = _prompt_toolset_checklist( - pinfo["label"], - current_enabled, - force_fresh=True, - ) - - selected_to_configure = _toolsets_needing_setup(new_enabled, config) - _configure_list(selected_to_configure) - - if new_enabled != current_enabled or selected_to_configure: - _diff_universe = _checklist_toolset_keys(pkey) - added = (new_enabled - current_enabled) & _diff_universe - removed = (current_enabled - new_enabled) & _diff_universe - _print_toolset_diff(added, removed) - _configure_newly_added(added, set(selected_to_configure), config) - - _save_platform_tools(config, pkey, new_enabled) - save_config(config) - print(color(f" ✓ Saved {pinfo['label']} configuration", Colors.GREEN)) else: - print(color(f" No changes to {pinfo['label']}", Colors.DIM)) - + _configure_one_platform(config, platform_keys[idx]) + platform_choices[idx] = _platform_menu_label(config, platform_keys[idx]) print() - platform_choices[idx] = _platform_menu_label(config, pkey) print() from hermes_constants import display_hermes_home diff --git a/tests/hermes_cli/test_post_setup_gating.py b/tests/hermes_cli/test_post_setup_gating.py index 8b625b8d95..036e872abd 100644 --- a/tests/hermes_cli/test_post_setup_gating.py +++ b/tests/hermes_cli/test_post_setup_gating.py @@ -21,7 +21,7 @@ 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", {} From 4d8d479e41f784f3c67e3464d21dbb04f57e9562 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:18:39 -0700 Subject: [PATCH 5/9] refactor(tools_config): split _get_platform_tools into explicit/composite resolvers; compact header comments --- hermes_cli/tools_config.py | 497 ++++++++++++++----------------------- 1 file changed, 189 insertions(+), 308 deletions(-) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 30e8fa0c36..22d9f553e5 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -25,9 +25,8 @@ from hermes_cli.toolset_scope import ( logger = logging.getLogger(__name__) -# Platforms already warned about an all-invalid platform_toolsets list, so the -# runtime check in _get_platform_tools warns once per platform instead of on -# every tool resolution for a persistently-corrupt config (#38798). +# Platforms already warned about an all-invalid platform_toolsets list: warn once per platform, +# not on every tool resolution for a persistently-corrupt config. _warned_invalid_platform_toolsets: Set[str] = set() PROJECT_ROOT = Path(__file__).parent.parent.resolve() @@ -147,9 +146,7 @@ from hermes_cli.tools_config_providers import ( # noqa: F401 — re-exported fo # ─── Toolset Registry ───────────────────────────────────────────────────────── -# Toolsets shown in the configurator, grouped for display. -# Each entry: (toolset_name, label, description) -# These map to keys in toolsets.py TOOLSETS dict. +# Toolsets shown in the configurator: (toolset key in toolsets.py TOOLSETS, label, description). CONFIGURABLE_TOOLSETS = [ ("web", "🔍 Web Search & Scraping", "web_search, web_extract"), ("browser", "🌐 Browser Automation", "navigate, click, type, scroll"), @@ -195,39 +192,25 @@ def gui_toolset_label(label: str) -> str: return text -# Toolsets that are OFF by default for new installs. -# They're still in _HERMES_CORE_TOOLS (available at runtime if enabled), -# but the setup checklist won't pre-select them for first-time users. -# -# Video gen is off by default — it's a niche, paid, slow feature. Users -# who want it opt in via `hermes tools` → Video Generation, which walks -# them through provider + model selection. -# -# X search is off by default for users without xAI credentials, but -# auto-enables when SuperGrok OAuth tokens are stored OR XAI_API_KEY is -# set — mirroring the HASS_TOKEN → homeassistant auto-enable below. The -# `hermes tools` → X (Twitter) Search setup walks users through credential -# setup. The tool's check_fn means the schema still won't appear to the -# model if the credential later goes missing or expires. +# OFF by default for new installs (still in _HERMES_CORE_TOOLS; the checklist just won't pre-select +# them). video_gen is niche/paid/slow. x_search auto-enables when xAI credentials exist (SuperGrok +# OAuth or XAI_API_KEY), mirroring HASS_TOKEN → homeassistant; its check_fn still hides the schema +# if the credential later expires. _DEFAULT_OFF_TOOLSETS = {"homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search", "a2a"} -# Config-only capabilities: they appear in `hermes tools` for provider/API-key -# configuration (TOOL_CATEGORIES) but are NOT model toolsets — they ship zero -# tool schemas and their on/off switch lives in their own config section -# (e.g. ``stt.enabled``), not ``platform_toolsets``. Excluded from the -# per-platform enable/disable checklist; configured via the "Reconfigure an -# existing tool" flow and the GUI provider matrix instead. +# Config-only capabilities: in `hermes tools` for provider/API-key setup (TOOL_CATEGORIES) but NOT +# model toolsets — zero schemas, on/off switch in their own section (``stt.enabled``), not +# ``platform_toolsets``. Excluded from the per-platform checklist; configured via "Reconfigure an +# existing tool" and the GUI provider matrix. _CONFIG_ONLY_TOOLSETS = {"stt"} def _xai_credentials_present() -> bool: - """Cheap, side-effect-free check for usable xAI credentials. + """Cheap, offline check for usable xAI credentials (auth store + env only). - Does NOT hit the network — only inspects the local auth store and environment. The tool's - runtime ``check_fn`` still gates schema registration if creds later expire or get revoked. Also - reused by ``provider_readiness_status`` for ``post_setup: "xai_grok"`` picker rows (xAI TTS, - Grok OAuth x_search). + The tool's runtime ``check_fn`` still gates schema registration if creds later expire. Also used + by ``provider_readiness_status`` for ``post_setup: "xai_grok"`` rows. """ try: from hermes_cli.auth import _read_xai_oauth_tokens @@ -260,11 +243,10 @@ def _homeassistant_credentials_present() -> bool: return False def _toolset_configuration_platform(ts_key: str, default: str = "cli") -> str: - """Return the platform a platform-less configuration UI should target. + """Platform a platform-less configuration UI should target. - Most configurable toolsets retain the historical desktop/CLI target. A toolset restricted away - from that platform must instead be configured on one of its supported platforms; otherwise the - shared save helper correctly drops it and the UI reports a successful no-op. + A toolset restricted away from the default (CLI) must be configured on one of its supported + platforms; otherwise the save helper correctly drops it and the UI reports a successful no-op. """ allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts_key) if not allowed or default in allowed: @@ -273,12 +255,8 @@ def _toolset_configuration_platform(ts_key: str, default: str = "cli") -> str: def _get_effective_configurable_toolsets(): - """Return CONFIGURABLE_TOOLSETS + any plugin-provided toolsets. - - Plugin toolsets are appended at the end so they appear after the built-in toolsets in the TUI - checklist. A plugin whose toolset key already appears in ``CONFIGURABLE_TOOLSETS`` is skipped — - bundled plugins (e.g. - """ + """CONFIGURABLE_TOOLSETS + plugin toolsets (appended, so they render after built-ins; a plugin + whose key is already built-in is skipped).""" result = list(CONFIGURABLE_TOOLSETS) seen = {ts_key for ts_key, _, _ in result} try: @@ -297,28 +275,19 @@ def _get_effective_configurable_toolsets(): def _get_plugin_toolset_keys() -> set: """Return the set of toolset keys provided by plugins.""" try: + # Non-blocking on the CLI startup path: while background discovery is still importing, + # this serves last launch's persisted key set instead of joining the discovery thread. from hermes_cli.plugins import get_plugin_toolset_keys_nowait - # Non-blocking on the CLI startup path: while background plugin - # discovery is still importing modules, this serves last launch's - # persisted key set (used only to exclude plugin toolsets from - # composite expansion) instead of joining the discovery thread. return get_plugin_toolset_keys_nowait() except Exception: return set() def _checklist_toolset_keys(platform: str) -> Set[str]: - """Return the toolset keys the ``hermes tools`` checklist actually offers for ``platform``. - - This mirrors exactly what ``_prompt_toolset_checklist`` renders: - ``_get_effective_configurable_toolsets()`` (built-in + plugin toolsets), filtered by - ``_toolset_allowed_for_platform``. The checklist's returned selection can therefore only ever be - a subset of this universe. - - Non-configurable toolsets that ``_get_platform_tools`` resolves at read time — ``kanban`` and - other check_fn-gated toolsets, recovered platform composites, MCP server names — are NOT in this - set because the checklist never shows them. - """ + """Toolset keys the ``hermes tools`` checklist offers for ``platform`` (mirrors + ``_prompt_toolset_checklist``). Non-configurable toolsets ``_get_platform_tools`` resolves at + read time (``kanban``, recovered composites, MCP names) are NOT here — the checklist never + shows them.""" return { ts_key for ts_key, _, _ in _get_effective_configurable_toolsets() @@ -326,9 +295,8 @@ def _checklist_toolset_keys(platform: str) -> Set[str]: and ts_key not in _CONFIG_ONLY_TOOLSETS } -# Platform display config — derived from the canonical registry so every -# module shares the same data. Kept as dict-of-dicts for backward -# compatibility with existing ``PLATFORMS[key]["label"]`` access patterns. +# Platform display config derived from the canonical registry; dict-of-dicts for the existing +# ``PLATFORMS[key]["label"]`` access pattern. from hermes_cli.platforms import PLATFORMS as _PLATFORMS_REGISTRY PLATFORMS = { @@ -363,9 +331,8 @@ def _toolset_label(ts_key: str) -> str: # ─── Tool Categories (provider-aware configuration) ────────────────────────── -# Maps toolset keys to their provider options. When a toolset is newly enabled, -# we use this to show provider selection and prompt for the right API keys. -# Toolsets not in this map either need no config or use the simple fallback. +# toolset key -> provider options shown when the toolset is newly enabled. Toolsets not in this +# map either need no config or use the TOOLSET_ENV_REQUIREMENTS fallback. TOOL_CATEGORIES = { "tts": { @@ -639,16 +606,10 @@ TOOL_CATEGORIES = { }, } -# Simple env-var requirements for toolsets NOT in TOOL_CATEGORIES. -# Used as a fallback for toolsets that just need an API key. -# -# `vision` is listed here only so it registers as a *configurable* toolset -# (the value gates the reconfigure menu + the "[no API key]" suffix). Its -# actual setup runs through `_configure_vision_backend()` — a full -# provider+model picker like `hermes model` — NOT this single-key prompt, so -# users are never forced onto OpenRouter. `_toolset_has_keys("vision")` -# resolves via `resolve_vision_provider_client()`, so the tuple below is never -# prompted or read for vision; it's purely a presence marker. +# Env-var fallback for toolsets NOT in TOOL_CATEGORIES. `vision` is listed only as a presence marker +# (registers it as configurable: reconfigure menu + "[no API key]" suffix); its setup runs through +# `_configure_vision_backend()` (full provider+model picker, never forcing OpenRouter) and +# `_toolset_has_keys("vision")` resolves via `resolve_vision_provider_client()`. TOOLSET_ENV_REQUIREMENTS = { "vision": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")], } @@ -668,26 +629,14 @@ _PLATFORM_ENABLE_ENV_VARS = ( def _get_enabled_platforms() -> List[str]: """Return platform keys that are configured (have tokens or are CLI).""" - return ["cli"] + [ - platform - for platform, env_var in _PLATFORM_ENABLE_ENV_VARS - if get_env_value(env_var) - ] + return ["cli"] + [platform for platform, env_var in _PLATFORM_ENABLE_ENV_VARS if get_env_value(env_var)] def _platform_toolset_summary(config: dict, platforms: Optional[List[str]] = None) -> Dict[str, Set[str]]: - """Return a summary of enabled toolsets per platform. - - When ``platforms`` is None, this uses ``_get_enabled_platforms`` to auto-detect platforms. Tests - can pass an explicit list to avoid relying on environment variables. - """ + """Enabled toolsets per platform (``platforms`` defaults to ``_get_enabled_platforms()``).""" if platforms is None: platforms = _get_enabled_platforms() - - summary: Dict[str, Set[str]] = {} - for pkey in platforms: - summary[pkey] = _get_platform_tools(config, pkey) - return summary + return {pkey: _get_platform_tools(config, pkey) for pkey in platforms} def _parse_enabled_flag(value, default: bool = True) -> bool: @@ -723,9 +672,7 @@ def enabled_mcp_server_names(config: dict) -> Set[str]: and _parse_enabled_flag(server_cfg.get("enabled", True), default=True) } try: - from hermes_cli.plugins import ( - get_portable_mcp_server_names_nowait, - ) + from hermes_cli.plugins import get_portable_mcp_server_names_nowait portable = get_portable_mcp_server_names_nowait() # Native config wins on a name collision (mirrors _load_mcp_config). @@ -753,27 +700,16 @@ def _exempt_explicit_platform_native( default_off.discard(ts) -#: Toolsets young enough that absence from a saved ``platform_toolsets`` list -#: means "never offered" rather than "declined". +#: Toolsets young enough that absence from a saved ``platform_toolsets`` list means "never offered" +#: rather than "declined". Saving ``hermes tools`` freezes a platform's composite into an explicit +#: list that nothing ever adds to, so a toolset shipped later stays off forever for picker users +#: while ``[hermes-cli]`` users inherit it; listing it here restores parity. #: -#: Saving ``hermes tools`` (or one toggle in the desktop Toolsets UI) replaces -#: a platform's composite with a frozen explicit list, and nothing ever adds to -#: that list — so a toolset shipped afterwards stays off forever for anyone who -#: has touched the picker, while everyone still on ``[hermes-cli]`` inherits it -#: on upgrade. Listing it here restores that parity. -#: -#: MUST ship in the same release as the toolset it names, and be emptied in the -#: next one. The inference only holds while no released build has put the -#: toolset on a checklist: once one has, a user who unchecks it writes a config -#: byte-identical to one saved before the toolset existed (the record below is -#: only written from that point on), and this rule turns their opt-out back on. -#: Landing late — or leaving an entry here for a second release — converts a -#: back-fill into a stuck checkbox. -#: -#: A ``check_fn``-gated toolset costs nothing here for users who cannot call -#: it: an enabled toolset still ships zero schemas when its check fails — the -#: same split Home Assistant uses. Probing a remote service from this path -#: would put a network call on every CLI start, gateway session and cron tick. +#: MUST ship in the same release as the toolset and be emptied in the next: once a released build +#: has put the toolset on a checklist, an unchecking user writes a config byte-identical to one +#: saved before it existed and this rule would turn their opt-out back on (stuck checkbox). +#: A ``check_fn``-gated toolset costs nothing here (zero schemas when its check fails); never probe +#: a remote service from this path — it runs on every CLI start, gateway session and cron tick. _RECENTLY_SHIPPED_TOOLSETS: frozenset = frozenset() @@ -800,8 +736,7 @@ def _enable_recently_shipped_toolsets( continue if not _toolset_allowed_for_platform(ts_key, platform): continue - # Parity is the whole justification, so only enable the toolset where - # staying on the composite would have enabled it anyway. Deliberately + # Only enable where staying on the composite would have enabled it anyway; deliberately # narrow composites (hermes-acp, hermes-webhook) stay narrow. ts_tools = set(resolve_toolset(ts_key, include_registry=False)) if composite_tools is None: @@ -815,8 +750,8 @@ def _configurable_subset_of(tool_names: Set[str], platform: str) -> Set[str]: """Configurable toolsets whose STATIC membership is contained in ``tool_names``. Compares ``resolve_toolset(ts, include_registry=False)``: a tool registered into a toolset at - runtime (e.g. delegate_cli -> delegation, desktop-only read_terminal -> terminal) that the - composite never listed must not drop the whole toolset (issue #49622). + runtime (delegate_cli -> delegation, desktop-only read_terminal -> terminal) that the composite + never listed must not drop the whole toolset. """ from toolsets import resolve_toolset @@ -833,24 +768,99 @@ def _configurable_subset_of(tool_names: Set[str], platform: str) -> Set[str]: def _default_off_toolsets(platform: str, explicitly_configured: bool) -> Set[str]: """Toolsets to strip from an implicit (composite-derived) enable set for ``platform``. - Legacy safety: a platform whose own name matches a default-off toolset (``homeassistant``) - keeps that toolset on first install — except platform-restricted toolsets, which stay opt-in - even on their own platform (``discord`` + ``discord`` stays OFF). Home Assistant is runtime- - gated by its check_fn (needs HASS_TOKEN), so a configured token is an explicit opt-in and must - not be stripped here — otherwise HA silently vanished from platforms like cron that resolve - without a saved toolset list (regression after #14798). + A platform whose own name is a default-off toolset (``homeassistant``) keeps it on first install, + except platform-restricted toolsets, which stay opt-in even on their own platform (``discord`` on + discord stays OFF). A configured HASS_TOKEN is an explicit opt-in (the check_fn gates at runtime) + and must not be stripped, or HA silently vanishes from platforms like cron that resolve without + a saved list. """ default_off = set(_DEFAULT_OFF_TOOLSETS) if platform in default_off and platform not in _TOOLSET_PLATFORM_RESTRICTIONS: default_off.remove(platform) if "homeassistant" in default_off and _homeassistant_credentials_present(): default_off.remove("homeassistant") - _exempt_explicit_platform_native( - default_off, platform, explicitly_configured=explicitly_configured - ) + _exempt_explicit_platform_native(default_off, platform, explicitly_configured=explicitly_configured) return default_off +def _configurable_keys() -> Set[str]: + return {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} + + +def _platform_default_keys() -> Set[str]: + return {p["default_toolset"] for p in PLATFORMS.values()} + + +def _explicit_toolsets( + toolset_names: List[str], explicit_known_keys: Set[str], config: dict, platform: str, + explicitly_configured: bool, +) -> Set[str]: + """Enabled set when the saved list names configurable/plugin keys directly. + + Direct membership avoids the subset-inference bug where composites like ``hermes-cli`` (all + _HERMES_CORE_TOOLS) made disabled toolsets re-appear. A mixed list (``[hermes-cli, spotify]`` + after enabling Spotify via ``hermes tools``) still expands the composite, otherwise sessions + keep only the opt-ins and lose every native tool; _DEFAULT_OFF_TOOLSETS applies to that + implicit expansion only — anything explicitly listed survives. + """ + from toolsets import resolve_toolset, TOOLSETS + + enabled = { + ts for ts in toolset_names + if ts in explicit_known_keys and _toolset_allowed_for_platform(ts, platform) + } + composite_tools: Set[str] = set() + for ts_name in toolset_names: + if ts_name not in explicit_known_keys and ts_name in TOOLSETS: + composite_tools.update(resolve_toolset(ts_name)) + if composite_tools: + enabled |= _configurable_subset_of(composite_tools, platform) - _default_off_toolsets( + platform, explicitly_configured + ) + _enable_recently_shipped_toolsets(enabled, config, platform) + return enabled + + +def _composite_toolsets(toolset_names: List[str], platform: str, explicitly_configured: bool) -> Set[str]: + """Enabled set inferred from composite names (``hermes-cli``) by reverse-mapping tool names. + + ``x_search`` is its own one-tool toolset the composite does NOT include, so the subset loop + never picks it up; inject it when xAI credentials exist (mirroring HASS_TOKEN → homeassistant) + and carve it out of the default-off subtraction. Only runs while no explicit list is saved — + once saved, that list is authoritative. + """ + from toolsets import resolve_toolset + + all_tool_names: Set[str] = set() + for ts_name in toolset_names: + all_tool_names.update(resolve_toolset(ts_name)) + enabled = _configurable_subset_of(all_tool_names, platform) + default_off = _default_off_toolsets(platform, explicitly_configured) + if _toolset_allowed_for_platform("x_search", platform) and _xai_credentials_present(): + enabled.add("x_search") + default_off.discard("x_search") + return enabled - default_off + + +def _enabled_plugin_toolsets(config: dict, platform: str, toolset_names: List[str], plugin_ts_keys: Set[str]) -> Set[str]: + """Plugin toolsets: on by default unless in _DEFAULT_OFF_TOOLSETS (e.g. bundled spotify — opt-in + so we don't ship 7 schemas to non-users) or "known" for this platform (``known_plugin_toolsets`` + is written on every ``hermes tools`` save) and absent from the saved list.""" + known_for_platform = set((config.get("known_plugin_toolsets", {}) or {}).get(platform, []) or []) + return { + pts for pts in plugin_ts_keys + if pts in toolset_names or (pts not in _DEFAULT_OFF_TOOLSETS and pts not in known_for_platform) + } + + +def _context_engine_active(config: dict) -> bool: + context_cfg = config.get("context") or {} + if not isinstance(context_cfg, dict): + context_cfg = {} + name = str(context_cfg.get("engine") or "compressor").strip().lower() + return bool(name) and name != "compressor" + + def _get_platform_tools( config: dict, platform: str, @@ -858,129 +868,42 @@ def _get_platform_tools( include_default_mcp_servers: bool = True, ) -> Set[str]: """Resolve which individual toolset names are enabled for a platform.""" - from toolsets import resolve_toolset, TOOLSETS - platform_toolsets = config.get("platform_toolsets") or {} toolset_names = platform_toolsets.get(platform) - # Track whether the user explicitly saved a toolset list for this platform - # (vs. falling back to the platform default). An explicit composite (e.g. - # ``hermes-discord``) is an opt-in to the platform's native default-off - # toolsets — see _exempt_explicit_platform_native (#35527). + # An explicitly saved list (even a composite like ``hermes-discord``) is an opt-in to the + # platform's native default-off toolsets — see _exempt_explicit_platform_native. explicitly_configured = isinstance(toolset_names, list) - if not explicitly_configured: toolset_names = [_platform_default_toolset(platform)] - - # YAML may parse bare numeric names (e.g. ``12306:``) as int. - # Normalise to str so downstream sorted() never mixes types. + # YAML may parse bare numeric names (``12306:``) as int; normalise so sorted() never mixes types. toolset_names = [str(ts) for ts in toolset_names] - configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} + configurable_keys = _configurable_keys() plugin_ts_keys = _get_plugin_toolset_keys() - platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()} - # Plugin-provided toolsets are first-class on a platform-toolsets list — - # explicit config like ``[hermes-cli, a2a]`` must survive filtering just - # like a built-in configurable toolset would. See issue #81163. + platform_default_keys = _platform_default_keys() + # Plugin toolsets are first-class on a saved list: ``[hermes-cli, a2a]`` must survive filtering. explicit_known_keys = configurable_keys | plugin_ts_keys - # If the saved list contains any configurable keys directly, the user - # has explicitly configured this platform — use direct membership. - # This avoids the subset-inference bug where composite toolsets like - # "hermes-cli" (which include all _HERMES_CORE_TOOLS) cause disabled - # toolsets to re-appear as enabled. - has_explicit_config = any(ts in explicit_known_keys for ts in toolset_names) - - if has_explicit_config: - enabled_toolsets = { - ts for ts in toolset_names - if ts in explicit_known_keys and _toolset_allowed_for_platform(ts, platform) - } - # Mixed config: composite toolset alongside configurables (e.g. - # ``[hermes-cli, spotify]`` after enabling Spotify via ``hermes - # tools``). Without expansion the composite name is silently dropped, - # leaving sessions with only the configurable opt-ins and no native - # tools. Mirror the else-branch's subset inference, but apply - # _DEFAULT_OFF_TOOLSETS only to the implicit expansion — anything the - # user explicitly listed (e.g. ``spotify``) must survive. - composite_tools = set() - for ts_name in toolset_names: - if ts_name not in explicit_known_keys and ts_name in TOOLSETS: - composite_tools.update(resolve_toolset(ts_name)) - - if composite_tools: - enabled_toolsets |= _configurable_subset_of(composite_tools, platform) - _default_off_toolsets( - platform, explicitly_configured - ) - - _enable_recently_shipped_toolsets(enabled_toolsets, config, platform) - else: - # No explicit config — fall back to resolving composite toolset names - # (e.g. "hermes-cli") to individual tool names and reverse-mapping. - all_tool_names = set() - for ts_name in toolset_names: - all_tool_names.update(resolve_toolset(ts_name)) - enabled_toolsets = _configurable_subset_of(all_tool_names, platform) - - # Auto-enable ``x_search`` when xAI credentials are configured. - # Unlike ``homeassistant`` (whose ``ha_*`` tools live inside the - # platform composite and thus pass the subset check above), - # ``x_search`` is its own one-tool toolset that the composite does - # NOT include, so the subset loop never picks it up. Inject it - # directly here, mirroring the HASS_TOKEN → ``homeassistant`` rule - # below: once you have working creds, you don't have to also click - # through ``hermes tools`` to flip the toolset on. Only fires when - # the user has not yet saved an explicit toolset list — once they - # do, the saved list is authoritative. - x_search_auto_enabled = ( - _toolset_allowed_for_platform("x_search", platform) - and _xai_credentials_present() + if any(ts in explicit_known_keys for ts in toolset_names): + enabled_toolsets = _explicit_toolsets( + toolset_names, explicit_known_keys, config, platform, explicitly_configured ) - if x_search_auto_enabled: - enabled_toolsets.add("x_search") + else: + enabled_toolsets = _composite_toolsets(toolset_names, platform, explicitly_configured) - default_off = _default_off_toolsets(platform, explicitly_configured) - # Symmetric carve-out for x_search auto-enable (see the inject - # block above). Without this, the default_off subtraction would - # strip the entry we just added. - if x_search_auto_enabled: - default_off.discard("x_search") - enabled_toolsets -= default_off - - _recover_platform_native_toolsets(enabled_toolsets, platform, skip=configurable_keys | plugin_ts_keys | platform_default_keys) - - # Plugin toolsets: enabled by default unless explicitly disabled, or - # unless the toolset is in _DEFAULT_OFF_TOOLSETS (e.g. spotify — - # shipped as a bundled plugin but user must opt in via `hermes tools` - # so we don't ship 7 Spotify tool schemas to users who don't use it). - # A plugin toolset is "known" for a platform once `hermes tools` - # has been saved for that platform (tracked via known_plugin_toolsets). - # Unknown plugins default to enabled; known-but-absent = disabled. + _recover_platform_native_toolsets( + enabled_toolsets, platform, skip=configurable_keys | plugin_ts_keys | platform_default_keys + ) if plugin_ts_keys: - known_map = config.get("known_plugin_toolsets", {}) or {} - known_for_platform = set(known_map.get(platform, []) or []) - for pts in plugin_ts_keys: - if pts in toolset_names or ( - pts not in _DEFAULT_OFF_TOOLSETS and pts not in known_for_platform - ): - enabled_toolsets.add(pts) + enabled_toolsets |= _enabled_plugin_toolsets(config, platform, toolset_names, plugin_ts_keys) - # Context-engine tools are runtime-provided by the active engine, so they - # are not part of any static platform composite. When a non-default engine - # is selected, keep its recovery/status tools available even after a user - # saves an explicit platform toolset list. Preserve the explicit empty-list - # contract: selecting no configurable tools means no context-engine tools - # either unless the user adds ``context_engine`` manually later. - context_cfg = config.get("context") or {} - if not isinstance(context_cfg, dict): - context_cfg = {} - context_engine_name = str(context_cfg.get("engine") or "compressor").strip().lower() - if context_engine_name and context_engine_name != "compressor" and not ( - explicitly_configured and not toolset_names - ): + # Context-engine tools are runtime-provided, not in any static composite: keep them for a + # non-default engine even after an explicit save. An explicit EMPTY list means no + # context-engine tools either, unless the user adds ``context_engine`` by hand. + if _context_engine_active(config) and not (explicitly_configured and not toolset_names): enabled_toolsets.add("context_engine") - # Preserve any explicit non-configurable toolset entries (for example, - # custom toolsets or MCP server names saved in platform_toolsets). + # Explicit non-configurable entries (custom toolsets, MCP server names) pass through. explicit_passthrough = { ts for ts in toolset_names if ts not in explicit_known_keys and ts not in platform_default_keys @@ -989,14 +912,10 @@ def _get_platform_tools( config, toolset_names, explicit_passthrough, include_default_mcp_servers ) - # Honor agent.disabled_toolsets from config.yaml — allows users to - # globally suppress specific toolsets (e.g. "memory") across all - # platforms without per-platform toolset configuration. This runs - # last so it overrides everything above. The value may arrive as a - # JSON-array string (e.g. "['memory']") from `hermes config set` or a - # JSON-mode editor save; parse it so the list is not silently dead (#86661). - agent_cfg = config.get("agent") or {} - disabled_toolsets = agent_cfg.get("disabled_toolsets") or [] + # agent.disabled_toolsets is a global suppression list and runs LAST so it overrides everything + # above. It may arrive as a JSON-array string ("['memory']") from `hermes config set` or a + # JSON-mode editor save; parse it so the list is not silently dead. + disabled_toolsets = (config.get("agent") or {}).get("disabled_toolsets") or [] if disabled_toolsets: from agent.skill_utils import parse_config_string_list @@ -1006,7 +925,6 @@ def _get_platform_tools( if explicitly_configured and toolset_names: _warn_all_invalid_platform_toolsets(platform, platform_toolsets[platform]) - return enabled_toolsets @@ -1030,13 +948,12 @@ def _recover_platform_native_toolsets(enabled_toolsets: Set[str], platform: str, skip = skip | {k for k in TOOLSETS if k.startswith("hermes-")} skip |= set(_DEFAULT_OFF_TOOLSETS) - {platform} for ts_key, ts_def in TOOLSETS.items(): - # Posture toolsets (e.g. ``coding``) are session-level selections made - # by agent/coding_context.py — not per-platform capabilities to recover. + # Posture toolsets (``coding``) are session-level selections made by agent/coding_context.py, + # not per-platform capabilities to recover. if ts_key in skip or ts_def.get("includes") or ts_def.get("posture"): continue - # Static membership (see #49622): a registry-added tool absent from the - # platform composite must not block recovery of a non-configurable - # toolset whose authored tools the composite does list. + # Static membership: a registry-added tool absent from the platform composite must not + # block recovery of a non-configurable toolset whose authored tools the composite lists. ts_tools = set(resolve_toolset(ts_key, include_registry=False)) if not ts_tools or not ts_tools.issubset(platform_tool_universe): continue @@ -1070,7 +987,7 @@ def _merge_mcp_servers( def _warn_all_invalid_platform_toolsets(platform: str, explicit: list) -> None: - """#38798: warn once when an explicitly configured platform has only invalid toolset names. + """Warn once when an explicitly configured platform has only invalid toolset names. A migration or hand-edit that left ``hermes`` instead of ``hermes-cli`` makes resolve_toolset() return [] for every entry and the platform silently ends up with no native tools. Surface it @@ -1098,74 +1015,46 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[ """Save the selected toolset keys for a platform to config.""" config.setdefault("platform_toolsets", {}) - # Drop platform-scoped toolsets that don't apply here. Prevents the - # "Configure all platforms" checklist (or a hand-edited config.yaml) - # from turning on, say, the `discord` toolset for Telegram. - enabled_toolset_keys = { - ts for ts in enabled_toolset_keys - if _toolset_allowed_for_platform(ts, platform) - } + # Drop platform-scoped toolsets that don't apply here, so the "Configure all platforms" + # checklist (or a hand-edited config.yaml) can't turn on `discord` for Telegram. + enabled_toolset_keys = {ts for ts in enabled_toolset_keys if _toolset_allowed_for_platform(ts, platform)} - # Get the set of all configurable toolset keys (built-in + plugin) - configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} plugin_keys = _get_plugin_toolset_keys() - configurable_keys |= plugin_keys + configurable_keys = _configurable_keys() | plugin_keys + # Platform defaults (hermes-cli, ...) resolve to ALL tools; preserving one would silently + # override the user's unchecked selections on the next read. + platform_default_keys = _platform_default_keys() - # Also exclude platform default toolsets (hermes-cli, hermes-telegram, etc.) - # These are "super" toolsets that resolve to ALL tools, so preserving them - # would silently override the user's unchecked selections on the next read. - platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()} - - # Get existing toolsets for this platform existing_toolsets = cfg_get(config, "platform_toolsets", platform, default=[]) if not isinstance(existing_toolsets, list): existing_toolsets = [] - existing_toolsets = [str(ts) for ts in existing_toolsets] - - # Preserve any entries that are NOT configurable toolsets and NOT platform - # defaults (i.e. only MCP server names should be preserved) + # Preserve only entries that are neither configurable nor platform defaults (MCP server names). preserved_entries = { - entry for entry in existing_toolsets - if entry not in configurable_keys and entry not in platform_default_keys + str(entry) for entry in existing_toolsets + if str(entry) not in configurable_keys and str(entry) not in platform_default_keys } - # Opening `hermes tools` is the user's opt-in to reconfigure tools, so treat - # saving from the picker as consent to clear the "no_mcp" sentinel. The - # picker has no checkbox for no_mcp, so without this users who once set it - # by hand could never re-enable MCP servers through the UI. + # Saving from the picker is consent to clear the "no_mcp" sentinel: the picker has no checkbox + # for it, so users who once set it by hand could otherwise never re-enable MCP via the UI. preserved_entries.discard("no_mcp") - # Merge preserved entries with new enabled toolsets config["platform_toolsets"][platform] = sorted(enabled_toolset_keys | preserved_entries) - # Track which plugin toolsets are "known" for this platform so we can - # distinguish "new plugin, default enabled" from "user disabled it". - # _cfg_section normalizes a present-but-null key ("known_plugin_toolsets:" - # in config.yaml parses to None) that setdefault alone would not replace. + # Record which plugin toolsets this platform "knows" (distinguishes "new plugin, default + # enabled" from "user disabled it"). _cfg_section normalizes a present-but-null key that + # setdefault alone would not replace. if plugin_keys: _cfg_section(config, "known_plugin_toolsets")[platform] = sorted(plugin_keys) - # Same record for builtin toolsets: which ones this platform's checklist - # has actually put in front of the user. Without it, a toolset the user - # unchecks here is indistinguishable from one that shipped after they - # saved, and _enable_recently_shipped_toolsets would turn it straight back - # on. Recorded from the full catalog, since that is what the picker showed. - _cfg_section(config, "known_builtin_toolsets")[platform] = sorted( - ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS - ) + # Same record for builtin toolsets the checklist actually offered; without it an unchecked + # toolset is indistinguishable from one shipped after the save and + # _enable_recently_shipped_toolsets would turn it straight back on. + _cfg_section(config, "known_builtin_toolsets")[platform] = sorted(_configurable_keys()) - # Reconcile with agent.disabled_toolsets. _get_platform_tools() applies - # that list as a final override AFTER reading platform_toolsets., - # so a toolset listed there stays permanently OFF no matter what this - # function writes — the toggle "saves" but silently can't ever take - # effect. Blank Slate installs pre-populate this list with ~27 toolsets, - # making most of the desktop Toolsets UI unusable for re-enabling - # anything (issue #49995). - # - # Only toolsets the user just explicitly enabled FOR THIS PLATFORM are - # cleared from the global disabled list — toolsets the user did not - # touch (still unchecked) or that remain disabled on other platforms - # are left alone, so agent.disabled_toolsets keeps working as a - # cross-platform suppression list for anything not actively re-enabled. + # Reconcile with agent.disabled_toolsets, which _get_platform_tools applies as a final override: + # a toolset listed there stays OFF no matter what this function writes (Blank Slate installs + # pre-populate ~27 entries, making the desktop Toolsets UI unable to re-enable anything). Only + # toolsets the user just explicitly enabled FOR THIS PLATFORM are cleared, so the list keeps + # working as a cross-platform suppression list for everything else. agent_cfg = config.get("agent") newly_enabled = enabled_toolset_keys - preserved_entries if isinstance(agent_cfg, dict) and agent_cfg.get("disabled_toolsets") and newly_enabled: @@ -1206,27 +1095,19 @@ def _toolset_has_keys( if ts_key in {"web", "image_gen", "video_gen", "tts", "stt", "browser"}: if features is None: - features = get_nous_subscription_features( - config, force_fresh=force_fresh - ) + features = get_nous_subscription_features(config, force_fresh=force_fresh) feature = features.features.get(ts_key) if feature and (feature.available or feature.managed_by_nous): return True - # Check TOOL_CATEGORIES first (provider-aware). A no-key provider - # (Local Browser, Edge TTS) counts as configured. + # Provider-aware categories first: a no-key provider (Local Browser, Edge TTS) counts as configured. cat = TOOL_CATEGORIES.get(ts_key) if cat: return any( _provider_env_ready(provider) for provider in _visible_providers(cat, config, force_fresh=force_fresh, features=features) ) - - # Fallback to simple requirements - requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, []) - if not requirements: - return True - return all(get_env_value(var) for var, _ in requirements) + return all(get_env_value(var) for var, _ in TOOLSET_ENV_REQUIREMENTS.get(ts_key, [])) # ─── Menu Helpers ───────────────────────────────────────────────────────────── From 55ba3cbd612fc81aa735a84606406d24da0113f7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:22:15 -0700 Subject: [PATCH 6/9] refactor(tools_config): extract MCP checklist + enable/disable command into tools_config_mcp; compact checklist/reconfigure helpers --- hermes_cli/tools_config.py | 450 +++------------------------------ hermes_cli/tools_config_mcp.py | 354 ++++++++++++++++++++++++++ 2 files changed, 389 insertions(+), 415 deletions(-) create mode 100644 hermes_cli/tools_config_mcp.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 22d9f553e5..d447ac1d5a 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -35,10 +35,7 @@ PROJECT_ROOT = Path(__file__).parent.parent.resolve() # ─── UI Helpers (shared with setup.py) ──────────────────────────────────────── from hermes_cli.cli_output import ( # noqa: E402 — late import block - print_error as _print_error, print_info as _print_info, - print_success as _print_success, - print_warning as _print_warning, ) from hermes_cli.tools_config_cua import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches _post_setup_no_window_flags, @@ -143,6 +140,17 @@ from hermes_cli.tools_config_providers import ( # noqa: F401 — re-exported fo _configure_vision_provider_model, _configure_simple_requirements, ) +from hermes_cli.tools_config_mcp import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches + _mcp_match_filter, + _mcp_preselected, + _apply_mcp_checklist, + _configure_mcp_tools_interactive, + _apply_toolset_change, + _apply_mcp_change, + _print_tools_list, + _known_tool_platforms, + tools_disable_enable_command, +) # ─── Toolset Registry ───────────────────────────────────────────────────────── @@ -1137,8 +1145,7 @@ def _estimate_tool_tokens() -> Dict[str, int]: _tool_token_cache = _tool_token_cache or {} try: - # Trigger full tool discovery (imports all tool modules). - import model_tools # noqa: F401 + import model_tools # noqa: F401 — triggers full tool discovery from tools.registry import registry cache_key = (scope, registry._generation) except Exception: @@ -1158,11 +1165,8 @@ def _estimate_tool_tokens() -> Dict[str, int]: counts: Dict[str, int] = {} for name in registry.get_all_tool_names(): schema = registry.get_schema(name) - if schema: - # Mirror what gets sent to the API: - # {"type": "function", "function": } - text = _json.dumps({"type": "function", "function": schema}) - counts[name] = len(enc.encode(text)) + if schema: # mirror the wire shape sent to the API + counts[name] = len(enc.encode(_json.dumps({"type": "function", "function": schema}))) _tool_token_cache[cache_key] = counts return counts @@ -1178,40 +1182,26 @@ def _prompt_toolset_checklist( from hermes_cli.curses_ui import curses_checklist from toolsets import resolve_toolset - # Pre-compute per-tool token counts (cached after first call). tool_tokens = _estimate_tool_tokens() - - effective_all = _get_effective_configurable_toolsets() - # Drop platform-scoped toolsets that don't apply to this platform, and - # config-only capabilities (stt) that have no per-platform toggle. + # Drop platform-scoped toolsets that don't apply here and config-only capabilities (stt). effective = [ - (k, l, d) for (k, l, d) in effective_all - if _toolset_allowed_for_platform(k, platform) - and k not in _CONFIG_ONLY_TOOLSETS + (k, l, d) for (k, l, d) in _get_effective_configurable_toolsets() + if _toolset_allowed_for_platform(k, platform) and k not in _CONFIG_ONLY_TOOLSETS ] - labels = [] for ts_key, ts_label, ts_desc in effective: suffix = "" - if ( - not _toolset_has_keys(ts_key, force_fresh=force_fresh) - and _is_configurable(ts_key) - ): + if not _toolset_has_keys(ts_key, force_fresh=force_fresh) and _is_configurable(ts_key): suffix = " [no API key]" labels.append(f"{ts_label} ({ts_desc}){suffix}") + pre_selected = {i for i, (ts_key, _, _) in enumerate(effective) if ts_key in enabled} - pre_selected = { - i for i, (ts_key, _, _) in enumerate(effective) - if ts_key in enabled - } - - # Build a live status function that shows deduplicated total token cost. status_fn = None if tool_tokens: ts_keys = [ts_key for ts_key, _, _ in effective] def status_fn(chosen: set) -> str: - # Collect unique tool names across all selected toolsets + """Deduplicated token cost of the selected toolsets.""" all_tools: set = set() for idx in chosen: all_tools.update(resolve_toolset(ts_keys[idx])) @@ -1221,79 +1211,40 @@ def _prompt_toolset_checklist( return f"Est. tool context: ~{total} tokens" chosen = curses_checklist( - f"Tools for {platform_label}", - labels, - pre_selected, - cancel_returns=pre_selected, - status_fn=status_fn, + f"Tools for {platform_label}", labels, pre_selected, cancel_returns=pre_selected, status_fn=status_fn, ) return {effective[i][0] for i in chosen} # ─── Provider-Aware Configuration ──────────────────────────────────────────── -def _configure_toolset( - ts_key: str, - config: dict, - *, - force_fresh: bool = True, -): - """Configure a toolset - provider selection + API keys. - - Uses TOOL_CATEGORIES for provider-aware config, falls back to simple env var prompts for - toolsets not in TOOL_CATEGORIES. - """ +def _configure_toolset(ts_key: str, config: dict, *, force_fresh: bool = True, reconfigure: bool = False): + """Configure a toolset: provider selection + API keys (TOOL_CATEGORIES), else simple env-var prompts.""" cat = TOOL_CATEGORIES.get(ts_key) - if cat: - _configure_tool_category(ts_key, cat, config, force_fresh=force_fresh) + _configure_tool_category(ts_key, cat, config, force_fresh=force_fresh, reconfigure=reconfigure) else: - # Simple fallback for vision, moa, etc. - _configure_simple_requirements(ts_key) + _configure_simple_requirements(ts_key, reconfigure=reconfigure) - - - - - - - -def _reconfigure_tool( - config: dict, - *, - force_fresh: bool = True, -): +def _reconfigure_tool(config: dict, *, force_fresh: bool = True): """Let user reconfigure an existing tool's provider or API key.""" - # Build list of configurable tools that are currently set up - configurable = [] - for ts_key, ts_label, _ in _get_effective_configurable_toolsets(): + configurable = [ + (ts_key, ts_label) + for ts_key, ts_label, _ in _get_effective_configurable_toolsets() if _is_configurable(ts_key) and ( _toolset_has_keys(ts_key, config, force_fresh=force_fresh) or _toolset_enabled_for_reconfigure(ts_key, config) - ): - configurable.append((ts_key, ts_label)) - + ) + ] if not configurable: _print_info("No configured tools to reconfigure.") return - - choices = [label for _, label in configurable] - choices.append("Cancel") - + choices = [label for _, label in configurable] + ["Cancel"] idx = _prompt_choice(" Which tool would you like to reconfigure?", choices, len(choices) - 1) - if idx >= len(configurable): - return # Cancel - - ts_key, ts_label = configurable[idx] - cat = TOOL_CATEGORIES.get(ts_key) - - if cat: - _configure_tool_category(ts_key, cat, config, force_fresh=force_fresh, reconfigure=True) - else: - _configure_simple_requirements(ts_key, reconfigure=True) - + return + _configure_toolset(configurable[idx][0], config, force_fresh=force_fresh, reconfigure=True) save_config(config) @@ -1307,11 +1258,7 @@ def _toolset_enabled_for_reconfigure(ts_key: str, config: dict) -> bool: if not _toolset_allowed_for_platform(ts_key, platform): continue try: - enabled = _get_platform_tools( - config, - platform, - include_default_mcp_servers=False, - ) + enabled = _get_platform_tools(config, platform, include_default_mcp_servers=False) except Exception: continue if ts_key in enabled: @@ -1578,330 +1525,3 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): print(color(f" Tool configuration saved to {display_hermes_home()}/config.yaml", Colors.DIM)) print(color(" Changes take effect on next 'hermes' or gateway restart.", Colors.DIM)) print() - - -# ─── MCP Tools Interactive Configuration ───────────────────────────────────── - - -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 (cleanest config shape; the - # server's native tool set is the active set, and any 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 are - # added as 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)] - # Drop any legacy exclude block — we're include-mode now. - tools_cfg.pop("exclude", None) - - -def _configure_mcp_tools_interactive(config: dict): - """Probe MCP servers for available tools and let user toggle them on/off. - - Connects to each server, discovers tools, shows a per-server curses checklist, and writes the - result back as ``tools.exclude`` entries in config.yaml. - """ - 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)) - - -# ─── Non-interactive disable/enable ────────────────────────────────────────── - - -def _apply_toolset_change(config: dict, platform: str, toolset_names: List[str], action: str): - """Add or remove built-in toolsets for a platform.""" - enabled = _get_platform_tools(config, platform, include_default_mcp_servers=False) - if action == "disable": - updated = enabled - set(toolset_names) - else: - updated = 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.""" - 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 are registered at runtime rather than in the static CLI display registry. Tool - introspection/configuration must recognize those names too, otherwise an active plugin platform - cannot audit its authority. - """ - 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. Preserve the built-in CLI path when a - # third-party plugin is malformed or its dependencies are unavailable. - pass - return known - - -def tools_disable_enable_command(args): - """Enable, disable, or list tools for a platform.""" - 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}' " - f"(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)}") diff --git a/hermes_cli/tools_config_mcp.py b/hermes_cli/tools_config_mcp.py new file mode 100644 index 0000000000..3b1274b6a8 --- /dev/null +++ b/hermes_cli/tools_config_mcp.py @@ -0,0 +1,354 @@ +"""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, +) + + +# ─── MCP Tools Interactive Configuration ───────────────────────────────────── + + +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 (cleanest config shape; the + # server's native tool set is the active set, and any 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 are + # added as 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)] + # Drop any legacy exclude block — we're include-mode now. + tools_cfg.pop("exclude", None) + + +def _configure_mcp_tools_interactive(config: dict): + """Probe MCP servers for available tools and let user toggle them on/off. + + Connects to each server, discovers tools, shows a per-server curses checklist, and writes the + result back as ``tools.exclude`` 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)) + + +# ─── Non-interactive disable/enable ────────────────────────────────────────── + + +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) + if action == "disable": + updated = enabled - set(toolset_names) + else: + updated = 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 are registered at runtime rather than in the static CLI display registry. Tool + introspection/configuration must recognize those names too, otherwise an active plugin platform + cannot 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. Preserve the built-in CLI path when a + # third-party plugin is malformed or its dependencies are unavailable. + 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}' " + f"(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)}") From ec8e85965d87addc3d6281d04f6f8237f7454b07 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:29:58 -0700 Subject: [PATCH 7/9] refactor(tools_config): compact extracted modules (join statements, tighten comments) --- hermes_cli/tools_config_cua.py | 401 +++++++++----------------- hermes_cli/tools_config_mcp.py | 85 ++---- hermes_cli/tools_config_post_setup.py | 240 +++++---------- hermes_cli/tools_config_providers.py | 399 +++++++++---------------- 4 files changed, 361 insertions(+), 764 deletions(-) diff --git a/hermes_cli/tools_config_cua.py b/hermes_cli/tools_config_cua.py index e6cd4ee73c..94ea7b0277 100644 --- a/hermes_cli/tools_config_cua.py +++ b/hermes_cli/tools_config_cua.py @@ -11,9 +11,7 @@ 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, + print_info as _print_info, print_success as _print_success, print_warning as _print_warning, ) logger = logging.getLogger("hermes_cli.tools_config") @@ -22,11 +20,9 @@ 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. - The GUI runs post-setup hooks via a console-less child; on Windows every console grandchild - (npm, pip, powershell) would flash a new window. CREATE_NO_WINDOW suppresses that 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. + 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 @@ -48,11 +44,7 @@ def _cua_driver_cmd() -> str: def _cua_version_summary(raw: str, *, limit: int = 120) -> str: - """Reduce a driver's ``--version`` output to one short status line. - - A ``HERMES_CUA_DRIVER_CMD`` override may print a multi-line banner (e.g. cmd.exe); interpolating - it verbatim shattered the one-line status summary, so keep the first non-empty line, bounded. - """ + """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: @@ -68,11 +60,9 @@ def _resolved_cua_driver_cmd() -> Optional[str]: def _cua_driver_env() -> dict: - """cua-driver child env with the Hermes telemetry policy applied. + """cua-driver child env with the Hermes telemetry policy applied (``cua_backend.cua_driver_child_env``). - Delegates to ``cua_backend.cua_driver_child_env`` (telemetry disabled by default; user opt-in - via ``computer_use.cua_telemetry``). Falls back to the current environment if the helper can't - be imported, so install/status never break on a telemetry-helper error. + 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 @@ -108,11 +98,7 @@ def _cua_driver_contract_status(binary: Optional[str] = None) -> dict: 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), - ) + _CUA_DRIVER_CONTRACT_CACHE.update(fingerprint=fingerprint, checked_at=now, state=dict(state)) return state @@ -123,28 +109,18 @@ def _cua_driver_install_ready() -> bool: return sys.platform != "win32" or _cua_driver_autostart_registered_windows() -def _pip_install( - args: List[str], - *, - timeout: int = 300, - capture_output: bool = True, -): +def _pip_install(args: List[str], *, timeout: int = 300, capture_output: bool = True): """Install Python packages from a post-setup hook. - Strategy (in order): 1. ``uv pip install`` if uv is on PATH — fast, doesn't need pip in the - venv. 2. ``python -m pip install`` — works on stdlib venvs. 3. ``python -m ensurepip --upgrade`` - then retry pip — covers ``uv venv`` which creates a venv WITHOUT pip. - - Why this exists: the Windows installer creates the venv via ``uv venv``, which doesn't seed pip. - Post-setup hooks that shelled out to ``[sys.executable, '-m', 'pip', 'install', ...]`` failed - with ``No module named pip`` on every fresh install. uv-first sidesteps that. + 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, and - # tier 2 is a pip that the Windows installer's `uv venv` does not seed. + # 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() @@ -154,9 +130,7 @@ def _pip_install( [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 - ), + creationflags=_post_setup_no_window_flags(streams_to_console=not capture_output), ) if result.returncode == 0: return result @@ -184,24 +158,20 @@ def _pip_install( 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}", + 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 - ), + 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 (it returned a package with zero binary assets and made every non-arm64 -# host skip the install), and re-implementing upstream's tag resolution here would drift. Trust the -# installer: fresh installs run install.sh directly (it errors clean on a missing-arch asset); -# upgrades ask the binary via `cua_driver_update_check()`. +# `/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: @@ -227,15 +197,13 @@ def _cua_driver_version(binary: str) -> Optional[str]: def _confirmed_update_check(driver_cmd: str, require_confirmed_update: bool) -> tuple: - """Ask the installed driver whether a newer release exists. + """Ask the installed driver whether a newer release exists; returns ``(proceed, pin_version)``. - Returns ``(proceed, pin_version)``. ``proceed=False`` means the caller should stop with - success (already latest, or indeterminate under ``require_confirmed_update``). Best-effort: an - older driver (no check-update verb) or an offline check yields None; then `hermes update` - keeps the installed version — an indeterminate check must never cost a multi-minute silent - reinstall on every update — while an explicit `computer-use install --upgrade` falls through. + ``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. """ - _state = None try: from tools.computer_use.cua_backend import cua_driver_update_check _state = cua_driver_update_check() @@ -253,22 +221,16 @@ def _confirmed_update_check(driver_cmd: str, require_confirmed_update: bool) -> "(offline, rate-limited, or driver too old to check); " "keeping the installed version." ) - _print_info( - " Force a refresh with: hermes computer-use install --upgrade" - ) + _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 rather than deferring: stdin is closed (a - # consent Read-Host can't block), the version is pinned, the ceiling is - # _CUA_BACKGROUND_UPDATE_TIMEOUT, and the installer preflights skip in seconds when the - # lock is held or GitHub is unreachable. Only contract repairs and fresh installs stay - # interactive-only — there upstream legitimately needs a human (autostart elevation, - # SmartScreen). - # Pin to the release check-update just 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 the assets are published and 404s when installed unpinned. Malformed - # values are ignored → unpinned fallback. + # 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") @@ -284,9 +246,9 @@ def install_cua_driver( ) -> 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. + 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 @@ -310,24 +272,15 @@ def install_cua_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( - " HERMES_CUA_DRIVER_CMD does not resolve to an executable: " - f"{override}" - ) - _print_info( - " Fix or unset the override before running computer-use install." - ) + _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." - ) + _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:") @@ -336,9 +289,9 @@ def install_cua_driver( 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. + # 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")) @@ -350,9 +303,7 @@ def install_cua_driver( 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." - ) + _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 @@ -361,8 +312,7 @@ def install_cua_driver( 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 " - f"runtime contract: {reason}." + 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( @@ -372,24 +322,18 @@ def install_cua_driver( return False if is_windows and require_confirmed_update: _print_info( - " Automatic Windows updates cannot safely run cua-driver's " - "interactive repair installer." + " 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" + " 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." - ) + _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): @@ -403,17 +347,14 @@ def install_cua_driver( 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. + # 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" - ) + _print_info(" Install it from an interactive terminal with: hermes computer-use install --upgrade") return False # Best-effort before/after version display. @@ -444,22 +385,19 @@ def install_cua_driver( return ok -# Ceiling for one upstream-installer run. Must exceed the installer's own stale-lock recovery -# window: _install-rust.sh serializes installs with ~/.cua-driver/packages/.install.lock.d and only -# force-releases a dead holder's lock after LOCK_STALE_AFTER_SECONDS=600. A shorter Python-side -# timeout kills every run before that recovery fires — a permanent "always times out" wedge. -# 660s = 600s lock window + 60s headroom for the download/swap. +# 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 (see -# _reap_after_timeout), so the drain must be bounded: a surviving descendant still holds the -# inherited stdout handle and an unbounded read waits on an EOF that never comes, turning the -# ceiling above into no ceiling. A successful kill closes the pipe immediately, so this is free. +# 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. (The -# lock/network preflights make a legitimate long wait impossible here, so a short ceiling is safe.) +# 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 @@ -469,10 +407,7 @@ _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") - ) + return Path(os.environ.get("CUA_DRIVER_RS_HOME") or str(Path.home() / ".cua-driver")) def _cua_install_lock_dir() -> "Path": @@ -488,9 +423,9 @@ def _cua_windows_install_lock_file() -> "Path": 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. + 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: @@ -511,13 +446,8 @@ def _clear_stale_windows_cua_install_lock() -> None: 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, + _wintypes.LPCWSTR, _wintypes.DWORD, _wintypes.DWORD, _wintypes.LPVOID, + _wintypes.DWORD, _wintypes.DWORD, _wintypes.HANDLE, ] create_file.restype = _wintypes.HANDLE close_handle = kernel32.CloseHandle @@ -536,26 +466,19 @@ def _clear_stale_windows_cua_install_lock() -> 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(), + "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(), + "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, - ) + 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) @@ -567,10 +490,9 @@ def _clear_stale_windows_cua_install_lock() -> None: def _clear_stale_cua_install_lock() -> None: """Best-effort: remove a stale installer lock left by a dead holder. - The POSIX installer stamps its holder pid into ``~/.cua-driver/packages/.install.lock.d/info``. - The Windows installer instead 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. + 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() @@ -625,16 +547,14 @@ def _cua_install_lock_held() -> bool: 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. The stale-clear already deleted an unheld file, so surviving - # = held; confirm with an open probe. + # 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 - lock_dir = _cua_install_lock_dir() - return lock_dir.is_dir() + return _cua_install_lock_dir().is_dir() except Exception as e: logger.debug("cua install lock probe failed: %s", e) return False @@ -643,17 +563,15 @@ def _cua_install_lock_held() -> bool: 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. + 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" - ) + 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: @@ -675,9 +593,7 @@ def _cua_driver_autostart_registered_windows() -> bool: try: result = subprocess.run( ["schtasks.exe", "/Query", "/TN", "cua-driver-serve"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=10, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, ) except Exception: return False @@ -687,9 +603,9 @@ def _cua_driver_autostart_registered_windows() -> bool: 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. + 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 @@ -717,11 +633,7 @@ def _repair_cua_driver_autostart_windows(driver_cmd: str, *, verbose: bool) -> b try: result = subprocess.run( [ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=300, + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=300, env=_cua_driver_env(), ) except subprocess.TimeoutExpired: @@ -777,9 +689,8 @@ def _kill_installer_tree(proc, *, is_windows: bool) -> None: 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 both communicate() and install.lock wedged, - # so collect the tree first and kill it leaf-up. + # 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: @@ -789,9 +700,7 @@ def _kill_installer_tree(proc, *, is_windows: bool) -> None: return except _psutil.Error as e: logger.debug( - "could not enumerate cua-driver installer tree for pid %s: %s", - proc.pid, - e, + "could not enumerate cua-driver installer tree for pid %s: %s", proc.pid, e ) proc.kill() return @@ -802,21 +711,13 @@ def _kill_installer_tree(proc, *, is_windows: bool) -> None: except _psutil.NoSuchProcess: pass except _psutil.Error as e: - logger.debug( - "could not kill cua-driver installer child pid %s: %s", - child.pid, - 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, - ) + logger.debug("could not kill cua-driver installer parent pid %s: %s", proc.pid, e) proc.kill() except (OSError, ProcessLookupError): proc.kill() @@ -825,27 +726,23 @@ def _kill_installer_tree(proc, *, is_windows: bool) -> None: 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. + 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); without it the timeout line is unactionable. + # 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:], + "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. + # 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", + "abandoning the drain, a surviving descendant holds the inherited handle", _CUA_INSTALLER_DRAIN_GRACE, ) except (OSError, ValueError) as e: @@ -857,26 +754,16 @@ def _cua_installer_command(is_windows: bool): 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 = ( - 'powershell -NoProfile -ExecutionPolicy Bypass -Command ' - f'"{ps_oneliner}"' - ) + 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 on multi-user machines. The - # manual hint stays the upstream one-liner the docs teach. + # 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" - ) + 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) @@ -898,11 +785,9 @@ def _cua_installer_command(is_windows: bool): 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 now. - + 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. """ @@ -911,27 +796,20 @@ def _unattended_installer_preflight(install_cmd: list, is_windows: bool): " 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" - ) + _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)." + " 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 (instead of - # `| iex`) is what lets us pass the parameter to a piped script. + # -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", + "-Command", f"$sc = irm {_CUA_INSTALL_PS1_URL}; & ([scriptblock]::Create($sc)) -NoAutoStart", ] return install_cmd @@ -946,8 +824,8 @@ def _run_cua_driver_installer( """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. + 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 @@ -973,8 +851,8 @@ def _run_cua_driver_installer( # 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. + # 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. @@ -983,16 +861,16 @@ def _run_cua_driver_installer( 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. + # 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. + # 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, @@ -1004,9 +882,7 @@ def _run_cua_driver_installer( except subprocess.TimeoutExpired: _reap_after_timeout(proc, is_windows=is_windows) raise - result = subprocess.CompletedProcess( - install_cmd, proc.returncode, stdout=None, stderr=None - ) + result = subprocess.CompletedProcess(install_cmd, proc.returncode, stdout=None, stderr=None) else: proc = subprocess.Popen( install_cmd, shell=False, env=installer_env, @@ -1021,21 +897,15 @@ def _run_cua_driver_installer( 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. + 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.write("\n--- cua-driver installer output ---\n" + result.stdout + "\n") _update_log.flush() except Exception: pass @@ -1043,12 +913,8 @@ def _run_cua_driver_installer( 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 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) @@ -1057,10 +923,7 @@ def _run_cua_driver_installer( _print_info(f" {manual_hint}") return False except subprocess.TimeoutExpired: - _print_warning( - f" cua-driver {label.lower()} timed out after " - f"{timeout}s." - ) + _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 — " diff --git a/hermes_cli/tools_config_mcp.py b/hermes_cli/tools_config_mcp.py index 3b1274b6a8..69260a5091 100644 --- a/hermes_cli/tools_config_mcp.py +++ b/hermes_cli/tools_config_mcp.py @@ -17,9 +17,6 @@ from hermes_cli.toolset_scope import ( ) -# ─── MCP Tools Interactive Configuration ───────────────────────────────────── - - def _mcp_match_filter(): """Runtime name-filter matcher (exact names or fnmatch globs), with a literal fallback. @@ -29,7 +26,6 @@ def _mcp_match_filter(): """ 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 @@ -50,30 +46,22 @@ def _apply_mcp_checklist(server_name: str, tools_cfg: dict, tool_names: List[str exclude_mode = bool(exclude_set) and not include_set if len(chosen) == len(tool_names) and not exclude_mode: - # All tools enabled — clear filters (cleanest config shape; the - # server's native tool set is the active set, and any tools the - # server adds later are auto-enabled). + # 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 are - # added as literal excludes; re-checked literals are dropped; - # glob patterns are preserved (they intentionally keep matching - # tools the vendor ships later). + # 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_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)) - ) + 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 " @@ -89,16 +77,12 @@ def _apply_mcp_checklist(server_name: str, tools_cfg: dict, tool_names: List[str tools_cfg.pop("include", None) else: tools_cfg["include"] = [tool_names[i] for i in sorted(chosen)] - # Drop any legacy exclude block — we're include-mode now. - tools_cfg.pop("exclude", None) + tools_cfg.pop("exclude", None) # include-mode now; drop any legacy exclude block def _configure_mcp_tools_interactive(config: dict): - """Probe MCP servers for available tools and let user toggle them on/off. - - Connects to each server, discovers tools, shows a per-server curses checklist, and writes the - result back as ``tools.exclude`` entries in config.yaml. - """ + """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 @@ -108,10 +92,7 @@ def _configure_mcp_tools_interactive(config: dict): _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"} - ] + 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 @@ -162,10 +143,7 @@ def _configure_mcp_tools_interactive(config: dict): 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, + f"MCP Server: {server_name} ({len(tools)} tools)", labels, pre_selected, cancel_returns=pre_selected, ) if chosen == pre_selected: @@ -175,9 +153,7 @@ def _configure_mcp_tools_interactive(config: dict): 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" - ) + _print_success(f" {server_name}: {len(chosen)} enabled, {len(tools) - len(chosen)} disabled") any_changes = True if any_changes: @@ -188,18 +164,12 @@ def _configure_mcp_tools_interactive(config: dict): print(color(" No changes to MCP tools", Colors.DIM)) -# ─── Non-interactive disable/enable ────────────────────────────────────────── - - 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) - if action == "disable": - updated = enabled - set(toolset_names) - else: - updated = enabled | set(toolset_names) + updated = enabled - set(toolset_names) if action == "disable" else enabled | set(toolset_names) _save_platform_tools(config, platform, updated) @@ -230,10 +200,7 @@ def _print_tools_list(enabled_toolsets: set, mcp_servers: dict, platform: str = 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) - ] + 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): @@ -267,12 +234,9 @@ def _print_tools_list(enabled_toolsets: set, mcp_servers: dict, platform: str = def _known_tool_platforms() -> set[str]: - """Return built-in plus discovered plugin platform names. - - Plugin platforms are registered at runtime rather than in the static CLI display registry. Tool - introspection/configuration must recognize those names too, otherwise an active plugin platform - cannot audit its authority. - """ + """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) @@ -283,8 +247,7 @@ def _known_tool_platforms() -> set[str]: discover_plugins() # idempotent known.update(platform_registry.registered_names()) except Exception: - # Plugin discovery is optional. Preserve the built-in CLI path when a - # third-party plugin is malformed or its dependencies are unavailable. + # Plugin discovery is optional: keep the built-in path when a plugin is malformed or deps are missing. pass return known @@ -319,17 +282,11 @@ def tools_disable_enable_command(args): 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) - ] + 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}' " - f"(only: {', '.join(allowed)})" - ) + _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: diff --git a/hermes_cli/tools_config_post_setup.py b/hermes_cli/tools_config_post_setup.py index 6305595ec5..d86c7fa7ed 100644 --- a/hermes_cli/tools_config_post_setup.py +++ b/hermes_cli/tools_config_post_setup.py @@ -29,23 +29,16 @@ logger = logging.getLogger("hermes_cli.tools_config") PROJECT_ROOT = Path(__file__).parent.parent.resolve() -# ─── Post-Setup Hooks ───────────────────────────────────────────────────────── - - def _ensure_browser_use_cli(*, verbose_hints: bool = False) -> None: """Install the Browser Use CLI if it isn't already runnable. - The Browser Use CLI 3.0 is the primary driver engine for EVERY browser backend except Camofox - (which is Firefox-based with no CDP surface, so the CDP-only browser-use harness cannot drive - it). - - MANAGED-FIRST: a browser-use on the user's PATH does NOT satisfy this check — only the Hermes- - managed ``$HERMES_HOME/bin`` copy does. + 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}" @@ -64,22 +57,15 @@ def _ensure_browser_use_cli(*, verbose_hints: bool = False) -> None: def _post_setup_lightpanda() -> None: - # Browser Use mode drives Lightpanda directly (Hermes spawns - # ``lightpanda serve``); the built-in tools go through agent-browser. - # Neither needs a Chromium build. + # 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, - ) + 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_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.") @@ -119,8 +105,7 @@ def _post_setup_agent_browser(post_setup_key: str) -> None: 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 when runnable — install it here - # too, not only on the explicit "Browser Use" picker row. + # 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. @@ -141,9 +126,7 @@ def _post_setup_agent_browser(post_setup_key: str) -> None: try: browser_cmd = _find_agent_browser(validate=False) except FileNotFoundError: - _print_warning( - " npx not found - browser tools require Node.js: https://nodejs.org" - ) + _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. @@ -157,15 +140,9 @@ def _post_setup_agent_browser(post_setup_key: str) -> None: 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" - ) + _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): @@ -173,9 +150,7 @@ def _post_setup_agent_browser(post_setup_key: str) -> None: # 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" - ) + _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: @@ -192,8 +167,7 @@ def _post_setup_camofox() -> None: _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. + # 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), @@ -213,69 +187,46 @@ def _post_setup_camofox() -> None: _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" -) +_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 (already installed → skip the install) -# label package name used in status lines -# installing progress line printed before the install -# args _pip_install arguments (keep in sync with -# _RESTORABLE_PYTHON_TOOL_DEPENDENCIES) -# manual the "Run manually:" command shown on failure/timeout -# on_install info lines printed only after a fresh successful install -# always info lines printed whenever the package ends up present +# 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", + "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", - ), + "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", + "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)", - ), + "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", + "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", + "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", - ), + "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", + "module": "ddgs", "label": "ddgs", "installing": "Installing ddgs (DuckDuckGo search package)...", - "args": ["-U", "ddgs", "--quiet"], - "manual": "uv pip install -U ddgs", + "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.", - ), + "always": ("No API key required. DuckDuckGo enforces server-side rate limits.", + "Pair with an extract provider if you also need web_extract."), }, } @@ -311,11 +262,8 @@ def _post_setup_pip(spec: dict) -> None: def _post_setup_spotify() -> None: - # Run the full `hermes auth spotify` flow — if the user has no - # client_id yet, this drops them into the interactive wizard - # (opens the Spotify dashboard, prompts for client_id, persists - # to ~/.hermes/.env), then continues straight into PKCE. If they - # already have an app, it skips the wizard and just does OAuth. + # 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 @@ -326,13 +274,11 @@ def _post_setup_spotify() -> None: _print_info(" Starting Spotify login...") try: login_spotify_command(SimpleNamespace( - client_id=None, redirect_uri=None, scope=None, - no_browser=False, timeout=None, + 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; they can retry with `hermes auth spotify`. + # 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: @@ -341,7 +287,6 @@ def _post_setup_spotify() -> None: def _post_setup_langfuse() -> None: - # Install the langfuse SDK. try: __import__("langfuse") _print_success(" langfuse SDK already installed") @@ -352,9 +297,7 @@ def _post_setup_langfuse() -> None: _print_success(" langfuse SDK installed") else: _print_warning(" langfuse SDK install failed — run manually: uv pip install langfuse") - # Opt the bundled observability/langfuse plugin into plugins.enabled. - # The plugin ships in the repo but doesn't load until the user enables - # it (standalone plugins are opt-in). + # 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() @@ -384,9 +327,7 @@ def _post_setup_xai_grok() -> None: 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" - ) + _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") @@ -394,11 +335,7 @@ def _post_setup_xai_grok() -> None: _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.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}") @@ -416,29 +353,21 @@ def _post_setup_xai_grok() -> None: ) if idx == 0: if _run_xai_oauth_login_from_setup(): - _print_success( - " Logged in — xAI will use these OAuth credentials" - ) + _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" - ) + _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" - ) + _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 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"), @@ -461,11 +390,9 @@ def _run_post_setup(post_setup_key: str): def valid_post_setup_keys() -> Set[str]: - """Return the set of post-setup keys declared by any visible provider. - - Collected from ``TOOL_CATEGORIES`` plus plugin-registered web/image/video/browser providers. - This is the allowlist the ``post-setup`` command and dashboard endpoint validate against, so a - caller cannot drive ``_run_post_setup`` with an arbitrary key. + """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, @@ -481,12 +408,9 @@ def valid_post_setup_keys() -> Set[str]: ps = prov.get("post_setup") if ps: keys.add(ps) - # Plugin-registered providers can declare their own post_setup hooks. for builder in ( - _plugin_web_search_providers, - _plugin_image_gen_providers, - _plugin_video_gen_providers, - _plugin_browser_providers, + _plugin_web_search_providers, _plugin_image_gen_providers, + _plugin_video_gen_providers, _plugin_browser_providers, ): try: for prov in builder(): @@ -499,10 +423,8 @@ def valid_post_setup_keys() -> Set[str]: def run_post_setup_command(args) -> int: - """``hermes tools post-setup `` — non-interactive post-setup runner. - - Stable, scriptable target the dashboard spawns so the GUI can drive backend setup without - re-implementing install logic. Returns a process exit code (0 ok, 2 unknown key). + """``hermes tools post-setup `` — 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: @@ -510,10 +432,7 @@ def run_post_setup_command(args) -> int: return 2 valid = valid_post_setup_keys() if key not in valid: - _print_error( - f"Unknown post-setup key: {key!r}. " - f"Valid keys: {', '.join(sorted(valid)) or '(none)'}" - ) + _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: @@ -527,20 +446,18 @@ def run_post_setup_command(args) -> int: # 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 is a silent no-op that 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; other hooks keep their existing behaviour. +# 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.""" + """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: - # No install-state check registered → assume satisfied (don't - # change behaviour for hooks we haven't explicitly opted in). return True try: return bool(predicate()) @@ -558,11 +475,9 @@ def _module_installed(module_name: str) -> bool: return False -# Python dependencies installed explicitly through ``hermes tools`` are not -# part of the managed runtime's locked ``all`` sync. A runtime replacement -# therefore needs a small, static allowlist that can be snapshotted before the -# old site-packages disappears and restored afterward. Keep these install -# arguments in sync with the corresponding ``_run_post_setup`` branches. +# 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")), @@ -575,44 +490,33 @@ _RESTORABLE_PYTHON_TOOL_DEPENDENCIES: dict[str, tuple[str, tuple[str, ...]]] = { 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() - ) + 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: +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). - Mirrors the hook so "Run setup" flips to an installed state only when re-running it would be a - no-op. + 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 install hook runs in a spawned ``hermes tools post-setup`` process, - # but this probe runs in the long-lived web-server/CLI process, whose - # browser_tool module may have cached a stale "Chromium missing" result - # from before the install. Drop the cache (when the module is loaded) so - # the readiness pill flips to Ready right after a successful setup run. + # 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 already in node_modules.""" + """True when the Camofox npm package ``_run_post_setup("camofox")`` installs is in node_modules.""" return (PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser").exists() @@ -620,20 +524,15 @@ 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 provider rows). - - Cloud providers host their own Chromium, so their hook only installs the agent-browser npm - package — presence of the CLI is the whole contract. - """ + """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() @@ -649,4 +548,3 @@ _POST_SETUP_READY: dict = { "lightpanda": lambda: _lightpanda_installed(), "cua_driver": lambda: _cua_driver_install_ready(), } - diff --git a/hermes_cli/tools_config_providers.py b/hermes_cli/tools_config_providers.py index eb73eae5f4..0c0bf99082 100644 --- a/hermes_cli/tools_config_providers.py +++ b/hermes_cli/tools_config_providers.py @@ -21,10 +21,8 @@ from utils import base_url_hostname, is_truthy_value logger = logging.getLogger("hermes_cli.tools_config") -# NOTE: tools_config-internal names (TOOL_CATEGORIES, _cfg_section, _prompt_choice, -# get_nous_subscription_features, post-setup hooks, ...) are imported lazily inside the -# functions that need them: tools_config re-imports this module, and tests patch those -# names on ``hermes_cli.tools_config``. +# tools_config-internal names (TOOL_CATEGORIES, _cfg_section, _prompt_choice, ...) are imported lazily +# inside the functions that need them: tools_config re-imports this module, and tests patch those names there. def _plugin_registry(module: str): @@ -47,14 +45,11 @@ def _plugin_provider_rows( skip_builtin: bool = False, flatten_variants: bool = False, ) -> list[dict]: - """Build picker-row dicts from a plugin registry's providers. + """Picker-row dicts (TOOL_CATEGORIES-shaped) for a plugin registry's providers. - Each row looks like a hardcoded ``TOOL_CATEGORIES`` provider row plus the ``marker_keys`` - (all set to the provider's registry name) that route downstream config-writing / model-picker - code through the plugin registry. ``skip_builtin`` drops providers whose name shadows the - registry's ``_BUILTIN_NAMES`` (defence in depth). ``flatten_variants`` expands a schema's tier - ``variants`` (e.g. Exa/Parallel free keyless vs paid SDK) into separate rows sharing one - backend name, distinguished by ``web_tier``. + ``marker_keys`` are all set to the registry name so downstream config/model-picker code routes through + the registry. ``skip_builtin`` drops names shadowing ``_BUILTIN_NAMES``; ``flatten_variants`` expands a + schema's tier ``variants`` into separate rows sharing one backend name, distinguished by ``web_tier``. """ registry = _plugin_registry(registry_module) if registry is None: @@ -69,9 +64,7 @@ def _plugin_provider_rows( for provider in providers: if require_name: name = getattr(provider, "name", None) - if not name: - continue - if skip_builtin and name.lower().strip() in builtin: + if not name or (skip_builtin and name.lower().strip() in builtin): continue try: schema = provider.get_setup_schema() @@ -85,14 +78,9 @@ def _plugin_provider_rows( if flatten_variants: entries += [v for v in (schema.get("variants") or []) if isinstance(v, dict)] for entry in entries: - row = { - "name": entry.get("name", provider.display_name), - "badge": entry.get("badge", ""), - "tag": entry.get("tag", ""), - "env_vars": entry.get("env_vars", []), - } - for key in marker_keys: - row[key] = name + row = {"name": entry.get("name", provider.display_name), "badge": entry.get("badge", ""), + "tag": entry.get("tag", ""), "env_vars": entry.get("env_vars", [])} + row.update({key: name for key in marker_keys}) if flatten_variants and entry.get("web_tier"): row["web_tier"] = entry["web_tier"] if entry.get("post_setup"): @@ -101,12 +89,10 @@ def _plugin_provider_rows( return rows -# Category -> (registry module, marker keys, _plugin_provider_rows kwargs). Marker semantics: -# image/video ``*_plugin_name`` route config writes + model pickers through the registry (video -# has no in-tree backend, so this is the only row source); web ``web_backend`` + ``web_search_plugin_name`` -# (all bundled web providers are plugins; only the firecrawl setup-flow rows stay hardcoded); -# browser ``browser_provider`` is the legacy key written to ``browser.cloud_provider``; TTS rows -# render below the built-in rows and write ``tts.provider: `` like them. +# Category -> (registry module, marker keys, _plugin_provider_rows kwargs). image/video ``*_plugin_name`` route +# config writes + model pickers through the registry (video has no in-tree backend); all bundled web providers +# are plugins (only firecrawl setup-flow rows stay hardcoded); ``browser_provider`` is the legacy key written to +# ``browser.cloud_provider``; TTS rows render below the built-in rows and write ``tts.provider: ``. _PLUGIN_PROVIDER_ROW_SPECS = { "image_gen": ("agent.image_gen_registry", ("image_gen_plugin_name",), {"require_name": False}), "video_gen": ("agent.video_gen_registry", ("video_gen_plugin_name",), {"require_name": False}), @@ -147,11 +133,10 @@ def _plugin_tts_providers() -> list[dict]: def web_provider_capabilities(backend: str) -> list: - """Return the capabilities (``search`` / ``extract``) a web backend supports. + """Capabilities (``search`` / ``extract``) a web backend supports, per the registry provider instance. - Consults the plugin registry's provider instance (``supports_search`` / ``supports_extract``) so - the Capabilities GUI can offer per-capability selection (``web.search_backend`` / - ``web.extract_backend``) only where it makes sense — e.g. ddgs and brave-free are search-only. + Lets the Capabilities GUI offer ``web.search_backend`` / ``web.extract_backend`` only where it makes sense + (ddgs and brave-free are search-only). Unknown backend or registry failure -> both. """ try: from agent.web_search_registry import get_provider @@ -186,34 +171,24 @@ def _visible_providers( force_fresh: bool = False, features: Optional[NousSubscriptionFeatures] = None, ) -> list[dict]: - """Return provider entries visible for the current auth/config state. + """Provider entries visible for the current auth/config state. - Nous-managed Tool Gateway rows (``managed_nous_feature``) are always shown — even to logged-out - / unentitled users — so the picker advertises that the capability exists. + Nous-managed rows (``managed_nous_feature``) are always shown, even logged-out/unentitled, to advertise + the capability. """ from hermes_cli.tools_config import get_nous_subscription_features if features is None: features = get_nous_subscription_features(config, force_fresh=force_fresh) acct = features.account_info - # Pool-only users (free tool pool, no paid access) get image gen but NOT video gen — the pool - # doesn't fund `fal-video`, so hide the managed video row rather than advertise a denial. - # Logged-out users still see it (advertising) and paid users are entitled to it. - pool_only = bool( - acct - and acct.logged_in - and acct.paid_service_access is not True - and acct.tool_gateway_entitled - ) + # Pool-only users (free tool pool, no paid access) get image gen but NOT video gen — the pool doesn't + # fund `fal-video`, so hide the managed video row rather than advertise a denial. + pool_only = bool(acct and acct.logged_in and acct.paid_service_access is not True and acct.tool_gateway_entitled) visible = [] for provider in cat.get("providers", []): - # Managed rows stay visible regardless of auth (selecting one drives an inline Portal - # login); a `requires_nous_auth` row without a managed feature hides until logged in. - if ( - provider.get("requires_nous_auth") - and not provider.get("managed_nous_feature") - and not features.nous_auth_present - ): + # Managed rows stay visible regardless of auth (selecting one drives an inline Portal login); a + # `requires_nous_auth` row without a managed feature hides until logged in. + if provider.get("requires_nous_auth") and not provider.get("managed_nous_feature") and not features.nous_auth_present: continue if ( pool_only @@ -223,12 +198,10 @@ def _visible_providers( continue visible.append(provider) - # Plugin-registered rows render BELOW the hardcoded rows. For web and browser they are the - # only real provider rows — the hardcoded leftovers are non-provider UX setup flows. + # Plugin-registered rows render BELOW the hardcoded rows (for web/browser they are the only real provider rows). builder = _PLUGIN_ROW_BUILDERS.get(cat.get("name")) if builder is not None: visible.extend(builder()) - return visible @@ -239,11 +212,10 @@ def provider_readiness_status( features=None, is_active: Optional[bool] = None, ) -> str: - """Compute an honest readiness state for a provider picker row. + """Honest readiness state for a provider picker row. - ``features`` (a ``NousSubscriptionFeatures``) can be passed to avoid re-fetching portal state - per row. ``is_active`` is the completed-setup fallback signal for post_setup hooks with no - registered installed-check (selecting a row runs its hook, so the active row has been set up). + ``features`` avoids re-fetching portal state per row. ``is_active`` is the completed-setup fallback for + post_setup hooks with no registered installed-check (selecting a row runs its hook). """ from hermes_cli.tools_config import ( _POST_SETUP_READY, @@ -265,19 +237,11 @@ def provider_readiness_status( # Same per-category entitlement gate the CLI applies at selection time. acct = features.account_info category = MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature) - entitled = bool( - acct - and acct.logged_in - and ( - acct.tool_gateway_entitled_for(category) - if category - else acct.tool_gateway_entitled - ) - ) + entitled = bool(acct and acct.logged_in and ( + acct.tool_gateway_entitled_for(category) if category else acct.tool_gateway_entitled)) if not entitled: return "needs_auth" - # Signed in and entitled — fall through: a managed row may still carry a local install - # hook (e.g. the managed browser row needs the agent-browser CLI on this machine). + # Signed in and entitled — fall through: a managed row may still carry a local install hook. post_setup = provider.get("post_setup") if post_setup: @@ -288,8 +252,7 @@ def provider_readiness_status( try: return "ready" if predicate() else "needs_setup" except Exception: - # Flaky detection must not manufacture a warning state. - return "ready" + return "ready" # flaky detection must not manufacture a warning state # No installed-check registered → the active-provider signal means "setup completed". if is_active is None: is_active = _is_provider_active(provider, config) @@ -323,11 +286,9 @@ def _toolset_needs_configuration_prompt( if selection_key: section = config.get(ts_key, {}) return not isinstance(section, dict) or selection_key not in section - if ts_key == "image_gen": - # Satisfied by the in-tree FAL backend OR any available plugin image gen provider. + if ts_key == "image_gen": # in-tree FAL backend OR any available plugin image gen provider satisfies return not fal_key_is_configured() and not _any_plugin_provider_available("agent.image_gen_registry") - if ts_key == "video_gen": - # No in-tree fallback — every video backend is a plugin. + if ts_key == "video_gen": # no in-tree fallback — every video backend is a plugin return not _any_plugin_provider_available("agent.video_gen_registry") return not _toolset_has_keys(ts_key, config, force_fresh=force_fresh) @@ -360,9 +321,8 @@ def _configure_tool_category( ): """Provider selection for a tool category, then API-key setup for the chosen row. - ``reconfigure`` is the "Reconfigure an existing tool" flow: no setup note / skip row / Nous - marker, and the chosen provider goes through the key-update prompts instead of the new-enable - prompts. + ``reconfigure`` ("Reconfigure an existing tool"): no setup note / skip row / Nous marker, and the chosen + provider goes through the key-update prompts instead of the new-enable prompts. """ from hermes_cli.tools_config import _prompt_choice, _provider_env_ready, get_nous_subscription_features @@ -389,8 +349,7 @@ def _configure_tool_category( _print_info(f" {cat['setup_note']}") print() - # Logged-in Nous users get a marker on rows included in their subscription so it is obvious - # which options cost extra vs. nothing on top of Nous. + # Logged-in Nous users get a marker on rows included in their subscription (cost-extra vs. included). _nous_logged_in = False if not reconfigure: try: @@ -398,8 +357,7 @@ def _configure_tool_category( except Exception: _nous_logged_in = False - # Plain text labels only (no ANSI codes in menu items) - provider_choices = [] + provider_choices = [] # plain text labels only (no ANSI codes in menu items) for p in providers: badge = f" [{p['badge']}]" if p.get("badge") else "" tag = f" — {p['tag']}" if p.get("tag") else "" @@ -409,46 +367,35 @@ def _configure_tool_category( configured = " [active]" elif p.get("env_vars", []): configured = " [configured]" - # Logged-in subscribers get the "included" star; everyone else a "via Nous Portal" hint - # so it is clear selecting the row triggers a Portal login. + # Subscribers get the "included" star; everyone else a hint that selecting triggers a Portal login. sub_marker = "" if not reconfigure and p.get("managed_nous_feature"): - if _nous_logged_in: - sub_marker = " ★ Included with your Nous subscription" - else: - sub_marker = " ★ via Nous Portal (login on select)" + sub_marker = " ★ Included with your Nous subscription" if _nous_logged_in else " ★ via Nous Portal (login on select)" provider_choices.append(f"{p['name']}{badge}{tag}{configured}{sub_marker}") if not reconfigure: provider_choices.append("Skip — keep defaults / configure later") default_idx = _detect_active_provider_index(providers, config, force_fresh=force_fresh) - question = " Select provider:" if reconfigure else f" {title}:" provider_idx = _prompt_choice(question, provider_choices, default_idx) - if provider_idx >= len(providers): _print_info(f" Skipped {name}") return - _configure_provider(providers[provider_idx], config, force_fresh=force_fresh, reconfigure=reconfigure) def _web_tier_matches(provider: dict, config: dict) -> bool: - """Return True when a web picker row's tier matches the configured tier. + """True when a web picker row's tier matches the configured tier (``web.provider_tier.``). - Tiered rows (Exa/Parallel Free vs Paid) share one ``web_backend`` name and differ only in - ``web_tier``; the configured tier lives at ``web.provider_tier.`` (set on selection). - Rules: no ``web_tier`` on the row → tier-agnostic, matches; configured tier set → must equal the - row's tier; unset → "auto": paid when the row's key is present, free otherwise (highlight the - row the runtime would actually use). + Tiered rows (Exa/Parallel Free vs Paid) share one ``web_backend`` and differ only in ``web_tier``. No + ``web_tier`` on the row → matches; configured tier set → must equal the row's tier; unset → "auto": + paid when the row's key is present, free otherwise (highlight the row the runtime would actually use). """ row_tier = provider.get("web_tier") if not row_tier: return True - web_cfg = config.get("web") - if not isinstance(web_cfg, dict): - web_cfg = {} + web_cfg = config.get("web") if isinstance(config.get("web"), dict) else {} tiers = web_cfg.get("provider_tier") if not isinstance(tiers, dict): tiers = {} @@ -495,8 +442,8 @@ def _managed_provider_active(provider: dict, config: dict, managed_feature: str, current = cfg_get(config, "stt", "provider") return feature.managed_by_nous and current in {provider["stt_provider"], NOUS_MANAGED_PROVIDER} if "browser_provider" in provider: - # Browser Use mode is a driver on top of the provider (attaches to its CDP endpoint), so - # the provider row stays active alongside the Browser Use row. + # Browser Use mode is a driver on top of the provider (attaches to its CDP endpoint), so the + # provider row stays active alongside the Browser Use row. current = cfg_get(config, "browser", "cloud_provider") return feature.managed_by_nous and current in {provider["browser_provider"], NOUS_MANAGED_PROVIDER} if provider.get("web_backend"): @@ -510,8 +457,8 @@ def _managed_provider_active(provider: dict, config: dict, managed_feature: str, def _browser_use_default_active(config: dict) -> bool: - """``browser.backend`` unset: Browser Use mode is the default, so the row is active whenever - the effective mode resolves on (legacy direct-API cloud config, or CLI runnable and no Camofox).""" + """``browser.backend`` unset: Browser Use mode is the default, so the row is active whenever the + effective mode resolves on (legacy direct-API cloud config, or CLI runnable and no Camofox).""" browser_cfg = config.get("browser") if isinstance(config, dict) else None try: from tools.browser_use_cli import _find_cli, is_legacy_browser_use_cloud_config @@ -540,12 +487,11 @@ def _stt_active(provider: dict, config: dict) -> bool: def _browser_provider_active(provider: dict, config: dict) -> bool: - # Browser Use mode composes with the provider (driver over its CDP endpoint) — don't - # deactivate the provider row. + # Browser Use mode composes with the provider (driver over its CDP endpoint) — don't deactivate the row. if provider["browser_provider"] != cfg_get(config, "browser", "cloud_provider"): return False - # Two local rows differ only by engine ("Local Browser" vs "Lightpanda"): config.yaml is the - # picker's source of truth here, the AGENT_BROWSER_ENGINE env var is not consulted. + # Two local rows differ only by engine ("Local Browser" vs "Lightpanda"): config.yaml is the picker's + # source of truth here, the AGENT_BROWSER_ENGINE env var is not consulted. if provider.get("browser_engine"): engine = str(cfg_get(config, "browser", "engine") or "auto").strip().lower() return engine == provider["browser_engine"] @@ -566,9 +512,7 @@ def _browser_backend_active(provider: dict, config: dict) -> bool: def _web_backend_active(provider: dict, config: dict) -> bool: - if cfg_get(config, "web", "backend") != provider["web_backend"]: - return False - return _web_tier_matches(provider, config) + return cfg_get(config, "web", "backend") == provider["web_backend"] and _web_tier_matches(provider, config) def _computer_use_active(provider: dict, config: dict) -> bool: @@ -577,18 +521,16 @@ def _computer_use_active(provider: dict, config: dict) -> bool: def _imagegen_backend_active(provider: dict, config: dict) -> bool: image_cfg = config.get("image_gen", {}) - if not isinstance(image_cfg, dict): - return False return ( - provider["imagegen_backend"] == "fal" + isinstance(image_cfg, dict) + and provider["imagegen_backend"] == "fal" and image_cfg.get("provider") in {None, "", "fal"} and not is_truthy_value(image_cfg.get("use_gateway"), default=False) ) -# Non-managed active checks, evaluated in order; the first marker the row carries decides. -# ``browser_provider`` is a membership test (a local row carries ``browser_provider: ""``), -# every other marker is a truthiness test. +# Non-managed active checks, evaluated in order; the first marker the row carries decides. ``browser_provider`` +# is a membership test (a local row carries ``browser_provider: ""``), every other marker is a truthiness test. _ACTIVE_CHECKS: tuple[tuple[str, Callable[[dict, dict], bool]], ...] = ( ("tts_provider", _tts_active), ("stt_provider", _stt_active), @@ -611,8 +553,8 @@ def _is_provider_active( managed_feature = provider.get("managed_nous_feature") plugin_name = provider.get("image_gen_plugin_name") if plugin_name and not managed_feature: - # Managed entries fall through to the managed branch, which also checks use_gateway — - # otherwise a managed FAL pick and a direct-key FAL pick would both report active. + # Managed entries fall through to the managed branch, which also checks use_gateway — otherwise a + # managed FAL pick and a direct-key FAL pick would both report active. image_cfg = config.get("image_gen", {}) if not (isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name): return False @@ -646,40 +588,27 @@ def _detect_active_provider_index( for i, p in enumerate(providers): if _is_provider_active(p, config, force_fresh=force_fresh): return i - # Fallback: env vars present → likely configured - if p.get("env_vars", []) and _provider_env_ready(p): + if p.get("env_vars", []) and _provider_env_ready(p): # fallback: env vars present → likely configured return i return 0 -# ─── Image Generation Model Pickers ─────────────────────────────────────────── -# -# IMAGEGEN_BACKENDS: per-backend catalog (config_key = top-level config.yaml section, catalog_fn -> -# ({model_id: metadata}, default_model)). A TOOL_CATEGORIES row tagged `imagegen_backend: ""` -# selects the catalog at picker time. - - def _fal_model_catalog(): """Lazy-load the FAL model catalog from the tool module.""" from tools.image_generation_tool import FAL_MODELS, DEFAULT_MODEL return FAL_MODELS, DEFAULT_MODEL +# Per-backend model catalog (config_key = top-level config.yaml section, catalog_fn -> ({model_id: metadata}, +# default_model)); a TOOL_CATEGORIES row tagged `imagegen_backend: ""` selects the catalog at picker time. IMAGEGEN_BACKENDS = { - "fal": { - "display": "FAL.ai", - "config_key": "image_gen", - "catalog_fn": _fal_model_catalog, - }, + "fal": {"display": "FAL.ai", "config_key": "image_gen", "catalog_fn": _fal_model_catalog}, } def _plugin_model_catalog(registry_module: str, plugin_name: str): - """Return ``(catalog_dict, default_model_id)`` for a plugin provider. - - ``catalog_dict`` is shaped like the legacy ``FAL_MODELS`` table so the picker code path is - shared. Returns ``({}, None)`` if the provider is unregistered or has no models. - """ + """``(catalog_dict, default_model_id)`` for a plugin provider; ``catalog_dict`` is shaped like the legacy + ``FAL_MODELS`` table so the picker path is shared. ``({}, None)`` if unregistered or no models.""" registry = _plugin_registry(registry_module) if registry is None: return {}, None @@ -719,10 +648,9 @@ def _pick_model_from_catalog( ) -> None: """Column-aligned model picker shared by the FAL, plugin image gen and video gen flows. - Writes the choice to ``config[cfg_key]["model"]``. The current model is listed first so the - cursor lands on it; a saved model that belongs to another provider (shared config key) or a - drifted catalog default never indexes the catalog. Safe when stdin is not a TTY — - curses_radiolist keeps the current selection. + Writes the choice to ``config[cfg_key]["model"]``. The current model is listed first so the cursor lands + on it; a saved model belonging to another provider (shared config key) or a drifted catalog default never + indexes the catalog. Safe when stdin is not a TTY — curses_radiolist keeps the current selection. """ from hermes_cli.tools_config import _cfg_section, _prompt_choice @@ -735,7 +663,6 @@ def _pick_model_from_catalog( model_ids = list(catalog.keys()) ordered = [current_model] + [m for m in model_ids if m != current_model] - widths = { "model": max(len(m) for m in model_ids), "speed": max((len(catalog[m].get("speed", "")) for m in model_ids), default=6), @@ -743,29 +670,20 @@ def _pick_model_from_catalog( } print() - header = ( - f" {'Model':<{widths['model']}} " - f"{'Speed':<{widths['speed']}} " - f"{'Strengths':<{widths['strengths']}} " - f"Price" - ) + header = (f" {'Model':<{widths['model']}} {'Speed':<{widths['speed']}} " + f"{'Strengths':<{widths['strengths']}} Price") print(color(header, Colors.CYAN)) rows = [] for mid in ordered: meta = catalog[mid] - row = ( - f"{row_indent}{mid:<{widths['model']}} " - f"{meta.get('speed', ''):<{widths['speed']}} " - f"{meta.get('strengths', ''):<{widths['strengths']}} " - f"{meta.get('price', '')}" - ) + row = (f"{row_indent}{mid:<{widths['model']}} {meta.get('speed', ''):<{widths['speed']}} " + f"{meta.get('strengths', ''):<{widths['strengths']}} {meta.get('price', '')}") if mid == current_model: row += " ← currently in use" rows.append(row) idx = _prompt_choice(f" Choose {display} model:", rows, default=0) - chosen = ordered[idx] cur_cfg["model"] = chosen _print_success(f" Model set to: {chosen}") @@ -805,15 +723,9 @@ def _configure_xai_imagine_storage(section_name: str, config: dict) -> None: " xAI Imagine can store generated media and create reusable public URLs. " "xAI may bill for stored files and public URL hosting." ) - idx = _prompt_choice( - " Stored public URLs:", - [ - "Enable public URLs without automatic expiry (recommended)", - "Disable stored public URLs", - "Enable public URLs for 2 days", - ], - default=0, - ) + choices = ["Enable public URLs without automatic expiry (recommended)", "Disable stored public URLs", + "Enable public URLs for 2 days"] + idx = _prompt_choice(" Stored public URLs:", choices, default=0) if idx == 1: storage_cfg["enabled"] = False _print_success(" xAI stored public URLs disabled") @@ -830,11 +742,10 @@ def _configure_xai_imagine_storage(section_name: str, config: dict) -> None: def _select_plugin_gen_provider(section: str, plugin_name: str, config: dict, *, use_gateway: bool) -> None: - """Persist a plugin-backed image/video generation provider selection and run its model picker. + """Persist a plugin-backed image/video gen provider selection and run its model picker. - ``use_gateway=True`` (Nous-managed pick) stores ``
.provider: nous``; BYOK picks store - the plugin name. Any legacy ``use_gateway`` key is removed so old read-time shims cannot - override the fresh selection. + ``use_gateway=True`` (Nous-managed pick) stores ``
.provider: nous``; BYOK picks store the plugin + name. Any legacy ``use_gateway`` key is removed so old read-time shims cannot override the selection. """ from hermes_cli.tools_config import _cfg_section @@ -858,9 +769,9 @@ def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gat _select_plugin_gen_provider("video_gen", plugin_name, config, use_gateway=use_gateway) -# Per-provider STT model catalogs for the picker; keys are ``stt.`` sections, first entry -# is the default. Kept in sync with the dashboard selects (web_server _CONFIG_FIELD_META) and the -# desktop settings enums (apps/desktop/src/app/settings/constants.ts). +# Per-provider STT model catalogs for the picker; keys are ``stt.`` sections, first entry is the +# default. Kept in sync with the dashboard selects (web_server _CONFIG_FIELD_META) and the desktop settings +# enums (apps/desktop/src/app/settings/constants.ts). STT_MODEL_CATALOG = { "local": ["base", "tiny", "small", "medium", "large-v3"], "groq": ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"], @@ -906,11 +817,10 @@ _PROVIDER_MARKER_SECTIONS = { def _write_provider_config(provider: dict, config: dict, *, managed_feature) -> None: """Persist the provider/backend config keys for a selected provider. - Pure, non-interactive core of :func:`_configure_provider`: no env prompts, post-setup hooks, - Nous auth gating or model pickers. Both the CLI and the GUI ``PUT .../provider`` endpoint call - through here so there is one code path. Each pick writes exactly ONE provider string per - category (``nous`` for managed rows) and removes any legacy ``use_gateway`` key so the read-time - shim cannot override the new choice. + Pure, non-interactive core of :func:`_configure_provider` (no env prompts, post-setup hooks, Nous auth + gating or model pickers) shared by the CLI and the GUI ``PUT .../provider`` endpoint. Each pick writes + exactly ONE provider string per category (``nous`` for managed rows) and removes any legacy + ``use_gateway`` key so the read-time shim cannot override the new choice. """ from hermes_cli.tools_config import TOOL_CATEGORIES, _cfg_section @@ -932,15 +842,13 @@ def _write_provider_config(provider: dict, config: dict, *, managed_feature) -> bp = provider["browser_provider"] browser_cfg = config.setdefault("browser", {}) if bp or managed_feature: - # Browser Use mode (browser.backend) composes with the provider — switching providers - # keeps the driver choice intact. + # Browser Use mode (browser.backend) composes with the provider — keep the driver choice intact. _set_selection("browser", "cloud_provider", bp) else: browser_cfg.pop("use_gateway", None) if provider.get("browser_backend"): config.setdefault("browser", {})["backend"] = provider["browser_backend"] - # Local engine rows ("Local Browser" resets to auto, "Lightpanda" sets lightpanda). Composes - # with browser.backend like the provider does. + # Local engine rows ("Local Browser" resets to auto, "Lightpanda" sets lightpanda); composes with browser.backend. if provider.get("browser_engine"): config.setdefault("browser", {})["engine"] = provider["browser_engine"] @@ -948,30 +856,28 @@ def _write_provider_config(provider: dict, config: dict, *, managed_feature) -> _set_selection("web", "backend", provider["web_backend"]) web_cfg = config.get("web") if isinstance(web_cfg, dict): - if provider.get("web_tier"): - tiers = web_cfg.setdefault("provider_tier", {}) - if isinstance(tiers, dict): - tiers[provider["web_backend"]] = provider["web_tier"] - else: - stale_tiers = web_cfg.get("provider_tier") - if isinstance(stale_tiers, dict): - stale_tiers.pop(provider["web_backend"], None) + tier = provider.get("web_tier") + tiers = web_cfg.setdefault("provider_tier", {}) if tier else web_cfg.get("provider_tier") + if isinstance(tiers, dict): + if tier: + tiers[provider["web_backend"]] = tier + else: + tiers.pop(provider["web_backend"], None) if provider.get("computer_use_backend"): config.setdefault("computer_use", {})["backend"] = provider["computer_use_backend"] if managed_feature and managed_feature not in {"web", "tts", "stt", "browser"}: - # Managed rows for categories without a marker above (image_gen/video_gen "Nous - # Subscription" rows carry only managed_nous_feature) still persist the "nous" selection. + # Managed rows without a marker above (image_gen/video_gen "Nous Subscription" rows carry only + # managed_nous_feature) still persist the "nous" selection. section = config.setdefault(managed_feature, {}) if isinstance(section, dict): section["provider"] = NOUS_MANAGED_PROVIDER _drop_use_gateway(section) elif not managed_feature: - # Non-gateway pick — clear any stale legacy use_gateway key on the category so the - # read-time shim cannot override it. Resolve the category from the row's own markers first - # (plugin-injected rows are NOT in TOOL_CATEGORIES' hardcoded lists), then fall back to the - # category-membership walk. + # Non-gateway pick — clear any stale legacy use_gateway key on the category. Resolve the category from + # the row's own markers first (plugin-injected rows are NOT in TOOL_CATEGORIES' hardcoded lists), then + # fall back to the category-membership walk. sections = [section_key for marker, section_key in _PROVIDER_MARKER_SECTIONS.items() if marker in provider] if not sections: sections = [cat_key for cat_key, cat in TOOL_CATEGORIES.items() if provider in cat.get("providers", [])][:1] @@ -980,12 +886,9 @@ def _write_provider_config(provider: dict, config: dict, *, managed_feature) -> def apply_provider_selection(ts_key: str, provider_name: str, config: dict) -> None: - """Non-interactively persist a provider selection for a toolset. - - Resolves ``provider_name`` among the rows the picker shows (:func:`_visible_providers`) and - writes the config keys only — API keys, post-setup hooks, auth gating and model pickers are - handled by separate GUI endpoints. Raises ``KeyError`` for an unknown toolset or provider. - """ + """Non-interactively persist a provider selection for a toolset (config keys only — API keys, post-setup + hooks, auth gating and model pickers are separate GUI endpoints). ``provider_name`` is resolved among + :func:`_visible_providers` rows; raises ``KeyError`` for an unknown toolset or provider.""" from hermes_cli.tools_config import TOOL_CATEGORIES, _cfg_section cat = TOOL_CATEGORIES.get(ts_key) @@ -1000,10 +903,9 @@ def apply_provider_selection(ts_key: str, provider_name: str, config: dict) -> N managed_feature = provider.get("managed_nous_feature") _write_provider_config(provider, config, managed_feature=managed_feature) - # Plugin image/video gen backends record the provider name in their own section (model choice - # is a separate GUI flow); managed picks store "nous". The in-tree FAL BYOK row always persists - # an explicit ``image_gen.provider: fal`` so a deliberate pick is distinguishable from a - # never-configured install. + # Plugin image/video gen backends record the provider name in their own section (model choice is a separate + # GUI flow); managed picks store "nous". The in-tree FAL BYOK row always persists an explicit + # ``image_gen.provider: fal`` so a deliberate pick is distinguishable from a never-configured install. selections = [ ("image_gen", provider.get("image_gen_plugin_name")), ("video_gen", provider.get("video_gen_plugin_name")), @@ -1019,10 +921,9 @@ def apply_provider_selection(ts_key: str, provider_name: str, config: dict) -> N def _nous_provider_gate(provider: dict, config: dict, managed_feature, *, force_fresh: bool) -> bool: """Return False (after printing why) when a Nous-gated row cannot be selected. - Managed Tool Gateway rows are always listed (see ``_visible_providers``) but only *activate* - with paid Nous Portal access — selecting one runs an inline Portal login (auth + entitlement - only, no inference-provider switch). Pure pre-auth UX rows (``requires_nous_auth`` without a - managed feature) keep the older logged-in + entitled gate. + Managed Tool Gateway rows are always listed but only *activate* with paid Nous Portal access — selecting + one runs an inline Portal login (auth + entitlement only, no inference-provider switch). Pure pre-auth UX + rows (``requires_nous_auth`` without a managed feature) keep the older logged-in + entitled gate. """ from hermes_cli.tools_config import get_nous_subscription_features @@ -1042,8 +943,7 @@ def _nous_provider_gate(provider: dict, config: dict, managed_feature, *, force_ entitled = bool(features.account_info and features.account_info.paid_service_access is True) if not features.nous_auth_present or not entitled: message = format_nous_portal_entitlement_message( - features.account_info, - capability=f"{provider.get('name', 'Nous Subscription')}", + features.account_info, capability=f"{provider.get('name', 'Nous Subscription')}" ) _print_warning(f" {message or 'Nous Subscription is only available after logging into Nous Portal.'}") return False @@ -1065,8 +965,8 @@ def _finish_provider_selection(provider: dict, config: dict, managed_feature) -> backend = provider.get("imagegen_backend") if backend: _configure_imagegen_model(backend, config) - # In-tree FAL is the only non-plugin backend. Persist "nous" for a managed row, "fal" for - # BYOK, and drop legacy use_gateway — never clobber a managed pick back onto direct keys. + # In-tree FAL is the only non-plugin backend: "nous" for a managed row, "fal" for BYOK, drop legacy + # use_gateway — never clobber a managed pick back onto direct keys. img_cfg = _cfg_section(config, "image_gen") img_cfg["provider"] = NOUS_MANAGED_PROVIDER if managed_feature else "fal" img_cfg.pop("use_gateway", None) @@ -1103,8 +1003,8 @@ def _print_provider_selection(provider: dict, managed_feature, *, reconfigure: b def _show_portal_hint(provider: dict, config: dict, managed_feature, force_fresh: bool) -> bool: - """True when a BYOK row shares its category with a Nous-managed sibling and the user is not - authed to Nous — a single dim hint tells them the key is avoidable via a Portal subscription.""" + """True when a BYOK row shares its category with a Nous-managed sibling and the user is not authed to + Nous — a single dim hint tells them the key is avoidable via a Portal subscription.""" from hermes_cli.tools_config import TOOL_CATEGORIES, get_nous_subscription_features if managed_feature or provider.get("requires_nous_auth"): @@ -1120,10 +1020,10 @@ def _show_portal_hint(provider: dict, config: dict, managed_feature, force_fresh def _prompt_env_vars(env_vars: list, *, reconfigure: bool) -> bool: - """Prompt for a provider's env vars; returns True when every key ended up configured. + """Prompt for a provider's env vars; True when every key ended up configured. - Reconfigure mode re-prompts every key ("Enter to keep current") and always returns True; the - new-enable flow keeps already-set keys without asking and reports False on any skipped key. + Reconfigure mode re-prompts every key ("Enter to keep current") and always returns True; the new-enable + flow keeps already-set keys without asking and reports False on any skipped key. """ all_configured = True for var in env_vars: @@ -1171,9 +1071,8 @@ def _configure_provider( ): """Configure a single provider - prompt for API keys and set config. - ``reconfigure=False`` is the new-enable flow: already-set keys are kept without asking, and - the post-setup hook only runs when every key was provided. ``reconfigure=True`` re-prompts - every key ("Enter to keep current") and always runs the post-setup hook. + ``reconfigure=False`` (new-enable): already-set keys are kept without asking and the post-setup hook only + runs when every key was provided. ``reconfigure=True`` re-prompts every key and always runs the hook. """ from hermes_cli.tools_config import _run_post_setup @@ -1184,8 +1083,7 @@ def _configure_provider( return _print_provider_selection(provider, managed_feature, reconfigure=reconfigure) - # Shared with the GUI provider-select endpoint (apply_provider_selection): single source of - # truth for the config-key writes. + # Shared with the GUI provider-select endpoint (apply_provider_selection): one source of truth for config writes. _write_provider_config(provider, config, managed_feature=managed_feature) if not env_vars: @@ -1201,7 +1099,6 @@ def _configure_provider( _print_info(" Available through Nous Portal subscription.") all_configured = _prompt_env_vars(env_vars, reconfigure=reconfigure) - if provider.get("post_setup") and all_configured: _run_post_setup(provider["post_setup"]) if all_configured: @@ -1216,12 +1113,10 @@ def _reconfigure_provider(provider: dict, config: dict, *, force_fresh: bool = T def _configure_vision_backend() -> None: - """Interactive vision-backend configuration. + """Interactive vision-backend configuration (``auxiliary.vision.{provider,model,base_url}``). - Vision resolves from ``auxiliary.vision.{provider,model,base_url}``. Rather than forcing - OpenRouter, offer any authenticated provider + model (same surface as ``hermes model``) or a - custom endpoint. "Auto" leaves the keys empty so the resolver uses the main-model fallback - chain. + Offers any authenticated provider + model (same surface as ``hermes model``) or a custom endpoint rather + than forcing OpenRouter. "Auto" leaves the keys empty so the resolver uses the main-model fallback chain. """ from hermes_cli.tools_config import _cfg_section, _prompt_choice @@ -1263,9 +1158,9 @@ def _configure_vision_backend() -> None: default_model = "gpt-4o-mini" if is_native_openai else "" model = _prompt(f" Vision model{f' (blank for {default_model})' if default_model else ''}").strip() or default_model save_env_value("OPENAI_API_KEY", api_key.strip()) - # Only base_url + model go to config.yaml; the key is the secret. Pin provider="custom" so - # the resolver routes through this endpoint — at the "auto" default - # _resolve_task_provider_model ignores base_url unless paired with a config api_key. + # Only base_url + model go to config.yaml; the key is the secret. Pin provider="custom" so the resolver + # routes through this endpoint — at the "auto" default _resolve_task_provider_model ignores base_url + # unless paired with a config api_key. vision_cfg["provider"] = "custom" vision_cfg["base_url"] = base_url if model: @@ -1276,16 +1171,15 @@ def _configure_vision_backend() -> None: _print_success(f" Vision set to custom endpoint{f' ({model})' if model else ''}") return - # Skip _print_info(" Skipped vision configuration") def _configure_vision_provider_model(config: dict, vision_cfg: dict) -> None: """Provider + model picker for vision, mirroring the ``/model`` surface. - Rows come from ``build_aux_picker_rows()`` so this lists exactly what the ``hermes model`` - aux-task picker lists, including user-defined ``providers:`` / ``custom_providers:`` endpoints. - Persists ``auxiliary.vision.provider`` + ``.model``. + Rows come from ``build_aux_picker_rows()`` so this lists exactly what the ``hermes model`` aux-task picker + lists, including user-defined ``providers:`` / ``custom_providers:`` endpoints. Persists + ``auxiliary.vision.provider`` + ``.model``. """ from hermes_cli.tools_config import _prompt_choice @@ -1300,12 +1194,8 @@ def _configure_vision_provider_model(config: dict, vision_cfg: dict) -> None: current_base_url = str(vision_cfg.get("base_url") or "").strip() try: - providers = build_aux_picker_rows( - current_provider=current_provider, - current_model=current_model, - current_base_url=current_base_url, - max_models=40, - ) + providers = build_aux_picker_rows(current_provider=current_provider, current_model=current_model, + current_base_url=current_base_url, max_models=40) except Exception as exc: _print_warning(f" Could not detect providers: {exc}") providers = [] @@ -1317,14 +1207,8 @@ def _configure_vision_provider_model(config: dict, vision_cfg: dict) -> None: ) return - provider_labels = [ - label - for _slug, label, _models in format_aux_picker_entries( - providers, - current_provider=current_provider, - current_base_url=current_base_url, - ) - ] + provider_labels = [label for _slug, label, _models in format_aux_picker_entries( + providers, current_provider=current_provider, current_base_url=current_base_url)] provider_labels.append("Cancel") pidx = _prompt_choice(" Choose vision provider:", provider_labels, 0) @@ -1335,7 +1219,6 @@ def _configure_vision_provider_model(config: dict, vision_cfg: dict) -> None: chosen = providers[pidx] slug = chosen.get("slug") models = list(chosen.get("models", [])) - model_choices = list(models) + ["Type a custom model id…"] midx = _prompt_choice(f" Choose vision model for {chosen.get('name') or slug}:", model_choices, 0) if midx < len(models): @@ -1358,9 +1241,8 @@ def _configure_vision_provider_model(config: dict, vision_cfg: dict) -> None: def _configure_simple_requirements(ts_key: str, *, reconfigure: bool = False): """Fallback for toolsets that just need env vars (no provider selection). - Vision has its own provider/model picker (any provider, like ``hermes model``) — run it - directly so neither flow falls back to the generic single-key prompt (which would re-ask for - OPENROUTER_API_KEY). + Vision has its own provider/model picker — run it directly so neither flow falls back to the generic + single-key prompt (which would re-ask for OPENROUTER_API_KEY). """ from hermes_cli.tools_config import TOOLSET_ENV_REQUIREMENTS, _toolset_has_keys, _toolset_label @@ -1394,10 +1276,7 @@ def _configure_simple_requirements(ts_key: str, *, reconfigure: bool = False): if value and value.strip(): save_env_value(var, value.strip()) _print_success(" Updated" if reconfigure else " Saved") + elif reconfigure: + _print_info(" Kept current") else: - if reconfigure: - _print_info(" Kept current") - else: - _print_warning(" Skipped") - - + _print_warning(" Skipped") From 80b2cc481466a7459fa8d0f17eca0257bf61fbf8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:31:32 -0700 Subject: [PATCH 8/9] refactor(tools_config): reflow re-import blocks and TOOL_CATEGORIES env_vars rows --- hermes_cli/tools_config.py | 165 +++++++++---------------------------- 1 file changed, 37 insertions(+), 128 deletions(-) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index d447ac1d5a..ab4fd5ff72 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -38,117 +38,42 @@ from hermes_cli.cli_output import ( # noqa: E402 — late import block print_info as _print_info, ) from hermes_cli.tools_config_cua import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches - _post_setup_no_window_flags, - _cua_driver_cmd, - _cua_version_summary, - _resolved_cua_driver_cmd, - _cua_driver_env, - _CUA_DRIVER_CONTRACT_CACHE, - _cua_driver_contract_status, - _cua_driver_install_ready, - _pip_install, - _cua_install_target_writable, - _cua_driver_version, - install_cua_driver, - _CUA_INSTALLER_TIMEOUT, - _CUA_INSTALLER_DRAIN_GRACE, - _CUA_BACKGROUND_UPDATE_TIMEOUT, - _CUA_LOCK_STALE_AFTER, - _cua_install_home, - _cua_install_lock_dir, - _cua_windows_install_lock_file, - _clear_stale_windows_cua_install_lock, - _clear_stale_cua_install_lock, - _cua_install_lock_held, - _cua_release_endpoint_reachable, - _ps_single_quote, - _cua_driver_autostart_registered_windows, - _repair_cua_driver_autostart_windows, - _remove_quietly, - _print_cua_platform_notes, + _post_setup_no_window_flags, _cua_driver_cmd, _cua_version_summary, _resolved_cua_driver_cmd, + _cua_driver_env, _CUA_DRIVER_CONTRACT_CACHE, _cua_driver_contract_status, _cua_driver_install_ready, + _pip_install, _cua_install_target_writable, _cua_driver_version, install_cua_driver, + _CUA_INSTALLER_TIMEOUT, _CUA_INSTALLER_DRAIN_GRACE, _CUA_BACKGROUND_UPDATE_TIMEOUT, _CUA_LOCK_STALE_AFTER, + _cua_install_home, _cua_install_lock_dir, _cua_windows_install_lock_file, + _clear_stale_windows_cua_install_lock, _clear_stale_cua_install_lock, _cua_install_lock_held, + _cua_release_endpoint_reachable, _ps_single_quote, _cua_driver_autostart_registered_windows, + _repair_cua_driver_autostart_windows, _remove_quietly, _print_cua_platform_notes, _run_cua_driver_installer, ) from hermes_cli.tools_config_post_setup import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches - _ensure_browser_use_cli, - _post_setup_lightpanda, - _post_setup_agent_browser, - _post_setup_camofox, - _KITTENTTS_WHEEL_URL, - _PIP_POST_SETUP_HOOKS, - _post_setup_pip, - _post_setup_spotify, - _post_setup_langfuse, - _post_setup_xai_grok, - _POST_SETUP_HOOKS, - _run_post_setup, - valid_post_setup_keys, - run_post_setup_command, - _POST_SETUP_INSTALLED, - _post_setup_already_installed, - _module_installed, - _RESTORABLE_PYTHON_TOOL_DEPENDENCIES, - active_restorable_python_tool_dependencies, - restorable_python_tool_dependency, - _agent_browser_installed, - _camofox_installed, - _lightpanda_installed, - _cloud_agent_browser_installed, - _POST_SETUP_READY, + _ensure_browser_use_cli, _post_setup_lightpanda, _post_setup_agent_browser, _post_setup_camofox, + _KITTENTTS_WHEEL_URL, _PIP_POST_SETUP_HOOKS, _post_setup_pip, _post_setup_spotify, _post_setup_langfuse, + _post_setup_xai_grok, _POST_SETUP_HOOKS, _run_post_setup, valid_post_setup_keys, run_post_setup_command, + _POST_SETUP_INSTALLED, _post_setup_already_installed, _module_installed, + _RESTORABLE_PYTHON_TOOL_DEPENDENCIES, active_restorable_python_tool_dependencies, + restorable_python_tool_dependency, _agent_browser_installed, _camofox_installed, _lightpanda_installed, + _cloud_agent_browser_installed, _POST_SETUP_READY, ) from hermes_cli.tools_config_providers import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches - _plugin_provider_rows, - _plugin_image_gen_providers, - _plugin_video_gen_providers, - _plugin_web_search_providers, - _plugin_browser_providers, - _plugin_tts_providers, - web_provider_capabilities, - _PLUGIN_ROW_BUILDERS, - _visible_providers, - provider_readiness_status, - _toolset_needs_configuration_prompt, - _any_plugin_provider_available, - _configure_tool_category, - _web_tier_matches, - _is_provider_active, - _detect_active_provider_index, - _fal_model_catalog, - IMAGEGEN_BACKENDS, - _plugin_model_catalog, - _plugin_image_gen_catalog, - _plugin_video_gen_catalog, - _pick_model_from_catalog, - _configure_imagegen_model, - _configure_imagegen_model_for_plugin, - _configure_videogen_model_for_plugin, - _configure_xai_imagine_storage, - _select_plugin_gen_provider, - _select_plugin_image_gen_provider, - _select_plugin_video_gen_provider, - STT_MODEL_CATALOG, - _STT_MODEL_CONFIG_KEY, - _configure_stt_model, - _PROVIDER_MARKER_SECTIONS, - _write_provider_config, - apply_provider_selection, - _nous_provider_gate, - _finish_provider_selection, - _print_provider_selection, - _configure_provider, - _reconfigure_provider, - _configure_vision_backend, - _configure_vision_provider_model, - _configure_simple_requirements, + _plugin_provider_rows, _plugin_image_gen_providers, _plugin_video_gen_providers, + _plugin_web_search_providers, _plugin_browser_providers, _plugin_tts_providers, web_provider_capabilities, + _PLUGIN_ROW_BUILDERS, _visible_providers, provider_readiness_status, _toolset_needs_configuration_prompt, + _any_plugin_provider_available, _configure_tool_category, _web_tier_matches, _is_provider_active, + _detect_active_provider_index, _fal_model_catalog, IMAGEGEN_BACKENDS, _plugin_model_catalog, + _plugin_image_gen_catalog, _plugin_video_gen_catalog, _pick_model_from_catalog, _configure_imagegen_model, + _configure_imagegen_model_for_plugin, _configure_videogen_model_for_plugin, + _configure_xai_imagine_storage, _select_plugin_gen_provider, _select_plugin_image_gen_provider, + _select_plugin_video_gen_provider, STT_MODEL_CATALOG, _STT_MODEL_CONFIG_KEY, _configure_stt_model, + _PROVIDER_MARKER_SECTIONS, _write_provider_config, apply_provider_selection, _nous_provider_gate, + _finish_provider_selection, _print_provider_selection, _configure_provider, _reconfigure_provider, + _configure_vision_backend, _configure_vision_provider_model, _configure_simple_requirements, ) from hermes_cli.tools_config_mcp import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches - _mcp_match_filter, - _mcp_preselected, - _apply_mcp_checklist, - _configure_mcp_tools_interactive, - _apply_toolset_change, - _apply_mcp_change, - _print_tools_list, - _known_tool_platforms, + _mcp_match_filter, _mcp_preselected, _apply_mcp_checklist, _configure_mcp_tools_interactive, + _apply_toolset_change, _apply_mcp_change, _print_tools_list, _known_tool_platforms, tools_disable_enable_command, ) @@ -355,16 +280,12 @@ TOOL_CATEGORIES = { "tts_provider": "openai", "requires_nous_auth": True, "managed_nous_feature": "tts", "override_env_vars": ["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"]}, {"name": "OpenAI TTS", "badge": "paid", "tag": "High quality voices", - "env_vars": [ - {"key": "VOICE_TOOLS_OPENAI_KEY", "prompt": "OpenAI API key", "url": "https://platform.openai.com/api-keys"}, - ], + "env_vars": [{"key": "VOICE_TOOLS_OPENAI_KEY", "prompt": "OpenAI API key", "url": "https://platform.openai.com/api-keys"}], "tts_provider": "openai"}, {"name": "xAI TTS", "tag": "Grok voices — uses xAI Grok OAuth or XAI_API_KEY", "env_vars": [], "tts_provider": "xai", "post_setup": "xai_grok"}, {"name": "ElevenLabs", "badge": "paid", "tag": "Most natural voices", - "env_vars": [ - {"key": "ELEVENLABS_API_KEY", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/app/settings/api-keys"}, - ], + "env_vars": [{"key": "ELEVENLABS_API_KEY", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/app/settings/api-keys"}], "tts_provider": "elevenlabs"}, # Mistral Voxtral TTS — `mistralai` SDK lazy-installs on first use. {"name": "Mistral (Voxtral TTS)", "badge": "paid", "tag": "Multilingual, native Opus", @@ -372,9 +293,7 @@ TOOL_CATEGORIES = { "tts_provider": "mistral"}, {"name": "Google Gemini TTS", "badge": "preview", "tag": "30 prebuilt voices, controllable via prompts", - "env_vars": [ - {"key": "GEMINI_API_KEY", "prompt": "Gemini API key", "url": "https://aistudio.google.com/app/apikey"}, - ], + "env_vars": [{"key": "GEMINI_API_KEY", "prompt": "Gemini API key", "url": "https://aistudio.google.com/app/apikey"}], "tts_provider": "gemini"}, {"name": "KittenTTS", "badge": "local · free", "tag": "Lightweight local ONNX TTS (~25MB), no API key", "env_vars": [], @@ -384,9 +303,7 @@ TOOL_CATEGORIES = { "tts_provider": "piper", "post_setup": "piper"}, {"name": "DeepInfra TTS", "badge": "paid", "tag": "Chatterbox, Qwen3-TTS, … — live catalog from api.deepinfra.com", - "env_vars": [ - {"key": "DEEPINFRA_API_KEY", "prompt": "DeepInfra API key", "url": "https://deepinfra.com/dash/api_keys"}, - ], + "env_vars": [{"key": "DEEPINFRA_API_KEY", "prompt": "DeepInfra API key", "url": "https://deepinfra.com/dash/api_keys"}], "tts_provider": "deepinfra"}, ], }, @@ -402,9 +319,7 @@ TOOL_CATEGORIES = { "stt_provider": "openai", "requires_nous_auth": True, "managed_nous_feature": "stt", "override_env_vars": ["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"]}, {"name": "OpenAI", "badge": "paid", "tag": "whisper-1, gpt-4o-transcribe, gpt-transcribe", - "env_vars": [ - {"key": "VOICE_TOOLS_OPENAI_KEY", "prompt": "OpenAI API key", "url": "https://platform.openai.com/api-keys"}, - ], + "env_vars": [{"key": "VOICE_TOOLS_OPENAI_KEY", "prompt": "OpenAI API key", "url": "https://platform.openai.com/api-keys"}], "stt_provider": "openai"}, {"name": "Groq", "badge": "free tier", "tag": "Whisper large-v3 family — very fast", "env_vars": [{"key": "GROQ_API_KEY", "prompt": "Groq API key", "url": "https://console.groq.com/keys"}], @@ -413,17 +328,13 @@ TOOL_CATEGORIES = { "stt_provider": "xai", "post_setup": "xai_grok"}, {"name": "ElevenLabs Scribe", "badge": "paid", "tag": "scribe_v2 — diarization + audio-event tagging", - "env_vars": [ - {"key": "ELEVENLABS_API_KEY", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/app/settings/api-keys"}, - ], + "env_vars": [{"key": "ELEVENLABS_API_KEY", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/app/settings/api-keys"}], "stt_provider": "elevenlabs"}, # Mistral Voxtral STT intentionally omitted — mistralai PyPI # package quarantined (malicious 2.4.6 release, 2026-05-12). # Restore alongside the dashboard stt.provider option. {"name": "DeepInfra", "badge": "paid", "tag": "Live STT catalog from api.deepinfra.com", - "env_vars": [ - {"key": "DEEPINFRA_API_KEY", "prompt": "DeepInfra API key", "url": "https://deepinfra.com/dash/api_keys"}, - ], + "env_vars": [{"key": "DEEPINFRA_API_KEY", "prompt": "DeepInfra API key", "url": "https://deepinfra.com/dash/api_keys"}], "stt_provider": "deepinfra"}, ], }, @@ -540,9 +451,7 @@ TOOL_CATEGORIES = { "post_setup": "browserbase", }, {"name": "Camofox", "badge": "free · local", "tag": "Anti-detection browser (Firefox/Camoufox)", - "env_vars": [ - {"key": "CAMOFOX_URL", "prompt": "Camofox server URL", "default": "http://localhost:9377", "url": "https://github.com/jo-inc/camofox-browser"}, - ], + "env_vars": [{"key": "CAMOFOX_URL", "prompt": "Camofox server URL", "default": "http://localhost:9377", "url": "https://github.com/jo-inc/camofox-browser"}], "browser_provider": "camofox", "post_setup": "camofox"}, {"name": "Browser Use", "badge": "free · local · cloud", "tag": "New SOTA web harness (CLI 3.0)", "env_vars": [], From 2df3d507c98c1223983e9479337452ebc6605d21 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:40:13 -0700 Subject: [PATCH 9/9] refactor(tools_config): reflow data tables and compact docstrings/comments in tools_config --- hermes_cli/tools_config.py | 730 ++++++++++++------------------------- 1 file changed, 238 insertions(+), 492 deletions(-) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index ab4fd5ff72..0468b96916 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -6,37 +6,23 @@ import os from pathlib import Path from typing import Dict, List, Optional, Set - -from hermes_cli.config import ( - cfg_get, - load_config, save_config, get_env_value, -) +from hermes_cli.config import cfg_get, load_config, save_config, get_env_value from hermes_cli.colors import Colors, color from hermes_cli.nous_subscription import ( - NousSubscriptionFeatures, - apply_nous_managed_defaults, - get_nous_subscription_features, + NousSubscriptionFeatures, apply_nous_managed_defaults, get_nous_subscription_features, ) from hermes_cli.toolset_scope import ( - _TOOLSET_PLATFORM_RESTRICTIONS, - toolset_allowed_for_platform as _toolset_allowed_for_platform, + _TOOLSET_PLATFORM_RESTRICTIONS, toolset_allowed_for_platform as _toolset_allowed_for_platform, ) logger = logging.getLogger(__name__) - -# Platforms already warned about an all-invalid platform_toolsets list: warn once per platform, -# not on every tool resolution for a persistently-corrupt config. +# Platforms already warned about an all-invalid platform_toolsets list (warn once, not per resolution). _warned_invalid_platform_toolsets: Set[str] = set() PROJECT_ROOT = Path(__file__).parent.parent.resolve() - -# ─── UI Helpers (shared with setup.py) ──────────────────────────────────────── - -from hermes_cli.cli_output import ( # noqa: E402 — late import block - print_info as _print_info, -) +from hermes_cli.cli_output import print_info as _print_info # noqa: E402 — late import block from hermes_cli.tools_config_cua import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches _post_setup_no_window_flags, _cua_driver_cmd, _cua_version_summary, _resolved_cua_driver_cmd, _cua_driver_env, _CUA_DRIVER_CONTRACT_CACHE, _cua_driver_contract_status, _cua_driver_install_ready, @@ -77,8 +63,7 @@ from hermes_cli.tools_config_mcp import ( # noqa: F401 — re-exported for herm tools_disable_enable_command, ) -# ─── Toolset Registry ───────────────────────────────────────────────────────── - +# --- Toolset Registry --- # Toolsets shown in the configurator: (toolset key in toolsets.py TOOLSETS, label, description). CONFIGURABLE_TOOLSETS = [ ("web", "🔍 Web Search & Scraping", "web_search, web_extract"), @@ -111,11 +96,8 @@ CONFIGURABLE_TOOLSETS = [ def gui_toolset_label(label: str) -> str: - """Strip leading emoji/icons from toolset titles for GUI surfaces. - - Registry labels use `` ``; plugin toolsets prefix with ``🔌``. CLI/TUI keeps the - raw ``label`` — only HTTP APIs call this helper. - """ + """Strip the leading ``<emoji>`` from a toolset title for GUI surfaces (plugins prefix ``🔌``). + CLI/TUI keeps the raw label — only HTTP APIs call this.""" text = (label or "").strip() if not text: return text @@ -125,36 +107,28 @@ def gui_toolset_label(label: str) -> str: return text -# OFF by default for new installs (still in _HERMES_CORE_TOOLS; the checklist just won't pre-select -# them). video_gen is niche/paid/slow. x_search auto-enables when xAI credentials exist (SuperGrok -# OAuth or XAI_API_KEY), mirroring HASS_TOKEN → homeassistant; its check_fn still hides the schema -# if the credential later expires. +# OFF by default for new installs (still in _HERMES_CORE_TOOLS; the checklist just won't pre-select them). +# video_gen is niche/paid/slow. x_search auto-enables when xAI credentials exist (SuperGrok OAuth or +# XAI_API_KEY), mirroring HASS_TOKEN → homeassistant; its check_fn still hides the schema if creds expire. _DEFAULT_OFF_TOOLSETS = {"homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search", "a2a"} - -# Config-only capabilities: in `hermes tools` for provider/API-key setup (TOOL_CATEGORIES) but NOT -# model toolsets — zero schemas, on/off switch in their own section (``stt.enabled``), not -# ``platform_toolsets``. Excluded from the per-platform checklist; configured via "Reconfigure an -# existing tool" and the GUI provider matrix. +# Config-only capabilities: in `hermes tools` for provider setup (TOOL_CATEGORIES) but NOT model toolsets — +# zero schemas, own on/off switch (``stt.enabled``), not ``platform_toolsets``. Excluded from the per-platform +# checklist; configured via "Reconfigure an existing tool" and the GUI provider matrix. _CONFIG_ONLY_TOOLSETS = {"stt"} def _xai_credentials_present() -> bool: - """Cheap, offline check for usable xAI credentials (auth store + env only). - - The tool's runtime ``check_fn`` still gates schema registration if creds later expire. Also used - by ``provider_readiness_status`` for ``post_setup: "xai_grok"`` rows. - """ + """Cheap offline check for xAI credentials (auth store + env only); the runtime ``check_fn`` still gates + schema registration if creds expire. Also used by ``provider_readiness_status`` for ``xai_grok`` rows.""" try: from hermes_cli.auth import _read_xai_oauth_tokens - _read_xai_oauth_tokens() return True except Exception: pass try: from tools.xai_http import get_env_value as _xai_get_env_value - if str(_xai_get_env_value("XAI_API_KEY") or "").strip(): return True except Exception: @@ -170,17 +144,14 @@ def _homeassistant_credentials_present() -> bool: """Return whether the active profile has a Home Assistant token.""" try: from agent.secret_scope import get_secret - return bool((get_secret("HASS_TOKEN", "") or "").strip()) except Exception: return False -def _toolset_configuration_platform(ts_key: str, default: str = "cli") -> str: - """Platform a platform-less configuration UI should target. - A toolset restricted away from the default (CLI) must be configured on one of its supported - platforms; otherwise the save helper correctly drops it and the UI reports a successful no-op. - """ +def _toolset_configuration_platform(ts_key: str, default: str = "cli") -> str: + """Platform a platform-less configuration UI should target: a toolset restricted away from ``default`` + must be configured on a supported platform, else the save helper drops it and the UI reports a no-op.""" allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts_key) if not allowed or default in allowed: return default @@ -188,8 +159,7 @@ def _toolset_configuration_platform(ts_key: str, default: str = "cli") -> str: def _get_effective_configurable_toolsets(): - """CONFIGURABLE_TOOLSETS + plugin toolsets (appended, so they render after built-ins; a plugin - whose key is already built-in is skipped).""" + """CONFIGURABLE_TOOLSETS + plugin toolsets (appended after built-ins; a plugin key already built-in is skipped).""" result = list(CONFIGURABLE_TOOLSETS) seen = {ts_key for ts_key, _, _ in result} try: @@ -208,8 +178,8 @@ def _get_effective_configurable_toolsets(): def _get_plugin_toolset_keys() -> set: """Return the set of toolset keys provided by plugins.""" try: - # Non-blocking on the CLI startup path: while background discovery is still importing, - # this serves last launch's persisted key set instead of joining the discovery thread. + # Non-blocking on CLI startup: while background discovery is still importing, serve last + # launch's persisted key set instead of joining the discovery thread. from hermes_cli.plugins import get_plugin_toolset_keys_nowait return get_plugin_toolset_keys_nowait() except Exception: @@ -217,25 +187,19 @@ def _get_plugin_toolset_keys() -> set: def _checklist_toolset_keys(platform: str) -> Set[str]: - """Toolset keys the ``hermes tools`` checklist offers for ``platform`` (mirrors - ``_prompt_toolset_checklist``). Non-configurable toolsets ``_get_platform_tools`` resolves at - read time (``kanban``, recovered composites, MCP names) are NOT here — the checklist never - shows them.""" + """Toolset keys the ``hermes tools`` checklist offers for ``platform`` (mirrors ``_prompt_toolset_checklist``). + Non-configurable toolsets ``_get_platform_tools`` resolves at read time (``kanban``, recovered + composites, MCP names) are NOT here — the checklist never shows them.""" return { - ts_key - for ts_key, _, _ in _get_effective_configurable_toolsets() - if _toolset_allowed_for_platform(ts_key, platform) - and ts_key not in _CONFIG_ONLY_TOOLSETS + ts_key for ts_key, _, _ in _get_effective_configurable_toolsets() + if _toolset_allowed_for_platform(ts_key, platform) and ts_key not in _CONFIG_ONLY_TOOLSETS } -# Platform display config derived from the canonical registry; dict-of-dicts for the existing -# ``PLATFORMS[key]["label"]`` access pattern. + +# Platform display config derived from the canonical registry (dict-of-dicts for ``PLATFORMS[key]["label"]``). from hermes_cli.platforms import PLATFORMS as _PLATFORMS_REGISTRY -PLATFORMS = { - k: {"label": info.label, "default_toolset": info.default_toolset} - for k, info in _PLATFORMS_REGISTRY.items() -} +PLATFORMS = {k: {"label": info.label, "default_toolset": info.default_toolset} for k, info in _PLATFORMS_REGISTRY.items()} def _platform_default_toolset(platform: str) -> str: @@ -263,284 +227,202 @@ def _toolset_label(ts_key: str) -> str: return next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key) -# ─── Tool Categories (provider-aware configuration) ────────────────────────── -# toolset key -> provider options shown when the toolset is newly enabled. Toolsets not in this -# map either need no config or use the TOOLSET_ENV_REQUIREMENTS fallback. +# --- Tool Categories (provider-aware configuration) --- +# toolset key -> provider options shown when the toolset is newly enabled. Toolsets not in this map either +# need no config or use the TOOLSET_ENV_REQUIREMENTS fallback. TOOL_CATEGORIES = { "tts": { - "name": "Text-to-Speech", - "icon": "🔊", + "name": "Text-to-Speech", "icon": "🔊", "providers": [ - {"name": "Microsoft Edge TTS", "badge": "★ recommended · free", - "tag": "Good quality, no API key needed", "env_vars": [], - "tts_provider": "edge"}, - {"name": "Nous Subscription", "badge": "subscription", - "tag": "Managed OpenAI TTS billed to your subscription", "env_vars": [], - "tts_provider": "openai", "requires_nous_auth": True, "managed_nous_feature": "tts", + {"name": "Microsoft Edge TTS", "badge": "★ recommended · free", "tag": "Good quality, no API key needed", + "env_vars": [], "tts_provider": "edge"}, + {"name": "Nous Subscription", "badge": "subscription", "tag": "Managed OpenAI TTS billed to your subscription", + "env_vars": [], "tts_provider": "openai", "requires_nous_auth": True, "managed_nous_feature": "tts", "override_env_vars": ["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"]}, - {"name": "OpenAI TTS", "badge": "paid", "tag": "High quality voices", - "env_vars": [{"key": "VOICE_TOOLS_OPENAI_KEY", "prompt": "OpenAI API key", "url": "https://platform.openai.com/api-keys"}], + {"name": "OpenAI TTS", "badge": "paid", "tag": "High quality voices", "env_vars": [{"key": "VOICE_TOOLS_OPENAI_KEY", "prompt": "OpenAI API key", "url": "https://platform.openai.com/api-keys"}], "tts_provider": "openai"}, {"name": "xAI TTS", "tag": "Grok voices — uses xAI Grok OAuth or XAI_API_KEY", "env_vars": [], "tts_provider": "xai", "post_setup": "xai_grok"}, - {"name": "ElevenLabs", "badge": "paid", "tag": "Most natural voices", - "env_vars": [{"key": "ELEVENLABS_API_KEY", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/app/settings/api-keys"}], + {"name": "ElevenLabs", "badge": "paid", "tag": "Most natural voices", "env_vars": [{"key": "ELEVENLABS_API_KEY", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/app/settings/api-keys"}], "tts_provider": "elevenlabs"}, # Mistral Voxtral TTS — `mistralai` SDK lazy-installs on first use. {"name": "Mistral (Voxtral TTS)", "badge": "paid", "tag": "Multilingual, native Opus", "env_vars": [{"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"}], "tts_provider": "mistral"}, - {"name": "Google Gemini TTS", "badge": "preview", - "tag": "30 prebuilt voices, controllable via prompts", + {"name": "Google Gemini TTS", "badge": "preview", "tag": "30 prebuilt voices, controllable via prompts", "env_vars": [{"key": "GEMINI_API_KEY", "prompt": "Gemini API key", "url": "https://aistudio.google.com/app/apikey"}], "tts_provider": "gemini"}, - {"name": "KittenTTS", "badge": "local · free", - "tag": "Lightweight local ONNX TTS (~25MB), no API key", "env_vars": [], - "tts_provider": "kittentts", "post_setup": "kittentts"}, - {"name": "Piper", "badge": "local · free", - "tag": "Local neural TTS, 44 languages (voices ~20-90MB)", "env_vars": [], - "tts_provider": "piper", "post_setup": "piper"}, - {"name": "DeepInfra TTS", "badge": "paid", - "tag": "Chatterbox, Qwen3-TTS, … — live catalog from api.deepinfra.com", - "env_vars": [{"key": "DEEPINFRA_API_KEY", "prompt": "DeepInfra API key", "url": "https://deepinfra.com/dash/api_keys"}], - "tts_provider": "deepinfra"}, + {"name": "KittenTTS", "badge": "local · free", "tag": "Lightweight local ONNX TTS (~25MB), no API key", + "env_vars": [], "tts_provider": "kittentts", "post_setup": "kittentts"}, + {"name": "Piper", "badge": "local · free", "tag": "Local neural TTS, 44 languages (voices ~20-90MB)", + "env_vars": [], "tts_provider": "piper", "post_setup": "piper"}, + {"name": "DeepInfra TTS", "badge": "paid", "tag": "Chatterbox, Qwen3-TTS, … — live catalog from api.deepinfra.com", + "env_vars": [{"key": "DEEPINFRA_API_KEY", "prompt": "DeepInfra API key", "url": "https://deepinfra.com/dash/api_keys"}], "tts_provider": "deepinfra"}, ], }, "stt": { - "name": "Speech-to-Text", - "icon": "🎙️", + "name": "Speech-to-Text", "icon": "🎙️", "providers": [ - {"name": "Local Whisper", "badge": "★ recommended · free", - "tag": "faster-whisper on-device, no API key", "env_vars": [], - "stt_provider": "local", "post_setup": "faster_whisper"}, + {"name": "Local Whisper", "badge": "★ recommended · free", "tag": "faster-whisper on-device, no API key", + "env_vars": [], "stt_provider": "local", "post_setup": "faster_whisper"}, {"name": "Nous Subscription", "badge": "subscription", - "tag": "Managed OpenAI transcription billed to your subscription", "env_vars": [], - "stt_provider": "openai", "requires_nous_auth": True, "managed_nous_feature": "stt", + "tag": "Managed OpenAI transcription billed to your subscription", "env_vars": [], "stt_provider": "openai", + "requires_nous_auth": True, "managed_nous_feature": "stt", "override_env_vars": ["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"]}, {"name": "OpenAI", "badge": "paid", "tag": "whisper-1, gpt-4o-transcribe, gpt-transcribe", - "env_vars": [{"key": "VOICE_TOOLS_OPENAI_KEY", "prompt": "OpenAI API key", "url": "https://platform.openai.com/api-keys"}], - "stt_provider": "openai"}, + "env_vars": [{"key": "VOICE_TOOLS_OPENAI_KEY", "prompt": "OpenAI API key", "url": "https://platform.openai.com/api-keys"}], "stt_provider": "openai"}, {"name": "Groq", "badge": "free tier", "tag": "Whisper large-v3 family — very fast", "env_vars": [{"key": "GROQ_API_KEY", "prompt": "Groq API key", "url": "https://console.groq.com/keys"}], "stt_provider": "groq"}, {"name": "xAI", "tag": "grok-stt — uses xAI Grok OAuth or XAI_API_KEY", "env_vars": [], "stt_provider": "xai", "post_setup": "xai_grok"}, - {"name": "ElevenLabs Scribe", "badge": "paid", - "tag": "scribe_v2 — diarization + audio-event tagging", - "env_vars": [{"key": "ELEVENLABS_API_KEY", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/app/settings/api-keys"}], - "stt_provider": "elevenlabs"}, - # Mistral Voxtral STT intentionally omitted — mistralai PyPI - # package quarantined (malicious 2.4.6 release, 2026-05-12). - # Restore alongside the dashboard stt.provider option. + {"name": "ElevenLabs Scribe", "badge": "paid", "tag": "scribe_v2 — diarization + audio-event tagging", + "env_vars": [{"key": "ELEVENLABS_API_KEY", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/app/settings/api-keys"}], "stt_provider": "elevenlabs"}, + # Mistral Voxtral STT intentionally omitted — mistralai PyPI package quarantined (malicious 2.4.6 + # release, 2026-05-12). Restore alongside the dashboard stt.provider option. {"name": "DeepInfra", "badge": "paid", "tag": "Live STT catalog from api.deepinfra.com", - "env_vars": [{"key": "DEEPINFRA_API_KEY", "prompt": "DeepInfra API key", "url": "https://deepinfra.com/dash/api_keys"}], - "stt_provider": "deepinfra"}, + "env_vars": [{"key": "DEEPINFRA_API_KEY", "prompt": "DeepInfra API key", "url": "https://deepinfra.com/dash/api_keys"}], "stt_provider": "deepinfra"}, ], }, "web": { - "name": "Web Search & Extract", - "setup_title": "Select Search Provider", + "name": "Web Search & Extract", "setup_title": "Select Search Provider", "setup_note": "A free DuckDuckGo search skill is also included — skip this if you don't need a premium provider.", "icon": "🔍", - # Provider rows come from plugins.web.<vendor> via _plugin_web_search_providers() - # (PR #25182). Only the two non-provider firecrawl setup-flow rows live here: - # managed Firecrawl via Nous subscription, and a self-hosted FIRECRAWL_API_URL. + # Provider rows come from plugins.web.<vendor> via _plugin_web_search_providers(). Only the two + # non-provider firecrawl setup-flow rows live here: managed via Nous subscription, and self-hosted. "providers": [ - {"name": "Nous Subscription", "badge": "subscription", - "tag": "Managed Firecrawl billed to your subscription", "web_backend": "firecrawl", - "env_vars": [], - "requires_nous_auth": True, "managed_nous_feature": "web", + {"name": "Nous Subscription", "badge": "subscription", "tag": "Managed Firecrawl billed to your subscription", + "web_backend": "firecrawl", "env_vars": [], "requires_nous_auth": True, "managed_nous_feature": "web", "override_env_vars": ["FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"]}, - {"name": "Firecrawl Self-Hosted", "badge": "free · self-hosted", - "tag": "Run your own Firecrawl instance (Docker)", "web_backend": "firecrawl", + {"name": "Firecrawl Self-Hosted", "badge": "free · self-hosted", "tag": "Run your own Firecrawl instance (Docker)", + "web_backend": "firecrawl", "env_vars": [{"key": "FIRECRAWL_API_URL", "prompt": "Your Firecrawl instance URL (e.g., http://localhost:3002)"}]}, ], }, "image_gen": { - "name": "Image Generation", - "icon": "🎨", - # Provider rows (FAL, OpenAI, OpenAI Codex, xAI) come from plugins.image_gen.<vendor> - # via _plugin_image_gen_providers(). Only the managed "Nous Subscription" setup-flow row - # lives here — it uses the fal plugin as backend but has a distinct UX. + "name": "Image Generation", "icon": "🎨", + # Provider rows (FAL, OpenAI, OpenAI Codex, xAI) come from plugins.image_gen.<vendor> via + # _plugin_image_gen_providers(). Only the managed "Nous Subscription" row lives here — fal backend, distinct UX. "providers": [ {"name": "Nous Subscription", "badge": "subscription", "tag": "Managed FAL image generation billed to your subscription", "env_vars": [], - "requires_nous_auth": True, "managed_nous_feature": "image_gen", - "override_env_vars": ["FAL_KEY"], + "requires_nous_auth": True, "managed_nous_feature": "image_gen", "override_env_vars": ["FAL_KEY"], "imagegen_backend": "fal"}, ], }, "video_gen": { - "name": "Video Generation", - "icon": "🎬", - # "Nous Subscription" row mirrors the image_gen pattern — managed - # FAL video generation billed via the Nous Portal. Plugin-backed - # provider rows (FAL BYOK, xAI, …) are injected at runtime by - # ``_plugin_video_gen_providers()`` in ``_visible_providers``. + "name": "Video Generation", "icon": "🎬", + # Mirrors image_gen: managed FAL video billed via the Nous Portal. Plugin-backed rows (FAL BYOK, xAI, …) + # are injected at runtime by ``_plugin_video_gen_providers()`` in ``_visible_providers``. "providers": [ - { - "name": "Nous Subscription", - "badge": "subscription", - "tag": "Managed FAL video generation billed to your subscription", - "env_vars": [], - "requires_nous_auth": True, - "managed_nous_feature": "video_gen", - "override_env_vars": ["FAL_KEY"], - # The underlying plugin backend — when the user picks - # "Nous Subscription" we set video_gen.provider = "fal" - # and video_gen.use_gateway = True so the FAL plugin - # routes through the managed queue gateway. - "video_gen_plugin_name": "fal", - }, + {"name": "Nous Subscription", "badge": "subscription", + "tag": "Managed FAL video generation billed to your subscription", "env_vars": [], + "requires_nous_auth": True, "managed_nous_feature": "video_gen", "override_env_vars": ["FAL_KEY"], + # Underlying plugin backend: picking this row sets video_gen.provider = "fal" and + # video_gen.use_gateway = True so the FAL plugin routes through the managed queue gateway. + "video_gen_plugin_name": "fal"}, ], }, "x_search": { - "name": "X (Twitter) Search", - "setup_title": "Select xAI Credential Source", + "name": "X (Twitter) Search", "setup_title": "Select xAI Credential Source", "setup_note": ( - "Hermes routes X searches through xAI's built-in x_search " - "Responses tool for read-only public X discovery. Use the xurl " - "skill for authenticated X API reads and account actions. Both " - "credential sources hit the same " - "https://api.x.ai/v1/responses endpoint — pick whichever you " - "already have. SuperGrok OAuth is preferred when both are set " - "(uses your subscription quota instead of API spend)." + "Hermes routes X searches through xAI's built-in x_search Responses tool for read-only public X " + "discovery. Use the xurl skill for authenticated X API reads and account actions. Both credential " + "sources hit the same https://api.x.ai/v1/responses endpoint — pick whichever you already have. " + "SuperGrok OAuth is preferred when both are set (uses your subscription quota instead of API spend)." ), "icon": "🐦", "providers": [ {"name": "xAI Grok OAuth (SuperGrok / Premium+)", "badge": "subscription", - "tag": "Browser login at accounts.x.ai — no API key required", "env_vars": [], - "post_setup": "xai_grok"}, + "tag": "Browser login at accounts.x.ai — no API key required", "env_vars": [], "post_setup": "xai_grok"}, {"name": "xAI API key", "badge": "paid", "tag": "Direct xAI API billing via XAI_API_KEY", "env_vars": [{"key": "XAI_API_KEY", "prompt": "xAI API key", "url": "https://console.x.ai/"}]}, ], }, "browser": { - "name": "Browser Automation", - "icon": "🌐", - # Cloud provider rows (Browserbase, Browser Use, Firecrawl) come from - # plugins.browser.<vendor> via _plugin_browser_providers() (PR #25214). Only - # non-provider setup-flow rows live here. "Local Browser" MUST stay first so a fresh - # install's Enter lands on the free local backend (index 0), never on the paid Nous row. - # Lightpanda is local too (cloud_provider: local, browser.engine: lightpanda — Browser Use - # mode spawns ``lightpanda serve``, built-in tools use ``agent-browser --engine - # lightpanda``; no Chromium). Camofox short-circuits the cloud dispatch via - # _is_camofox_mode(). + "name": "Browser Automation", "icon": "🌐", + # Cloud provider rows (Browserbase, Browser Use, Firecrawl) come from plugins.browser.<vendor> via + # _plugin_browser_providers(); only non-provider setup-flow rows live here. "Local Browser" MUST stay + # first so a fresh install's Enter lands on the free local backend (index 0), never on the paid Nous row. + # Lightpanda is local too (cloud_provider: local, browser.engine: lightpanda — Browser Use mode spawns + # ``lightpanda serve``, built-in tools use ``agent-browser --engine lightpanda``; no Chromium). + # Camofox short-circuits the cloud dispatch via _is_camofox_mode(). "providers": [ - {"name": "Local Browser", "badge": "★ recommended · free", - "tag": "Headless Chromium, no API key needed", "env_vars": [], - "browser_provider": "local", "browser_engine": "auto", "post_setup": "agent_browser"}, + {"name": "Local Browser", "badge": "★ recommended · free", "tag": "Headless Chromium, no API key needed", + "env_vars": [], "browser_provider": "local", "browser_engine": "auto", "post_setup": "agent_browser"}, {"name": "Lightpanda", "badge": "free · local · no Chromium", "tag": "Zig headless browser spawned by Hermes, text-only (no screenshots)", "env_vars": [], "browser_provider": "local", "browser_engine": "lightpanda", "post_setup": "lightpanda"}, - { - "name": "Nous Subscription (Browser Use cloud)", - "badge": "subscription", - "tag": "Managed Browser Use billed to your subscription", - "env_vars": [], - "browser_provider": "browser-use", - "requires_nous_auth": True, - "managed_nous_feature": "browser", - "override_env_vars": ["BROWSER_USE_API_KEY"], - # Cloud hook: installs the agent-browser CLI only. Browser Use - # hosts its own Chromium, so the local-Chromium install (and - # the local-Chromium readiness gate) must not apply here — - # with "agent_browser" this row read "needs setup" forever on - # machines without a local Chromium build. - "post_setup": "browserbase", - }, + {"name": "Nous Subscription (Browser Use cloud)", "badge": "subscription", + "tag": "Managed Browser Use billed to your subscription", "env_vars": [], "browser_provider": "browser-use", + "requires_nous_auth": True, "managed_nous_feature": "browser", "override_env_vars": ["BROWSER_USE_API_KEY"], + # Cloud hook installs only the agent-browser CLI: Browser Use hosts its own Chromium, so the + # local-Chromium install and readiness gate must not apply (with "agent_browser" this row read + # "needs setup" forever on machines without a local Chromium build). + "post_setup": "browserbase"}, {"name": "Camofox", "badge": "free · local", "tag": "Anti-detection browser (Firefox/Camoufox)", "env_vars": [{"key": "CAMOFOX_URL", "prompt": "Camofox server URL", "default": "http://localhost:9377", "url": "https://github.com/jo-inc/camofox-browser"}], "browser_provider": "camofox", "post_setup": "camofox"}, - {"name": "Browser Use", "badge": "free · local · cloud", "tag": "New SOTA web harness (CLI 3.0)", - "env_vars": [], + {"name": "Browser Use", "badge": "free · local · cloud", "tag": "New SOTA web harness (CLI 3.0)", "env_vars": [], "browser_backend": "browser-use", "post_setup": "browser_use_cli"}, ], }, "homeassistant": { - "name": "Smart Home", - "icon": "🏠", + "name": "Smart Home", "icon": "🏠", "providers": [ {"name": "Home Assistant", "tag": "REST API integration", - "env_vars": [ - {"key": "HASS_TOKEN", "prompt": "Home Assistant Long-Lived Access Token"}, - {"key": "HASS_URL", "prompt": "Home Assistant URL", "default": "http://homeassistant.local:8123"}, - ]}, + "env_vars": [{"key": "HASS_TOKEN", "prompt": "Home Assistant Long-Lived Access Token"}, + {"key": "HASS_URL", "prompt": "Home Assistant URL", "default": "http://homeassistant.local:8123"}]}, ], }, "spotify": { - "name": "Spotify", - "icon": "🎵", + "name": "Spotify", "icon": "🎵", "providers": [ - {"name": "Spotify Web API", "tag": "PKCE OAuth — opens the setup wizard", "env_vars": [], - "post_setup": "spotify"}, + {"name": "Spotify Web API", "tag": "PKCE OAuth — opens the setup wizard", "env_vars": [], "post_setup": "spotify"}, ], }, "computer_use": { - "name": "Computer Use (macOS/Windows/Linux)", - "icon": "🖱️", - # Runtime backends ship for macOS, Windows, and Linux (X11 today, - # Wayland via XWayland). Per-host gaps surface via `computer-use doctor`. + "name": "Computer Use (macOS/Windows/Linux)", "icon": "🖱️", + # Runtime backends ship for macOS, Windows, Linux (X11; Wayland via XWayland). Gaps surface via `computer-use doctor`. "platform_gate": ["darwin", "win32", "linux"], "providers": [ - { - "name": "cua-driver (background)", - "badge": "★ recommended · free · local", - "tag": ( - "Background computer-use via cua-driver — does NOT steal " - "your cursor or focus. Works with any model." - ), - "env_vars": [ - # cua-driver reads HOME/TMPDIR from the process env, no - # extra keys required. Set HERMES_CUA_DRIVER_CMD to use a - # specific binary (e.g. a local build); there is no - # version-pin env var. - ], - "computer_use_backend": "cua", - "post_setup": "cua_driver", - }, + {"name": "cua-driver (background)", "badge": "★ recommended · free · local", + "tag": "Background computer-use via cua-driver — does NOT steal your cursor or focus. Works with any model.", + # cua-driver reads HOME/TMPDIR from the process env; HERMES_CUA_DRIVER_CMD selects a specific + # binary (e.g. a local build). There is no version-pin env var. + "env_vars": [], "computer_use_backend": "cua", "post_setup": "cua_driver"}, ], }, "langfuse": { - "name": "Langfuse Observability", - "icon": "📊", + "name": "Langfuse Observability", "icon": "📊", "providers": [ {"name": "Langfuse Cloud", "tag": "Hosted Langfuse (cloud.langfuse.com)", - "env_vars": [ - {"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)", "url": "https://cloud.langfuse.com"}, - {"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)", "url": "https://cloud.langfuse.com"}, - ], + "env_vars": [{"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)", "url": "https://cloud.langfuse.com"}, + {"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)", "url": "https://cloud.langfuse.com"}], "post_setup": "langfuse"}, {"name": "Langfuse Self-Hosted", "tag": "Self-hosted Langfuse instance", - "env_vars": [ - {"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)"}, - {"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)"}, - {"key": "HERMES_LANGFUSE_BASE_URL", "prompt": "Langfuse server URL (e.g. http://localhost:3000)", "default": "http://localhost:3000"}, - ], + "env_vars": [{"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)"}, + {"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)"}, + {"key": "HERMES_LANGFUSE_BASE_URL", "prompt": "Langfuse server URL (e.g. http://localhost:3000)", "default": "http://localhost:3000"}], "post_setup": "langfuse"}, ], }, } -# Env-var fallback for toolsets NOT in TOOL_CATEGORIES. `vision` is listed only as a presence marker -# (registers it as configurable: reconfigure menu + "[no API key]" suffix); its setup runs through -# `_configure_vision_backend()` (full provider+model picker, never forcing OpenRouter) and -# `_toolset_has_keys("vision")` resolves via `resolve_vision_provider_client()`. +# Env-var fallback for toolsets NOT in TOOL_CATEGORIES. `vision` is only a presence marker (registers it as +# configurable: reconfigure menu + "[no API key]" suffix); its setup runs through `_configure_vision_backend()` +# (full provider+model picker, never forcing OpenRouter) and `_toolset_has_keys("vision")` uses +# `resolve_vision_provider_client()`. TOOLSET_ENV_REQUIREMENTS = { "vision": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")], } - -# ─── Platform / Toolset Helpers ─────────────────────────────────────────────── - +# --- Platform / Toolset Helpers --- _PLATFORM_ENABLE_ENV_VARS = ( - ("telegram", "TELEGRAM_BOT_TOKEN"), - ("discord", "DISCORD_BOT_TOKEN"), - ("slack", "SLACK_BOT_TOKEN"), - ("whatsapp", "WHATSAPP_ENABLED"), - ("qqbot", "QQ_APP_ID"), + ("telegram", "TELEGRAM_BOT_TOKEN"), ("discord", "DISCORD_BOT_TOKEN"), ("slack", "SLACK_BOT_TOKEN"), + ("whatsapp", "WHATSAPP_ENABLED"), ("qqbot", "QQ_APP_ID"), ) @@ -574,41 +456,29 @@ def _parse_enabled_flag(value, default: bool = True) -> bool: def enabled_mcp_server_names(config: dict) -> Set[str]: - """Names of MCP servers globally enabled in config.yaml or by a plugin. - - Shared by the platform resolver and the cron toolset resolver so every path agrees on MCP - membership. A server is enabled unless ``enabled`` is explicitly falsey; missing/unknown values - count as enabled. Portable-plugin servers (in-memory, not in config.yaml) are included so their - tools reach the model's schema — enabling the plugin is the user's opt-in. - """ + """Names of MCP servers globally enabled in config.yaml or by a plugin. Shared by the platform and cron + resolvers so every path agrees on MCP membership. A server is enabled unless ``enabled`` is explicitly + falsey (missing/unknown counts as enabled). Portable-plugin servers (in-memory, not in config.yaml) are + included so their tools reach the model's schema — enabling the plugin is the user's opt-in.""" mcp_servers = (config or {}).get("mcp_servers") or {} names = { - str(name) - for name, server_cfg in mcp_servers.items() - if isinstance(server_cfg, dict) - and _parse_enabled_flag(server_cfg.get("enabled", True), default=True) + str(name) for name, server_cfg in mcp_servers.items() + if isinstance(server_cfg, dict) and _parse_enabled_flag(server_cfg.get("enabled", True), default=True) } try: from hermes_cli.plugins import get_portable_mcp_server_names_nowait - portable = get_portable_mcp_server_names_nowait() - # Native config wins on a name collision (mirrors _load_mcp_config). - names |= portable - set(mcp_servers) + names |= portable - set(mcp_servers) # native config wins on a name collision (mirrors _load_mcp_config) except Exception: logger.debug("Failed to include portable MCP servers", exc_info=True) return names -def _exempt_explicit_platform_native( - default_off: Set[str], platform: str, *, explicitly_configured: bool -) -> None: - """Let platform-native default-off toolsets through on explicit config. - - Default-off toolsets restricted to a platform (e.g. ``discord`` on discord) are that platform's - native tools: kept off for unconfigured platforms as a security opt-in, but once the user - explicitly saves a toolset list, stripping them silently would defeat that configuration. - Mutates ``default_off`` in place. - """ +def _exempt_explicit_platform_native(default_off: Set[str], platform: str, *, explicitly_configured: bool) -> None: + """Let platform-native default-off toolsets through on explicit config (mutates ``default_off``). + Default-off toolsets restricted to a platform (``discord`` on discord) are its native tools: off for + unconfigured platforms as a security opt-in, but once the user explicitly saves a toolset list, stripping + them silently would defeat that configuration.""" if not explicitly_configured: return for ts in list(default_off): @@ -617,44 +487,34 @@ def _exempt_explicit_platform_native( default_off.discard(ts) -#: Toolsets young enough that absence from a saved ``platform_toolsets`` list means "never offered" -#: rather than "declined". Saving ``hermes tools`` freezes a platform's composite into an explicit -#: list that nothing ever adds to, so a toolset shipped later stays off forever for picker users -#: while ``[hermes-cli]`` users inherit it; listing it here restores parity. -#: -#: MUST ship in the same release as the toolset and be emptied in the next: once a released build -#: has put the toolset on a checklist, an unchecking user writes a config byte-identical to one -#: saved before it existed and this rule would turn their opt-out back on (stuck checkbox). -#: A ``check_fn``-gated toolset costs nothing here (zero schemas when its check fails); never probe -#: a remote service from this path — it runs on every CLI start, gateway session and cron tick. +#: Toolsets young enough that absence from a saved ``platform_toolsets`` list means "never offered", not +#: "declined": saving ``hermes tools`` freezes a platform's composite into an explicit list nothing adds to, so +#: a later toolset stays off forever for picker users while ``[hermes-cli]`` users inherit it. +#: MUST ship in the same release as the toolset and be emptied in the next: once a released build has put the +#: toolset on a checklist, an unchecking user's config is byte-identical to one saved before it existed and this +#: rule would turn the opt-out back on (stuck checkbox). ``check_fn``-gated toolsets cost nothing here; never +#: probe a remote service from this path — it runs on every CLI start, gateway session and cron tick. _RECENTLY_SHIPPED_TOOLSETS: frozenset = frozenset() -def _enable_recently_shipped_toolsets( - enabled_toolsets: Set[str], config: dict, platform: str -) -> None: - """Turn on toolsets that shipped after this platform's saved list. - - Either way of saying no outlives this: unchecking in ``hermes tools`` records the toolset in - ``known_builtin_toolsets`` so it reads as declined from then on, and ``agent.disabled_toolsets`` - is subtracted after every rule in :func:`_get_platform_tools`. Mutates ``enabled_toolsets`` in - place. - """ +def _enable_recently_shipped_toolsets(enabled_toolsets: Set[str], config: dict, platform: str) -> None: + """Turn on toolsets that shipped after this platform's saved list (mutates ``enabled_toolsets``). + Both ways of saying no outlive this: unchecking in ``hermes tools`` records the toolset in + ``known_builtin_toolsets`` (declined from then on), and ``agent.disabled_toolsets`` is subtracted after + every rule in :func:`_get_platform_tools`.""" from toolsets import resolve_toolset offered = (config.get("known_builtin_toolsets") or {}).get(platform) declined = {str(ts) for ts in offered} if isinstance(offered, list) else set() - default_ts = _platform_default_toolset(platform) composite_tools = None - for ts_key in sorted(_RECENTLY_SHIPPED_TOOLSETS): if ts_key in enabled_toolsets or ts_key in declined: continue if not _toolset_allowed_for_platform(ts_key, platform): continue - # Only enable where staying on the composite would have enabled it anyway; deliberately - # narrow composites (hermes-acp, hermes-webhook) stay narrow. + # Only enable where staying on the composite would have enabled it anyway; deliberately narrow + # composites (hermes-acp, hermes-webhook) stay narrow. ts_tools = set(resolve_toolset(ts_key, include_registry=False)) if composite_tools is None: composite_tools = set(resolve_toolset(default_ts)) @@ -664,12 +524,9 @@ def _enable_recently_shipped_toolsets( def _configurable_subset_of(tool_names: Set[str], platform: str) -> Set[str]: - """Configurable toolsets whose STATIC membership is contained in ``tool_names``. - - Compares ``resolve_toolset(ts, include_registry=False)``: a tool registered into a toolset at - runtime (delegate_cli -> delegation, desktop-only read_terminal -> terminal) that the composite - never listed must not drop the whole toolset. - """ + """Configurable toolsets whose STATIC membership is contained in ``tool_names``. Uses + ``include_registry=False``: a tool registered into a toolset at runtime (delegate_cli -> delegation, + desktop-only read_terminal -> terminal) that the composite never listed must not drop the whole toolset.""" from toolsets import resolve_toolset enabled = set() @@ -684,13 +541,10 @@ def _configurable_subset_of(tool_names: Set[str], platform: str) -> Set[str]: def _default_off_toolsets(platform: str, explicitly_configured: bool) -> Set[str]: """Toolsets to strip from an implicit (composite-derived) enable set for ``platform``. - - A platform whose own name is a default-off toolset (``homeassistant``) keeps it on first install, - except platform-restricted toolsets, which stay opt-in even on their own platform (``discord`` on - discord stays OFF). A configured HASS_TOKEN is an explicit opt-in (the check_fn gates at runtime) - and must not be stripped, or HA silently vanishes from platforms like cron that resolve without - a saved list. - """ + A platform whose own name is a default-off toolset (``homeassistant``) keeps it on first install, except + platform-restricted toolsets, which stay opt-in even on their own platform (``discord`` on discord stays + OFF). A configured HASS_TOKEN is an explicit opt-in (check_fn gates at runtime) and must not be stripped, + or HA silently vanishes from platforms like cron that resolve without a saved list.""" default_off = set(_DEFAULT_OFF_TOOLSETS) if platform in default_off and platform not in _TOOLSET_PLATFORM_RESTRICTIONS: default_off.remove(platform) @@ -712,40 +566,28 @@ def _explicit_toolsets( toolset_names: List[str], explicit_known_keys: Set[str], config: dict, platform: str, explicitly_configured: bool, ) -> Set[str]: - """Enabled set when the saved list names configurable/plugin keys directly. - - Direct membership avoids the subset-inference bug where composites like ``hermes-cli`` (all - _HERMES_CORE_TOOLS) made disabled toolsets re-appear. A mixed list (``[hermes-cli, spotify]`` - after enabling Spotify via ``hermes tools``) still expands the composite, otherwise sessions - keep only the opt-ins and lose every native tool; _DEFAULT_OFF_TOOLSETS applies to that - implicit expansion only — anything explicitly listed survives. - """ + """Enabled set when the saved list names configurable/plugin keys directly. Direct membership avoids the + subset-inference bug where composites like ``hermes-cli`` (all _HERMES_CORE_TOOLS) re-enabled disabled + toolsets. A mixed list (``[hermes-cli, spotify]``) still expands the composite, else sessions keep only the + opt-ins and lose every native tool; _DEFAULT_OFF_TOOLSETS applies to that implicit expansion only.""" from toolsets import resolve_toolset, TOOLSETS - enabled = { - ts for ts in toolset_names - if ts in explicit_known_keys and _toolset_allowed_for_platform(ts, platform) - } + enabled = {ts for ts in toolset_names if ts in explicit_known_keys and _toolset_allowed_for_platform(ts, platform)} composite_tools: Set[str] = set() for ts_name in toolset_names: if ts_name not in explicit_known_keys and ts_name in TOOLSETS: composite_tools.update(resolve_toolset(ts_name)) if composite_tools: - enabled |= _configurable_subset_of(composite_tools, platform) - _default_off_toolsets( - platform, explicitly_configured - ) + enabled |= _configurable_subset_of(composite_tools, platform) - _default_off_toolsets(platform, explicitly_configured) _enable_recently_shipped_toolsets(enabled, config, platform) return enabled def _composite_toolsets(toolset_names: List[str], platform: str, explicitly_configured: bool) -> Set[str]: """Enabled set inferred from composite names (``hermes-cli``) by reverse-mapping tool names. - - ``x_search`` is its own one-tool toolset the composite does NOT include, so the subset loop - never picks it up; inject it when xAI credentials exist (mirroring HASS_TOKEN → homeassistant) - and carve it out of the default-off subtraction. Only runs while no explicit list is saved — - once saved, that list is authoritative. - """ + ``x_search`` is a one-tool toolset the composite does NOT include, so the subset loop never picks it up; + inject it when xAI credentials exist (mirroring HASS_TOKEN → homeassistant) and carve it out of the + default-off subtraction. Only runs while no explicit list is saved — a saved list is authoritative.""" from toolsets import resolve_toolset all_tool_names: Set[str] = set() @@ -760,9 +602,9 @@ def _composite_toolsets(toolset_names: List[str], platform: str, explicitly_conf def _enabled_plugin_toolsets(config: dict, platform: str, toolset_names: List[str], plugin_ts_keys: Set[str]) -> Set[str]: - """Plugin toolsets: on by default unless in _DEFAULT_OFF_TOOLSETS (e.g. bundled spotify — opt-in - so we don't ship 7 schemas to non-users) or "known" for this platform (``known_plugin_toolsets`` - is written on every ``hermes tools`` save) and absent from the saved list.""" + """Plugin toolsets: on by default unless in _DEFAULT_OFF_TOOLSETS (bundled spotify — opt-in so non-users + don't get 7 schemas) or "known" for this platform (``known_plugin_toolsets`` is written on every + ``hermes tools`` save) and absent from the saved list.""" known_for_platform = set((config.get("known_plugin_toolsets", {}) or {}).get(platform, []) or []) return { pts for pts in plugin_ts_keys @@ -778,17 +620,12 @@ def _context_engine_active(config: dict) -> bool: return bool(name) and name != "compressor" -def _get_platform_tools( - config: dict, - platform: str, - *, - include_default_mcp_servers: bool = True, -) -> Set[str]: +def _get_platform_tools(config: dict, platform: str, *, include_default_mcp_servers: bool = True) -> Set[str]: """Resolve which individual toolset names are enabled for a platform.""" platform_toolsets = config.get("platform_toolsets") or {} toolset_names = platform_toolsets.get(platform) - # An explicitly saved list (even a composite like ``hermes-discord``) is an opt-in to the - # platform's native default-off toolsets — see _exempt_explicit_platform_native. + # An explicitly saved list (even a composite like ``hermes-discord``) is an opt-in to the platform's + # native default-off toolsets — see _exempt_explicit_platform_native. explicitly_configured = isinstance(toolset_names, list) if not explicitly_configured: toolset_names = [_platform_default_toolset(platform)] @@ -802,43 +639,29 @@ def _get_platform_tools( explicit_known_keys = configurable_keys | plugin_ts_keys if any(ts in explicit_known_keys for ts in toolset_names): - enabled_toolsets = _explicit_toolsets( - toolset_names, explicit_known_keys, config, platform, explicitly_configured - ) + enabled_toolsets = _explicit_toolsets(toolset_names, explicit_known_keys, config, platform, explicitly_configured) else: enabled_toolsets = _composite_toolsets(toolset_names, platform, explicitly_configured) - _recover_platform_native_toolsets( - enabled_toolsets, platform, skip=configurable_keys | plugin_ts_keys | platform_default_keys - ) + _recover_platform_native_toolsets(enabled_toolsets, platform, skip=configurable_keys | plugin_ts_keys | platform_default_keys) if plugin_ts_keys: enabled_toolsets |= _enabled_plugin_toolsets(config, platform, toolset_names, plugin_ts_keys) - # Context-engine tools are runtime-provided, not in any static composite: keep them for a - # non-default engine even after an explicit save. An explicit EMPTY list means no - # context-engine tools either, unless the user adds ``context_engine`` by hand. + # Context-engine tools are runtime-provided, not in any static composite: keep them for a non-default + # engine even after an explicit save. An explicit EMPTY list means none, unless ``context_engine`` is added by hand. if _context_engine_active(config) and not (explicitly_configured and not toolset_names): enabled_toolsets.add("context_engine") # Explicit non-configurable entries (custom toolsets, MCP server names) pass through. - explicit_passthrough = { - ts for ts in toolset_names - if ts not in explicit_known_keys and ts not in platform_default_keys - } - enabled_toolsets |= _merge_mcp_servers( - config, toolset_names, explicit_passthrough, include_default_mcp_servers - ) + explicit_passthrough = {ts for ts in toolset_names if ts not in explicit_known_keys and ts not in platform_default_keys} + enabled_toolsets |= _merge_mcp_servers(config, toolset_names, explicit_passthrough, include_default_mcp_servers) - # agent.disabled_toolsets is a global suppression list and runs LAST so it overrides everything - # above. It may arrive as a JSON-array string ("['memory']") from `hermes config set` or a - # JSON-mode editor save; parse it so the list is not silently dead. + # agent.disabled_toolsets is a global suppression list and runs LAST so it overrides everything above. It + # may arrive as a JSON-array string ("['memory']") from `hermes config set` or a JSON-mode editor save. disabled_toolsets = (config.get("agent") or {}).get("disabled_toolsets") or [] if disabled_toolsets: from agent.skill_utils import parse_config_string_list - - enabled_toolsets -= { - name.strip() for name in parse_config_string_list(disabled_toolsets) if name.strip() - } + enabled_toolsets -= {name.strip() for name in parse_config_string_list(disabled_toolsets) if name.strip()} if explicitly_configured and toolset_names: _warn_all_invalid_platform_toolsets(platform, platform_toolsets[platform]) @@ -846,13 +669,10 @@ def _get_platform_tools( def _recover_platform_native_toolsets(enabled_toolsets: Set[str], platform: str, *, skip: Set[str]) -> None: - """Add non-configurable platform toolsets (e.g. discord, feishu_doc, feishu_drive) in place. - - These are part of the platform's default composite but absent from CONFIGURABLE_TOOLSETS, so - they can't appear in the TUI checklist or in a user-saved config. Must run for BOTH the explicit - and the composite branch of ``_get_platform_tools`` — otherwise saving via ``hermes tools`` - silently drops them. - """ + """Add non-configurable platform toolsets (discord, feishu_doc, feishu_drive) in place. They are in the + platform's default composite but absent from CONFIGURABLE_TOOLSETS, so they can't appear in the checklist + or a saved config. Must run for BOTH branches of ``_get_platform_tools`` — otherwise saving via + ``hermes tools`` silently drops them.""" from toolsets import resolve_toolset, TOOLSETS platform_tool_universe = set(resolve_toolset(_platform_default_toolset(platform))) @@ -865,12 +685,12 @@ def _recover_platform_native_toolsets(enabled_toolsets: Set[str], platform: str, skip = skip | {k for k in TOOLSETS if k.startswith("hermes-")} skip |= set(_DEFAULT_OFF_TOOLSETS) - {platform} for ts_key, ts_def in TOOLSETS.items(): - # Posture toolsets (``coding``) are session-level selections made by agent/coding_context.py, - # not per-platform capabilities to recover. + # Posture toolsets (``coding``) are session-level selections made by agent/coding_context.py, not + # per-platform capabilities to recover. if ts_key in skip or ts_def.get("includes") or ts_def.get("posture"): continue - # Static membership: a registry-added tool absent from the platform composite must not - # block recovery of a non-configurable toolset whose authored tools the composite lists. + # Static membership: a registry-added tool absent from the platform composite must not block recovery + # of a non-configurable toolset whose authored tools the composite lists. ts_tools = set(resolve_toolset(ts_key, include_registry=False)) if not ts_tools or not ts_tools.issubset(platform_tool_universe): continue @@ -884,12 +704,9 @@ def _recover_platform_native_toolsets(enabled_toolsets: Set[str], platform: str, def _merge_mcp_servers( config: dict, toolset_names: List[str], explicit_passthrough: Set[str], include_default_mcp_servers: bool ) -> Set[str]: - """Explicit passthrough entries plus the MCP servers enabled for this platform. - - MCP servers are available on all platforms by default. If the platform explicitly lists one or - more MCP server names, that is an allowlist; otherwise every globally enabled server is included - (when ``include_default_mcp_servers``). The ``no_mcp`` sentinel disables all MCP servers. - """ + """Explicit passthrough entries plus the MCP servers enabled for this platform. MCP servers are on for all + platforms by default; explicitly listed server names form an allowlist, otherwise every globally enabled + server is included (when ``include_default_mcp_servers``). The ``no_mcp`` sentinel disables all.""" enabled_mcp_servers = enabled_mcp_server_names(config) no_mcp = "no_mcp" in toolset_names if no_mcp: @@ -904,44 +721,33 @@ def _merge_mcp_servers( def _warn_all_invalid_platform_toolsets(platform: str, explicit: list) -> None: - """Warn once when an explicitly configured platform has only invalid toolset names. - - A migration or hand-edit that left ``hermes`` instead of ``hermes-cli`` makes resolve_toolset() - return [] for every entry and the platform silently ends up with no native tools. Surface it - where tools are resolved for a session, not only during ``hermes update``/``hermes doctor``. - """ + """Warn once when an explicitly configured platform has only invalid toolset names (e.g. ``hermes`` left + instead of ``hermes-cli`` makes resolve_toolset() return [] for every entry and the platform silently has + no native tools). Surfaced where tools are resolved for a session, not only in update/doctor.""" from toolsets import validate_toolset named = [str(t) for t in explicit if isinstance(t, str) and t] - if ( - named - and not any(validate_toolset(t) for t in named) - and platform not in _warned_invalid_platform_toolsets - ): + if named and not any(validate_toolset(t) for t in named) and platform not in _warned_invalid_platform_toolsets: _warned_invalid_platform_toolsets.add(platform) logger.warning( "platform '%s' has no valid toolsets configured (unknown " "name(s): %s) - tools will be unavailable. Run `hermes tools` " "to reconfigure. See issue #38798.", - platform, - ", ".join(named), + platform, ", ".join(named), ) def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[str]): """Save the selected toolset keys for a platform to config.""" config.setdefault("platform_toolsets", {}) - - # Drop platform-scoped toolsets that don't apply here, so the "Configure all platforms" - # checklist (or a hand-edited config.yaml) can't turn on `discord` for Telegram. + # Drop platform-scoped toolsets that don't apply here, so the "Configure all platforms" checklist (or a + # hand-edited config.yaml) can't turn on `discord` for Telegram. enabled_toolset_keys = {ts for ts in enabled_toolset_keys if _toolset_allowed_for_platform(ts, platform)} - plugin_keys = _get_plugin_toolset_keys() configurable_keys = _configurable_keys() | plugin_keys - # Platform defaults (hermes-cli, ...) resolve to ALL tools; preserving one would silently - # override the user's unchecked selections on the next read. + # Platform defaults (hermes-cli, ...) resolve to ALL tools; preserving one would silently override the + # user's unchecked selections on the next read. platform_default_keys = _platform_default_keys() - existing_toolsets = cfg_get(config, "platform_toolsets", platform, default=[]) if not isinstance(existing_toolsets, list): existing_toolsets = [] @@ -950,38 +756,29 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[ str(entry) for entry in existing_toolsets if str(entry) not in configurable_keys and str(entry) not in platform_default_keys } - # Saving from the picker is consent to clear the "no_mcp" sentinel: the picker has no checkbox - # for it, so users who once set it by hand could otherwise never re-enable MCP via the UI. + # Saving from the picker is consent to clear the "no_mcp" sentinel: the picker has no checkbox for it, + # so users who once set it by hand could otherwise never re-enable MCP via the UI. preserved_entries.discard("no_mcp") - config["platform_toolsets"][platform] = sorted(enabled_toolset_keys | preserved_entries) - - # Record which plugin toolsets this platform "knows" (distinguishes "new plugin, default - # enabled" from "user disabled it"). _cfg_section normalizes a present-but-null key that - # setdefault alone would not replace. + # Record which plugin toolsets this platform "knows" (distinguishes "new plugin, default enabled" from + # "user disabled it"). _cfg_section normalizes a present-but-null key that setdefault alone would not replace. if plugin_keys: _cfg_section(config, "known_plugin_toolsets")[platform] = sorted(plugin_keys) - - # Same record for builtin toolsets the checklist actually offered; without it an unchecked - # toolset is indistinguishable from one shipped after the save and - # _enable_recently_shipped_toolsets would turn it straight back on. + # Same record for builtin toolsets the checklist offered; without it an unchecked toolset is + # indistinguishable from one shipped after the save and _enable_recently_shipped_toolsets re-enables it. _cfg_section(config, "known_builtin_toolsets")[platform] = sorted(_configurable_keys()) - - # Reconcile with agent.disabled_toolsets, which _get_platform_tools applies as a final override: - # a toolset listed there stays OFF no matter what this function writes (Blank Slate installs - # pre-populate ~27 entries, making the desktop Toolsets UI unable to re-enable anything). Only - # toolsets the user just explicitly enabled FOR THIS PLATFORM are cleared, so the list keeps - # working as a cross-platform suppression list for everything else. + # Reconcile with agent.disabled_toolsets, which _get_platform_tools applies as a final override: a toolset + # listed there stays OFF no matter what this writes (Blank Slate installs pre-populate ~27 entries, making + # the desktop Toolsets UI unable to re-enable anything). Only toolsets just explicitly enabled FOR THIS + # PLATFORM are cleared, so the list keeps working as a cross-platform suppression list for everything else. agent_cfg = config.get("agent") newly_enabled = enabled_toolset_keys - preserved_entries if isinstance(agent_cfg, dict) and agent_cfg.get("disabled_toolsets") and newly_enabled: from agent.skill_utils import parse_config_string_list - parsed_disabled = parse_config_string_list(agent_cfg["disabled_toolsets"]) remaining = [ts for ts in parsed_disabled if ts not in newly_enabled] if remaining != parsed_disabled: agent_cfg["disabled_toolsets"] = remaining - save_config(config) @@ -991,32 +788,24 @@ def _provider_env_ready(provider: dict) -> bool: def _toolset_has_keys( - ts_key: str, - config: dict = None, - *, - force_fresh: bool = False, - features: Optional[NousSubscriptionFeatures] = None, + ts_key: str, config: dict = None, *, force_fresh: bool = False, features: Optional[NousSubscriptionFeatures] = None, ) -> bool: """Check if a toolset's required API keys are configured.""" if config is None: config = load_config() - if ts_key == "vision": try: from agent.auxiliary_client import resolve_vision_provider_client - _provider, client, _model = resolve_vision_provider_client() return client is not None except Exception: return False - if ts_key in {"web", "image_gen", "video_gen", "tts", "stt", "browser"}: if features is None: features = get_nous_subscription_features(config, force_fresh=force_fresh) feature = features.features.get(ts_key) if feature and (feature.available or feature.managed_by_nous): return True - # Provider-aware categories first: a no-key provider (Local Browser, Edge TTS) counts as configured. cat = TOOL_CATEGORIES.get(ts_key) if cat: @@ -1027,32 +816,25 @@ def _toolset_has_keys( return all(get_env_value(var) for var, _ in TOOLSET_ENV_REQUIREMENTS.get(ts_key, [])) -# ─── Menu Helpers ───────────────────────────────────────────────────────────── - def _prompt_choice(question: str, choices: list, default: int = 0) -> int: """Single-select menu (arrow keys). Delegates to curses_radiolist.""" from hermes_cli.curses_ui import curses_radiolist return curses_radiolist(question, choices, selected=default, cancel_returns=default) -# ─── Token Estimation ──────────────────────────────────────────────────────── - +# --- Token Estimation --- # Profile-keyed cache so one process can serve distinct plugin tool catalogs. _tool_token_cache: Optional[Dict[tuple[str, int], Dict[str, int]]] = None def _estimate_tool_tokens() -> Dict[str, int]: - """Return estimated token counts per individual tool name. - - Counts tiktoken (cl100k_base) tokens of the JSON-serialised OpenAI tool schema. Triggers tool - discovery on first call and caches for the process. Empty dict if tiktoken/registry unavailable. - """ + """Estimated tiktoken (cl100k_base) tokens per tool name from the JSON-serialised OpenAI tool schema. + Triggers tool discovery on first call and caches per process; {} if tiktoken/registry unavailable.""" global _tool_token_cache from hermes_constants import hermes_home_key scope = hermes_home_key() _tool_token_cache = _tool_token_cache or {} - try: import model_tools # noqa: F401 — triggers full tool discovery from tools.registry import registry @@ -1060,17 +842,14 @@ def _estimate_tool_tokens() -> Dict[str, int]: except Exception: logger.debug("Tool registry unavailable; skipping token estimation") return _tool_token_cache.setdefault((scope, -1), {}) - if cache_key in _tool_token_cache: return _tool_token_cache[cache_key] - try: import tiktoken enc = tiktoken.get_encoding("cl100k_base") except Exception: logger.debug("tiktoken unavailable; skipping tool token estimation") return _tool_token_cache.setdefault(cache_key, {}) - counts: Dict[str, int] = {} for name in registry.get_all_tool_names(): schema = registry.get_schema(name) @@ -1080,13 +859,7 @@ def _estimate_tool_tokens() -> Dict[str, int]: return counts -def _prompt_toolset_checklist( - platform_label: str, - enabled: Set[str], - platform: str = "cli", - *, - force_fresh: bool = True, -) -> Set[str]: +def _prompt_toolset_checklist(platform_label: str, enabled: Set[str], platform: str = "cli", *, force_fresh: bool = True) -> Set[str]: """Multi-select checklist of toolsets. Returns set of selected toolset keys.""" from hermes_cli.curses_ui import curses_checklist from toolsets import resolve_toolset @@ -1125,8 +898,7 @@ def _prompt_toolset_checklist( return {effective[i][0] for i in chosen} -# ─── Provider-Aware Configuration ──────────────────────────────────────────── - +# --- Provider-Aware Configuration --- def _configure_toolset(ts_key: str, config: dict, *, force_fresh: bool = True, reconfigure: bool = False): """Configure a toolset: provider selection + API keys (TOOL_CATEGORIES), else simple env-var prompts.""" cat = TOOL_CATEGORIES.get(ts_key) @@ -1158,11 +930,8 @@ def _reconfigure_tool(config: dict, *, force_fresh: bool = True): def _toolset_enabled_for_reconfigure(ts_key: str, config: dict) -> bool: - """Return True if a configurable toolset is enabled anywhere. - - Reconfigure must include enabled-but-unconfigured categories so users can finish provider/API- - key setup without disabling and re-enabling the toolset. - """ + """True if a configurable toolset is enabled anywhere. Reconfigure must include enabled-but-unconfigured + categories so users can finish provider/API-key setup without disabling and re-enabling the toolset.""" for platform in PLATFORMS: if not _toolset_allowed_for_platform(ts_key, platform): continue @@ -1175,8 +944,7 @@ def _toolset_enabled_for_reconfigure(ts_key: str, config: dict) -> bool: return False -# ─── Main Entry Point ───────────────────────────────────────────────────────── - +# --- Main Entry Point --- def _shared_metrics_state(config: dict) -> tuple[bool, bool]: """Return (collection_enabled, send_enabled) from a config dict.""" telemetry = config.get("telemetry") @@ -1199,11 +967,8 @@ def _shared_metrics_menu_label(config: dict) -> str: def _configure_shared_metrics_interactive(config: dict) -> None: - """Toggle shared-metrics collection and sending from `hermes tools`. - - Delegates to the setup wizard's prompt so the consent rules live in one place: sending requires - collection, and disabling collection also disables sending. - """ + """Toggle shared-metrics collection/sending from `hermes tools`. Delegates to the setup wizard's prompt so + the consent rules live in one place: sending requires collection; disabling collection disables sending.""" from hermes_cli.setup import setup_telemetry before = _shared_metrics_state(config) @@ -1222,24 +987,18 @@ def _print_toolset_diff(added: Set[str], removed: Set[str], *, indent: str = " def _toolsets_needing_setup(new_enabled: Set[str], config: dict) -> List[str]: - """Selected toolsets still missing provider/API-key setup, in sorted order. - - These must open configuration even when the checklist selection itself didn't change (e.g. Web - Search already enabled but ``web.backend`` missing). - """ + """Selected toolsets still missing provider/API-key setup, sorted. These must open configuration even when + the checklist selection didn't change (e.g. Web Search enabled but ``web.backend`` missing).""" return [ ts_key for ts_key in sorted(new_enabled) - if _is_configurable(ts_key) - and _toolset_needs_configuration_prompt(ts_key, config, force_fresh=True) + if _is_configurable(ts_key) and _toolset_needs_configuration_prompt(ts_key, config, force_fresh=True) ] def _configure_newly_added(added: Set[str], already: Set[str], config: dict) -> None: """Configure newly enabled toolsets that need keys, skipping those already handled.""" for ts_key in sorted(added - already): - if _is_configurable(ts_key) and _toolset_needs_configuration_prompt( - ts_key, config, force_fresh=True, - ): + if _is_configurable(ts_key) and _toolset_needs_configuration_prompt(ts_key, config, force_fresh=True): _configure_toolset(ts_key, config) @@ -1282,12 +1041,9 @@ def _configure_list(to_configure: List[str], config: dict, *, selected: bool = T def _checklist_diff(new_enabled: Set[str], prev: Set[str], platform: str) -> tuple[Set[str], Set[str]]: - """``(added, removed)`` scoped to the checklist's universe. - - The resolved ``prev`` can include non-configurable toolsets (``kanban``, recovered platform - composites) the user was never shown a checkbox for; without this scope the summary would print - spurious ``- kanban`` removals even though the config keeps them (see _checklist_toolset_keys). - """ + """``(added, removed)`` scoped to the checklist's universe: the resolved ``prev`` can include + non-configurable toolsets (``kanban``, recovered composites) the user never saw a checkbox for, which would + otherwise print spurious ``- kanban`` removals even though the config keeps them.""" universe = _checklist_toolset_keys(platform) return (new_enabled - prev) & universe, (prev - new_enabled) & universe @@ -1299,15 +1055,12 @@ def _first_install_flow(config: dict, enabled_platforms: List[str]) -> None: current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False) new_enabled = _prompt_toolset_checklist(pinfo["label"], current_enabled - _DEFAULT_OFF_TOOLSETS, pkey) _print_toolset_diff(*_checklist_diff(new_enabled, current_enabled, pkey)) - auto_configured = apply_nous_managed_defaults(config, enabled_toolsets=new_enabled, force_fresh=True) for ts_key in sorted(auto_configured): label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) print(color(f" ✓ {label}: using your Nous subscription defaults", Colors.GREEN)) - - # Walk through ALL selected tools with provider options or key requirements, so browser - # (Local vs Browserbase), TTS (Edge vs OpenAI vs ElevenLabs), etc. are shown even when a - # free provider exists. + # Walk through ALL selected tools with provider options or key requirements, so browser (Local vs + # Browserbase), TTS (Edge vs OpenAI vs ElevenLabs), etc. are shown even when a free provider exists. _configure_list( [ts for ts in sorted(new_enabled) if _is_configurable(ts) and ts not in auto_configured], config, selected=False, @@ -1326,7 +1079,6 @@ def _configure_all_platforms(config: dict, platform_keys: List[str]) -> bool: new_enabled = _prompt_toolset_checklist("All platforms", all_current, force_fresh=True) selected_to_configure = _toolsets_needing_setup(new_enabled, config) _configure_list(selected_to_configure, config) - if new_enabled == all_current and not selected_to_configure: print(color(" No changes", Colors.DIM)) return False @@ -1336,9 +1088,8 @@ def _configure_all_platforms(config: dict, platform_keys: List[str]) -> bool: if added or removed: print(color(f" {PLATFORMS[pk]['label']}:", Colors.DIM)) _print_toolset_diff(added, removed, indent=" ") - # Keys for newly enabled tools not already handled by the global selected-tool pass, so a - # tool already enabled globally but lacking provider config doesn't drop the user back to - # the main menu. + # Keys for newly enabled tools not already handled by the global selected-tool pass, so a tool enabled + # globally but lacking provider config doesn't drop the user back to the main menu. _configure_newly_added(added, set(selected_to_configure), config) _save_platform_tools(config, pk, new_enabled) save_config(config) @@ -1353,7 +1104,6 @@ def _configure_one_platform(config: dict, pkey: str) -> None: new_enabled = _prompt_toolset_checklist(pinfo["label"], current_enabled, force_fresh=True) selected_to_configure = _toolsets_needing_setup(new_enabled, config) _configure_list(selected_to_configure, config) - if new_enabled != current_enabled or selected_to_configure: added, removed = _checklist_diff(new_enabled, current_enabled, pkey) _print_toolset_diff(added, removed) @@ -1366,12 +1116,9 @@ def _configure_one_platform(config: dict, pkey: str) -> None: def tools_command(args=None, first_install: bool = False, config: dict = None): - """Entry point for `hermes tools` and `hermes setup tools`. - - ``first_install`` (fresh installs) skips the platform menu, goes straight to the CLI checklist - and prompts for API keys on enabled tools. When the wizard passes its own ``config`` dict, - platform_toolsets are written into it so they survive the wizard's final save_config(). - """ + """Entry point for `hermes tools` and `hermes setup tools`. ``first_install`` skips the platform menu, goes + straight to the CLI checklist and prompts for API keys on enabled tools. When the wizard passes its own + ``config`` dict, platform_toolsets are written into it so they survive the wizard's final save_config().""" if config is None: config = load_config() enabled_platforms = _get_enabled_platforms() @@ -1385,7 +1132,6 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): print(color(" Tools that need API keys will be configured when enabled.", Colors.DIM)) print(color(" Guide: https://hermes-agent.nousresearch.com/docs/user-guide/features/tools", Colors.DIM)) print() - if first_install: _first_install_flow(config, enabled_platforms) return