5105 lines
216 KiB
Python
5105 lines
216 KiB
Python
"""Unified tool configuration for Hermes Agent."""
|
|
|
|
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,
|
|
)
|
|
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__)
|
|
|
|
|
|
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).
|
|
_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_error as _print_error,
|
|
print_info as _print_info,
|
|
print_success as _print_success,
|
|
print_warning as _print_warning,
|
|
prompt as _prompt,
|
|
)
|
|
|
|
# ─── 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.
|
|
CONFIGURABLE_TOOLSETS = [
|
|
("web", "🔍 Web Search & Scraping", "web_search, web_extract"),
|
|
("browser", "🌐 Browser Automation", "navigate, click, type, scroll"),
|
|
("terminal", "💻 Terminal & Processes", "terminal, process"),
|
|
("file", "📁 File Operations", "read, write, patch, search"),
|
|
("code_execution", "⚡ Code Execution", "execute_code"),
|
|
("vision", "👁️ Vision / Image Analysis", "vision_analyze"),
|
|
("video", "🎬 Video Analysis", "video_analyze (requires video-capable model)"),
|
|
("image_gen", "🎨 Image Generation", "image_generate"),
|
|
("video_gen", "🎬 Video Generation", "video_generate (text/image/reference)"),
|
|
("x_search", "🐦 X (Twitter) Search", "x_search (requires xAI OAuth or XAI_API_KEY)"),
|
|
("tts", "🔊 Text-to-Speech", "text_to_speech"),
|
|
("stt", "🎙️ Speech-to-Text", "voice transcription (gateway voice messages + voice mode)"),
|
|
("skills", "📚 Skills", "list, view, manage"),
|
|
("todo", "📋 Task Planning", "todo_list"),
|
|
("memory", "💾 Memory", "persistent memory across sessions"),
|
|
("context_engine", "🧩 Context Engine", "runtime tools from the active context engine"),
|
|
("session_search", "🔎 Session Search", "search past conversations"),
|
|
("clarify", "❓ Clarifying Questions", "clarify"),
|
|
("delegation", "👥 Task Delegation", "delegate_task"),
|
|
("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"),
|
|
("homeassistant", "🏠 Home Assistant", "smart home device control"),
|
|
("spotify", "🎵 Spotify", "playback, search, playlists, library"),
|
|
("discord", "💬 Discord (read/participate)", "fetch messages, search members, create thread"),
|
|
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
|
|
("yuanbao", "🤖 Yuanbao", "group info, member queries, DM"),
|
|
("computer_use", "🖱️ Computer Use (macOS/Windows/Linux)", "background desktop control via cua-driver"),
|
|
]
|
|
|
|
|
|
def gui_toolset_label(label: str) -> str:
|
|
"""Strip leading emoji/icons from toolset titles for GUI surfaces.
|
|
|
|
Registry labels use ``<emoji> <title>``; plugin toolsets prefix with ``🔌``. CLI/TUI keeps the
|
|
raw ``label`` — only HTTP APIs call this helper.
|
|
"""
|
|
text = (label or "").strip()
|
|
if not text:
|
|
return text
|
|
parts = text.split(None, 1)
|
|
if len(parts) == 2 and parts[0] and not any(ch.isascii() and ch.isalnum() for ch in parts[0]):
|
|
return parts[1].strip()
|
|
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.
|
|
_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_TOOLSETS = {"stt"}
|
|
|
|
|
|
def _xai_credentials_present() -> bool:
|
|
"""Cheap, side-effect-free check for usable xAI credentials.
|
|
|
|
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).
|
|
"""
|
|
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:
|
|
pass
|
|
try:
|
|
from agent.secret_scope import get_secret
|
|
except ImportError: # pragma: no cover — secret_scope is in-repo
|
|
return bool(str(os.environ.get("XAI_API_KEY") or "").strip())
|
|
return bool(str(get_secret("XAI_API_KEY") or "").strip())
|
|
|
|
|
|
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:
|
|
"""Return the 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.
|
|
"""
|
|
allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts_key)
|
|
if not allowed or default in allowed:
|
|
return default
|
|
return sorted(allowed)[0]
|
|
|
|
|
|
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.
|
|
"""
|
|
result = list(CONFIGURABLE_TOOLSETS)
|
|
seen = {ts_key for ts_key, _, _ in result}
|
|
try:
|
|
from hermes_cli.plugins import discover_plugins, get_plugin_toolsets
|
|
discover_plugins() # idempotent — ensures plugins are loaded
|
|
for entry in get_plugin_toolsets():
|
|
if entry[0] in seen:
|
|
continue
|
|
seen.add(entry[0])
|
|
result.append(entry)
|
|
except Exception:
|
|
pass
|
|
return result
|
|
|
|
|
|
def _get_plugin_toolset_keys() -> set:
|
|
"""Return the set of toolset keys provided by plugins."""
|
|
try:
|
|
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.
|
|
"""
|
|
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
|
|
}
|
|
|
|
# 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.
|
|
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()
|
|
}
|
|
|
|
|
|
def _platform_default_toolset(platform: str) -> str:
|
|
"""Composite toolset a platform falls back to (plugin platforms derive ``hermes-<platform>``)."""
|
|
plat_info = PLATFORMS.get(platform)
|
|
return plat_info["default_toolset"] if plat_info else f"hermes-{platform}"
|
|
|
|
|
|
def _cfg_section(config: dict, key: str) -> dict:
|
|
"""Return ``config[key]`` as a dict, replacing a missing or non-dict value with ``{}``."""
|
|
section = config.setdefault(key, {})
|
|
if not isinstance(section, dict):
|
|
section = {}
|
|
config[key] = section
|
|
return section
|
|
|
|
|
|
def _is_configurable(ts_key: str) -> bool:
|
|
"""True when the toolset has provider options or simple env-var requirements to prompt for."""
|
|
return bool(TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key))
|
|
|
|
|
|
def _toolset_label(ts_key: str) -> str:
|
|
"""Display label for a toolset key (built-in or plugin), falling back to the key itself."""
|
|
return next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key)
|
|
|
|
|
|
# ─── 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.
|
|
|
|
TOOL_CATEGORIES = {
|
|
"tts": {
|
|
"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",
|
|
"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"},
|
|
],
|
|
"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"},
|
|
],
|
|
"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",
|
|
"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"},
|
|
],
|
|
},
|
|
"stt": {
|
|
"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": "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",
|
|
"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"},
|
|
{"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": "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"},
|
|
],
|
|
},
|
|
"web": {
|
|
"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.
|
|
"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",
|
|
"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",
|
|
"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.
|
|
"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"],
|
|
"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``.
|
|
"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",
|
|
},
|
|
],
|
|
},
|
|
"x_search": {
|
|
"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)."
|
|
),
|
|
"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"},
|
|
{"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().
|
|
"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": "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": "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": [],
|
|
"browser_backend": "browser-use", "post_setup": "browser_use_cli"},
|
|
],
|
|
},
|
|
"homeassistant": {
|
|
"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"},
|
|
]},
|
|
],
|
|
},
|
|
"spotify": {
|
|
"name": "Spotify",
|
|
"icon": "🎵",
|
|
"providers": [
|
|
{"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`.
|
|
"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",
|
|
},
|
|
],
|
|
},
|
|
"langfuse": {
|
|
"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"},
|
|
],
|
|
"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"},
|
|
],
|
|
"post_setup": "langfuse"},
|
|
],
|
|
},
|
|
}
|
|
|
|
# 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.
|
|
TOOLSET_ENV_REQUIREMENTS = {
|
|
"vision": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")],
|
|
}
|
|
|
|
|
|
# ─── 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]:
|
|
"""``<binary> --version`` stdout (possibly ""), or None when the probe itself fails."""
|
|
try:
|
|
return subprocess.run(
|
|
[binary, "--version"],
|
|
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, env=_cua_driver_env(),
|
|
creationflags=_post_setup_no_window_flags(),
|
|
).stdout.strip()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def 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.
|
|
|
|
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 <key>`` — 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 <key>")
|
|
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 ───────────────────────────────────────────────
|
|
|
|
_PLATFORM_ENABLE_ENV_VARS = (
|
|
("telegram", "TELEGRAM_BOT_TOKEN"),
|
|
("discord", "DISCORD_BOT_TOKEN"),
|
|
("slack", "SLACK_BOT_TOKEN"),
|
|
("whatsapp", "WHATSAPP_ENABLED"),
|
|
("qqbot", "QQ_APP_ID"),
|
|
)
|
|
|
|
|
|
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)
|
|
]
|
|
|
|
|
|
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.
|
|
"""
|
|
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
|
|
|
|
|
|
def _parse_enabled_flag(value, default: bool = True) -> bool:
|
|
"""Parse bool-like config values used by tool/platform settings."""
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, int):
|
|
return value != 0
|
|
if isinstance(value, str):
|
|
lowered = value.strip().lower()
|
|
if lowered in {"true", "1", "yes", "on"}:
|
|
return True
|
|
if lowered in {"false", "0", "no", "off"}:
|
|
return False
|
|
return default
|
|
|
|
|
|
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.
|
|
"""
|
|
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)
|
|
}
|
|
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)
|
|
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.
|
|
"""
|
|
if not explicitly_configured:
|
|
return
|
|
for ts in list(default_off):
|
|
allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts)
|
|
if allowed is not None and platform in allowed:
|
|
default_off.discard(ts)
|
|
|
|
|
|
#: Toolsets young enough that absence from a saved ``platform_toolsets`` list
|
|
#: means "never offered" rather than "declined".
|
|
#:
|
|
#: 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.
|
|
_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.
|
|
"""
|
|
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
|
|
# Parity is the whole justification, so only enable the toolset 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))
|
|
if not ts_tools or not ts_tools.issubset(composite_tools):
|
|
continue
|
|
enabled_toolsets.add(ts_key)
|
|
|
|
|
|
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).
|
|
"""
|
|
from toolsets import resolve_toolset
|
|
|
|
enabled = set()
|
|
for ts_key, _, _ in CONFIGURABLE_TOOLSETS:
|
|
if not _toolset_allowed_for_platform(ts_key, platform):
|
|
continue
|
|
ts_tools = set(resolve_toolset(ts_key, include_registry=False))
|
|
if ts_tools and ts_tools.issubset(tool_names):
|
|
enabled.add(ts_key)
|
|
return enabled
|
|
|
|
|
|
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).
|
|
"""
|
|
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
|
|
)
|
|
return default_off
|
|
|
|
|
|
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."""
|
|
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).
|
|
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.
|
|
toolset_names = [str(ts) for ts in toolset_names]
|
|
|
|
configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
|
|
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.
|
|
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 x_search_auto_enabled:
|
|
enabled_toolsets.add("x_search")
|
|
|
|
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.
|
|
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)
|
|
|
|
# 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
|
|
):
|
|
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_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
|
|
)
|
|
|
|
# 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 []
|
|
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()
|
|
}
|
|
|
|
if explicitly_configured and toolset_names:
|
|
_warn_all_invalid_platform_toolsets(platform, platform_toolsets[platform])
|
|
|
|
return enabled_toolsets
|
|
|
|
|
|
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.
|
|
"""
|
|
from toolsets import resolve_toolset, TOOLSETS
|
|
|
|
platform_tool_universe = set(resolve_toolset(_platform_default_toolset(platform)))
|
|
configurable_tool_universe = set()
|
|
for ts_key, _, _ in CONFIGURABLE_TOOLSETS:
|
|
configurable_tool_universe.update(resolve_toolset(ts_key))
|
|
claimed = set()
|
|
for ts_key in enabled_toolsets:
|
|
claimed.update(resolve_toolset(ts_key))
|
|
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.
|
|
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.
|
|
ts_tools = set(resolve_toolset(ts_key, include_registry=False))
|
|
if not ts_tools or not ts_tools.issubset(platform_tool_universe):
|
|
continue
|
|
if ts_tools.issubset(configurable_tool_universe):
|
|
continue
|
|
if not ts_tools.issubset(claimed):
|
|
enabled_toolsets.add(ts_key)
|
|
claimed.update(ts_tools)
|
|
|
|
|
|
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.
|
|
"""
|
|
enabled_mcp_servers = enabled_mcp_server_names(config)
|
|
no_mcp = "no_mcp" in toolset_names
|
|
if no_mcp:
|
|
explicit_mcp_servers = set()
|
|
result = explicit_passthrough - enabled_mcp_servers - {"no_mcp"}
|
|
else:
|
|
explicit_mcp_servers = explicit_passthrough & enabled_mcp_servers
|
|
result = explicit_passthrough - enabled_mcp_servers
|
|
if include_default_mcp_servers and not explicit_mcp_servers and not no_mcp:
|
|
return result | enabled_mcp_servers
|
|
return result | explicit_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.
|
|
|
|
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``.
|
|
"""
|
|
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
|
|
):
|
|
_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),
|
|
)
|
|
|
|
|
|
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. 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)
|
|
}
|
|
|
|
# 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
|
|
|
|
# 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)
|
|
preserved_entries = {
|
|
entry for entry in existing_toolsets
|
|
if entry not in configurable_keys and 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.
|
|
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.
|
|
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
|
|
)
|
|
|
|
# Reconcile with agent.disabled_toolsets. _get_platform_tools() applies
|
|
# that list as a final override AFTER reading platform_toolsets.<platform>,
|
|
# 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.
|
|
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)
|
|
|
|
|
|
def _provider_env_ready(provider: dict) -> bool:
|
|
"""True when every env var a provider row declares is set (trivially true for no-key rows)."""
|
|
return all(get_env_value(e["key"]) for e in provider.get("env_vars", []))
|
|
|
|
|
|
def _toolset_has_keys(
|
|
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
|
|
|
|
# Check TOOL_CATEGORIES first (provider-aware). 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)
|
|
|
|
|
|
# ─── 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 ────────────────────────────────────────────────────────
|
|
|
|
# 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.
|
|
"""
|
|
global _tool_token_cache
|
|
from hermes_constants import hermes_home_key
|
|
|
|
scope = hermes_home_key()
|
|
_tool_token_cache = _tool_token_cache or {}
|
|
|
|
try:
|
|
# Trigger full tool discovery (imports all tool modules).
|
|
import model_tools # noqa: F401
|
|
from tools.registry import registry
|
|
cache_key = (scope, registry._generation)
|
|
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)
|
|
if schema:
|
|
# Mirror what gets sent to the API:
|
|
# {"type": "function", "function": <schema>}
|
|
text = _json.dumps({"type": "function", "function": schema})
|
|
counts[name] = len(enc.encode(text))
|
|
_tool_token_cache[cache_key] = counts
|
|
return counts
|
|
|
|
|
|
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
|
|
|
|
# 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.
|
|
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
|
|
]
|
|
|
|
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)
|
|
):
|
|
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
|
|
}
|
|
|
|
# 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
|
|
all_tools: set = set()
|
|
for idx in chosen:
|
|
all_tools.update(resolve_toolset(ts_keys[idx]))
|
|
total = sum(tool_tokens.get(name, 0) for name in all_tools)
|
|
if total >= 1000:
|
|
return f"Est. tool context: ~{total / 1000:.1f}k tokens"
|
|
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,
|
|
)
|
|
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.
|
|
"""
|
|
cat = TOOL_CATEGORIES.get(ts_key)
|
|
|
|
if cat:
|
|
_configure_tool_category(ts_key, cat, config, force_fresh=force_fresh)
|
|
else:
|
|
# Simple fallback for vision, moa, etc.
|
|
_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: <name>`` 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[<key>]["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 = {
|
|
# 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,
|
|
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.<backend>`` (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 ``<section>.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.<provider>`` 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(
|
|
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():
|
|
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")
|
|
|
|
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)
|
|
|
|
save_config(config)
|
|
|
|
|
|
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.
|
|
"""
|
|
for platform in PLATFORMS:
|
|
if not _toolset_allowed_for_platform(ts_key, platform):
|
|
continue
|
|
try:
|
|
enabled = _get_platform_tools(
|
|
config,
|
|
platform,
|
|
include_default_mcp_servers=False,
|
|
)
|
|
except Exception:
|
|
continue
|
|
if ts_key in enabled:
|
|
return True
|
|
return False
|
|
|
|
|
|
# ─── 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")
|
|
telemetry = telemetry if isinstance(telemetry, dict) else {}
|
|
shared = telemetry.get("shared_metrics")
|
|
shared = shared if isinstance(shared, dict) else {}
|
|
return shared.get("enabled") is True, shared.get("send") is True
|
|
|
|
|
|
def _shared_metrics_menu_label(config: dict) -> str:
|
|
"""Menu row for shared metrics, showing both consent states."""
|
|
enabled, send = _shared_metrics_state(config)
|
|
if not enabled:
|
|
state = "off"
|
|
elif send:
|
|
state = "collecting + sending to Nous"
|
|
else:
|
|
state = "collecting locally"
|
|
return f"Configure shared metrics ({state})"
|
|
|
|
|
|
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.
|
|
"""
|
|
from hermes_cli.setup import setup_telemetry
|
|
|
|
before = _shared_metrics_state(config)
|
|
setup_telemetry(config)
|
|
after = _shared_metrics_state(config)
|
|
if before != after:
|
|
save_config(config)
|
|
|
|
|
|
def _print_toolset_diff(added: Set[str], removed: Set[str], *, indent: str = " ") -> None:
|
|
"""Print ``+ label`` / ``- label`` lines for a checklist change."""
|
|
for ts in sorted(added):
|
|
print(color(f"{indent}+ {_toolset_label(ts)}", Colors.GREEN))
|
|
for ts in sorted(removed):
|
|
print(color(f"{indent}- {_toolset_label(ts)}", Colors.RED))
|
|
|
|
|
|
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).
|
|
"""
|
|
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)
|
|
]
|
|
|
|
|
|
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,
|
|
):
|
|
_configure_toolset(ts_key, config)
|
|
|
|
|
|
def _platform_menu_label(config: dict, pkey: str) -> str:
|
|
count = len(_get_platform_tools(config, pkey, include_default_mcp_servers=False))
|
|
total = len(_get_effective_configurable_toolsets())
|
|
return f"Configure {PLATFORMS[pkey]['label']} ({count}/{total} enabled)"
|
|
|
|
|
|
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().
|
|
"""
|
|
if config is None:
|
|
config = load_config()
|
|
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()
|
|
return
|
|
print(color("⚕ Hermes Tool Configuration", Colors.CYAN, Colors.BOLD))
|
|
print(color(" Enable or disable tools per platform.", Colors.DIM))
|
|
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()
|
|
|
|
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()
|
|
|
|
return
|
|
|
|
# ── Returning user: platform menu loop ──
|
|
platform_keys = list(enabled_platforms)
|
|
platform_choices = [_platform_menu_label(config, pkey) for pkey in platform_keys]
|
|
|
|
if len(platform_keys) > 1:
|
|
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:
|
|
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)
|
|
|
|
while True:
|
|
idx = _prompt_choice("Select an option:", platform_choices, default=0)
|
|
|
|
if idx == _done_idx:
|
|
break
|
|
|
|
if idx == _reconfig_idx:
|
|
_reconfigure_tool(config, force_fresh=True)
|
|
print()
|
|
continue
|
|
|
|
if idx == _metrics_idx:
|
|
_configure_shared_metrics_interactive(config)
|
|
platform_choices[_metrics_idx] = _shared_metrics_menu_label(config)
|
|
print()
|
|
continue
|
|
|
|
if 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))
|
|
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))
|
|
|
|
print()
|
|
platform_choices[idx] = _platform_menu_label(config, pkey)
|
|
|
|
print()
|
|
from hermes_constants import display_hermes_home
|
|
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)}")
|