diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 0468b96916..2898617af4 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -6,24 +6,16 @@ import os from pathlib import Path from typing import Dict, List, Optional, Set -from hermes_cli.config import cfg_get, load_config, save_config, get_env_value +from hermes_cli.cli_output import print_info as _print_info from hermes_cli.colors import Colors, color +from hermes_cli.config import cfg_get, load_config, save_config, get_env_value from hermes_cli.nous_subscription import ( - NousSubscriptionFeatures, apply_nous_managed_defaults, get_nous_subscription_features, -) + NousSubscriptionFeatures, apply_nous_managed_defaults, get_nous_subscription_features) +from hermes_cli.platforms import PLATFORMS as _PLATFORMS_REGISTRY from hermes_cli.toolset_scope import ( - _TOOLSET_PLATFORM_RESTRICTIONS, toolset_allowed_for_platform as _toolset_allowed_for_platform, -) - -logger = logging.getLogger(__name__) - -# Platforms already warned about an all-invalid platform_toolsets list (warn once, not per resolution). -_warned_invalid_platform_toolsets: Set[str] = set() - -PROJECT_ROOT = Path(__file__).parent.parent.resolve() - -from hermes_cli.cli_output import print_info as _print_info # noqa: E402 — late import block -from hermes_cli.tools_config_cua import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches + _TOOLSET_PLATFORM_RESTRICTIONS, toolset_allowed_for_platform as _toolset_allowed_for_platform) +# Re-exports: keep ``hermes_cli.tools_config.X`` callers and test patch targets resolving. +from hermes_cli.tools_config_cua import ( # noqa: F401 _post_setup_no_window_flags, _cua_driver_cmd, _cua_version_summary, _resolved_cua_driver_cmd, _cua_driver_env, _CUA_DRIVER_CONTRACT_CACHE, _cua_driver_contract_status, _cua_driver_install_ready, _pip_install, _cua_install_target_writable, _cua_driver_version, install_cua_driver, @@ -32,18 +24,16 @@ from hermes_cli.tools_config_cua import ( # noqa: F401 — re-exported for herm _clear_stale_windows_cua_install_lock, _clear_stale_cua_install_lock, _cua_install_lock_held, _cua_release_endpoint_reachable, _ps_single_quote, _cua_driver_autostart_registered_windows, _repair_cua_driver_autostart_windows, _remove_quietly, _print_cua_platform_notes, - _run_cua_driver_installer, -) -from hermes_cli.tools_config_post_setup import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches + _run_cua_driver_installer) +from hermes_cli.tools_config_post_setup import ( # noqa: F401 _ensure_browser_use_cli, _post_setup_lightpanda, _post_setup_agent_browser, _post_setup_camofox, _KITTENTTS_WHEEL_URL, _PIP_POST_SETUP_HOOKS, _post_setup_pip, _post_setup_spotify, _post_setup_langfuse, _post_setup_xai_grok, _POST_SETUP_HOOKS, _run_post_setup, valid_post_setup_keys, run_post_setup_command, _POST_SETUP_INSTALLED, _post_setup_already_installed, _module_installed, _RESTORABLE_PYTHON_TOOL_DEPENDENCIES, active_restorable_python_tool_dependencies, restorable_python_tool_dependency, _agent_browser_installed, _camofox_installed, _lightpanda_installed, - _cloud_agent_browser_installed, _POST_SETUP_READY, -) -from hermes_cli.tools_config_providers import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches + _cloud_agent_browser_installed, _POST_SETUP_READY) +from hermes_cli.tools_config_providers import ( # noqa: F401 _plugin_provider_rows, _plugin_image_gen_providers, _plugin_video_gen_providers, _plugin_web_search_providers, _plugin_browser_providers, _plugin_tts_providers, web_provider_capabilities, _PLUGIN_ROW_BUILDERS, _visible_providers, provider_readiness_status, _toolset_needs_configuration_prompt, @@ -55,13 +45,21 @@ from hermes_cli.tools_config_providers import ( # noqa: F401 — re-exported fo _select_plugin_video_gen_provider, STT_MODEL_CATALOG, _STT_MODEL_CONFIG_KEY, _configure_stt_model, _PROVIDER_MARKER_SECTIONS, _write_provider_config, apply_provider_selection, _nous_provider_gate, _finish_provider_selection, _print_provider_selection, _configure_provider, _reconfigure_provider, - _configure_vision_backend, _configure_vision_provider_model, _configure_simple_requirements, -) -from hermes_cli.tools_config_mcp import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches + _configure_vision_backend, _configure_vision_provider_model, _configure_simple_requirements) +from hermes_cli.tools_config_mcp import ( # noqa: F401 _mcp_match_filter, _mcp_preselected, _apply_mcp_checklist, _configure_mcp_tools_interactive, _apply_toolset_change, _apply_mcp_change, _print_tools_list, _known_tool_platforms, - tools_disable_enable_command, -) + tools_disable_enable_command) + +logger = logging.getLogger(__name__) + +# Platforms already warned about an all-invalid platform_toolsets list (warn once, not per resolution). +_warned_invalid_platform_toolsets: Set[str] = set() + +PROJECT_ROOT = Path(__file__).parent.parent.resolve() + +# Platform display config derived from the canonical registry (dict-of-dicts for ``PLATFORMS[key]["label"]``). +PLATFORMS = {k: {"label": info.label, "default_toolset": info.default_toolset} for k, info in _PLATFORMS_REGISTRY.items()} # --- Toolset Registry --- # Toolsets shown in the configurator: (toolset key in toolsets.py TOOLSETS, label, description). @@ -99,22 +97,18 @@ def gui_toolset_label(label: str) -> str: """Strip the leading ```` from a toolset title for GUI surfaces (plugins prefix ``🔌``). CLI/TUI keeps the raw label — only HTTP APIs call this.""" text = (label or "").strip() - if not text: - return text 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]): + if len(parts) == 2 and not any(ch.isascii() and ch.isalnum() for ch in parts[0]): return parts[1].strip() return text -# OFF by default for new installs (still in _HERMES_CORE_TOOLS; the checklist just won't pre-select them). -# video_gen is niche/paid/slow. x_search auto-enables when xAI credentials exist (SuperGrok OAuth or -# XAI_API_KEY), mirroring HASS_TOKEN → homeassistant; its check_fn still hides the schema if creds expire. +# OFF by default for new installs (still in _HERMES_CORE_TOOLS; the checklist won't pre-select them). x_search +# auto-enables when xAI creds exist (mirrors HASS_TOKEN → homeassistant); its check_fn still gates the schema. _DEFAULT_OFF_TOOLSETS = {"homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search", "a2a"} -# Config-only capabilities: in `hermes tools` for provider setup (TOOL_CATEGORIES) but NOT model toolsets — -# zero schemas, own on/off switch (``stt.enabled``), not ``platform_toolsets``. Excluded from the per-platform -# checklist; configured via "Reconfigure an existing tool" and the GUI provider matrix. +# Config-only capabilities: provider setup in `hermes tools` (TOOL_CATEGORIES) but not model toolsets — zero +# schemas, own switch (``stt.enabled``), never in ``platform_toolsets`` or the per-platform checklist. _CONFIG_ONLY_TOOLSETS = {"stt"} @@ -136,7 +130,7 @@ def _xai_credentials_present() -> bool: 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()) + get_secret = os.environ.get return bool(str(get_secret("XAI_API_KEY") or "").strip()) @@ -153,9 +147,7 @@ def _toolset_configuration_platform(ts_key: str, default: str = "cli") -> str: """Platform a platform-less configuration UI should target: a toolset restricted away from ``default`` must be configured on a supported platform, else the save helper drops it and the UI reports a no-op.""" allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts_key) - if not allowed or default in allowed: - return default - return sorted(allowed)[0] + return default if not allowed or default in allowed else sorted(allowed)[0] def _get_effective_configurable_toolsets(): @@ -166,10 +158,9 @@ def _get_effective_configurable_toolsets(): 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) + if entry[0] not in seen: + seen.add(entry[0]) + result.append(entry) except Exception: pass return result @@ -187,25 +178,16 @@ def _get_plugin_toolset_keys() -> set: def _checklist_toolset_keys(platform: str) -> Set[str]: - """Toolset keys the ``hermes tools`` checklist offers for ``platform`` (mirrors ``_prompt_toolset_checklist``). - Non-configurable toolsets ``_get_platform_tools`` resolves at read time (``kanban``, recovered - composites, MCP names) are NOT here — the checklist never shows them.""" + """Toolset keys the ``hermes tools`` checklist offers for ``platform`` (mirrors ``_prompt_toolset_checklist``); + read-time-resolved toolsets (``kanban``, recovered composites, MCP names) are NOT here.""" 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 (dict-of-dicts for ``PLATFORMS[key]["label"]``). -from hermes_cli.platforms import PLATFORMS as _PLATFORMS_REGISTRY - -PLATFORMS = {k: {"label": info.label, "default_toolset": info.default_toolset} for k, info in _PLATFORMS_REGISTRY.items()} + if _toolset_allowed_for_platform(ts_key, platform) and ts_key not in _CONFIG_ONLY_TOOLSETS} def _platform_default_toolset(platform: str) -> str: """Composite toolset a platform falls back to (plugin platforms derive ``hermes-``).""" - plat_info = PLATFORMS.get(platform) - return plat_info["default_toolset"] if plat_info else f"hermes-{platform}" + return PLATFORMS[platform]["default_toolset"] if platform in PLATFORMS else f"hermes-{platform}" def _cfg_section(config: dict, key: str) -> dict: @@ -227,62 +209,72 @@ def _toolset_label(ts_key: str) -> str: return next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key) -# --- Tool Categories (provider-aware configuration) --- -# toolset key -> provider options shown when the toolset is newly enabled. Toolsets not in this map either -# need no config or use the TOOLSET_ENV_REQUIREMENTS fallback. +# --- Tool Categories: toolset key -> provider options shown when newly enabled. Toolsets not in this map +# either need no config or use the TOOLSET_ENV_REQUIREMENTS fallback. +def _key(key: str, prompt: str, url: str = "", **extra) -> dict: + """One ``env_vars`` entry for a provider row (key order matters for the GUI JSON).""" + return {"key": key, "prompt": prompt, **extra, **({"url": url} if url else {})} + + +def _row(name: str, badge: str = "", tag: str = "", env_vars: list = (), **markers) -> dict: + """One TOOL_CATEGORIES provider row; ``markers`` are the ``*_provider`` / ``post_setup`` / Nous keys.""" + row = {"name": name} + if badge: + row["badge"] = badge + if tag: + row["tag"] = tag + row["env_vars"] = list(env_vars) + row.update(markers) + return row + + +_NOUS = {"requires_nous_auth": True} +_OPENAI_VOICE_KEY = _key("VOICE_TOOLS_OPENAI_KEY", "OpenAI API key", "https://platform.openai.com/api-keys") +_ELEVENLABS_KEY = _key("ELEVENLABS_API_KEY", "ElevenLabs API key", "https://elevenlabs.io/app/settings/api-keys") +_DEEPINFRA_KEY = _key("DEEPINFRA_API_KEY", "DeepInfra API key", "https://deepinfra.com/dash/api_keys") +_LANGFUSE_PUBLIC = ("HERMES_LANGFUSE_PUBLIC_KEY", "Langfuse public key (pk-lf-...)") +_LANGFUSE_SECRET = ("HERMES_LANGFUSE_SECRET_KEY", "Langfuse secret key (sk-lf-...)") 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"}, + _row("Microsoft Edge TTS", "★ recommended · free", "Good quality, no API key needed", tts_provider="edge"), + _row("Nous Subscription", "subscription", "Managed OpenAI TTS billed to your subscription", tts_provider="openai", + **_NOUS, managed_nous_feature="tts", override_env_vars=["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"]), + _row("OpenAI TTS", "paid", "High quality voices", [_OPENAI_VOICE_KEY], tts_provider="openai"), + _row("xAI TTS", tag="Grok voices — uses xAI Grok OAuth or XAI_API_KEY", tts_provider="xai", post_setup="xai_grok"), + _row("ElevenLabs", "paid", "Most natural voices", [_ELEVENLABS_KEY], 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"}, + _row("Mistral (Voxtral TTS)", "paid", "Multilingual, native Opus", + [_key("MISTRAL_API_KEY", "Mistral API key", "https://console.mistral.ai/")], tts_provider="mistral"), + _row("Google Gemini TTS", "preview", "30 prebuilt voices, controllable via prompts", + [_key("GEMINI_API_KEY", "Gemini API key", "https://aistudio.google.com/app/apikey")], tts_provider="gemini"), + _row("KittenTTS", "local · free", "Lightweight local ONNX TTS (~25MB), no API key", tts_provider="kittentts", + post_setup="kittentts"), + _row("Piper", "local · free", "Local neural TTS, 44 languages (voices ~20-90MB)", tts_provider="piper", + post_setup="piper"), + _row("DeepInfra TTS", "paid", "Chatterbox, Qwen3-TTS, … — live catalog from api.deepinfra.com", [_DEEPINFRA_KEY], + 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"}, + _row("Local Whisper", "★ recommended · free", "faster-whisper on-device, no API key", stt_provider="local", + post_setup="faster_whisper"), + _row("Nous Subscription", "subscription", "Managed OpenAI transcription billed to your subscription", + stt_provider="openai", **_NOUS, managed_nous_feature="stt", + override_env_vars=["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"]), + _row("OpenAI", "paid", "whisper-1, gpt-4o-transcribe, gpt-transcribe", [_OPENAI_VOICE_KEY], stt_provider="openai"), + _row("Groq", "free tier", "Whisper large-v3 family — very fast", + [_key("GROQ_API_KEY", "Groq API key", "https://console.groq.com/keys")], stt_provider="groq"), + _row("xAI", tag="grok-stt — uses xAI Grok OAuth or XAI_API_KEY", stt_provider="xai", post_setup="xai_grok"), + _row("ElevenLabs Scribe", "paid", "scribe_v2 — diarization + audio-event tagging", [_ELEVENLABS_KEY], + 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"}, + _row("DeepInfra", "paid", "Live STT catalog from api.deepinfra.com", [_DEEPINFRA_KEY], stt_provider="deepinfra"), ], }, "web": { @@ -293,11 +285,11 @@ TOOL_CATEGORIES = { # non-provider firecrawl setup-flow rows live here: managed via Nous subscription, and self-hosted. "providers": [ {"name": "Nous Subscription", "badge": "subscription", "tag": "Managed Firecrawl billed to your subscription", - "web_backend": "firecrawl", "env_vars": [], "requires_nous_auth": True, "managed_nous_feature": "web", + "web_backend": "firecrawl", "env_vars": [], **_NOUS, "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)"}]}, + "env_vars": [_key("FIRECRAWL_API_URL", "Your Firecrawl instance URL (e.g., http://localhost:3002)")]}, ], }, "image_gen": { @@ -305,23 +297,18 @@ TOOL_CATEGORIES = { # Provider rows (FAL, OpenAI, OpenAI Codex, xAI) come from plugins.image_gen. via # _plugin_image_gen_providers(). Only the managed "Nous Subscription" row lives here — fal backend, distinct UX. "providers": [ - {"name": "Nous Subscription", "badge": "subscription", - "tag": "Managed FAL image generation billed to your subscription", "env_vars": [], - "requires_nous_auth": True, "managed_nous_feature": "image_gen", "override_env_vars": ["FAL_KEY"], - "imagegen_backend": "fal"}, + _row("Nous Subscription", "subscription", "Managed FAL image generation billed to your subscription", **_NOUS, + managed_nous_feature="image_gen", override_env_vars=["FAL_KEY"], imagegen_backend="fal"), ], }, "video_gen": { "name": "Video Generation", "icon": "🎬", # Mirrors image_gen: managed FAL video billed via the Nous Portal. Plugin-backed rows (FAL BYOK, xAI, …) - # are injected at runtime by ``_plugin_video_gen_providers()`` in ``_visible_providers``. + # are injected at runtime by ``_plugin_video_gen_providers()`` in ``_visible_providers``. Picking this row + # sets video_gen.provider = "fal" + use_gateway so the FAL plugin routes through the managed queue gateway. "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"], - # Underlying plugin backend: picking this row sets video_gen.provider = "fal" and - # video_gen.use_gateway = True so the FAL plugin routes through the managed queue gateway. - "video_gen_plugin_name": "fal"}, + _row("Nous Subscription", "subscription", "Managed FAL video generation billed to your subscription", **_NOUS, + managed_nous_feature="video_gen", override_env_vars=["FAL_KEY"], video_gen_plugin_name="fal"), ], }, "x_search": { @@ -334,10 +321,10 @@ TOOL_CATEGORIES = { ), "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/"}]}, + _row("xAI Grok OAuth (SuperGrok / Premium+)", "subscription", "Browser login at accounts.x.ai — no API key required", + post_setup="xai_grok"), + _row("xAI API key", "paid", "Direct xAI API billing via XAI_API_KEY", + [_key("XAI_API_KEY", "xAI API key", "https://console.x.ai/")]), ], }, "browser": { @@ -349,81 +336,68 @@ TOOL_CATEGORIES = { # ``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 only the agent-browser CLI: Browser Use hosts its own Chromium, so the - # local-Chromium install and readiness gate must not apply (with "agent_browser" this row read - # "needs setup" forever on machines without a local Chromium build). - "post_setup": "browserbase"}, - {"name": "Camofox", "badge": "free · local", "tag": "Anti-detection browser (Firefox/Camoufox)", - "env_vars": [{"key": "CAMOFOX_URL", "prompt": "Camofox server URL", "default": "http://localhost:9377", "url": "https://github.com/jo-inc/camofox-browser"}], - "browser_provider": "camofox", "post_setup": "camofox"}, - {"name": "Browser Use", "badge": "free · local · cloud", "tag": "New SOTA web harness (CLI 3.0)", "env_vars": [], - "browser_backend": "browser-use", "post_setup": "browser_use_cli"}, + _row("Local Browser", "★ recommended · free", "Headless Chromium, no API key needed", browser_provider="local", + browser_engine="auto", post_setup="agent_browser"), + _row("Lightpanda", "free · local · no Chromium", "Zig headless browser spawned by Hermes, text-only (no screenshots)", + browser_provider="local", browser_engine="lightpanda", post_setup="lightpanda"), + # Cloud hook installs only the agent-browser CLI: Browser Use hosts its own Chromium, so the + # local-Chromium install and readiness gate must not apply (with "agent_browser" this row read + # "needs setup" forever on machines without a local Chromium build). + _row("Nous Subscription (Browser Use cloud)", "subscription", "Managed Browser Use billed to your subscription", + browser_provider="browser-use", **_NOUS, managed_nous_feature="browser", + override_env_vars=["BROWSER_USE_API_KEY"], post_setup="browserbase"), + _row("Camofox", "free · local", "Anti-detection browser (Firefox/Camoufox)", + [_key("CAMOFOX_URL", "Camofox server URL", "https://github.com/jo-inc/camofox-browser", default="http://localhost:9377")], + browser_provider="camofox", post_setup="camofox"), + _row("Browser Use", "free · local · cloud", "New SOTA web harness (CLI 3.0)", 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"}]}, + _row("Home Assistant", tag="REST API integration", + env_vars=[_key("HASS_TOKEN", "Home Assistant Long-Lived Access Token"), + _key("HASS_URL", "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"}, - ], + "providers": [_row("Spotify Web API", tag="PKCE OAuth — opens the setup wizard", post_setup="spotify")], }, "computer_use": { "name": "Computer Use (macOS/Windows/Linux)", "icon": "🖱️", # Runtime backends ship for macOS, Windows, Linux (X11; Wayland via XWayland). Gaps surface via `computer-use doctor`. "platform_gate": ["darwin", "win32", "linux"], + # cua-driver reads HOME/TMPDIR from the process env; HERMES_CUA_DRIVER_CMD selects a specific + # binary (e.g. a local build). There is no version-pin env var. "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.", - # cua-driver reads HOME/TMPDIR from the process env; HERMES_CUA_DRIVER_CMD selects a specific - # binary (e.g. a local build). There is no version-pin env var. - "env_vars": [], "computer_use_backend": "cua", "post_setup": "cua_driver"}, + _row("cua-driver (background)", "★ recommended · free · local", + "Background computer-use via cua-driver — does NOT steal your cursor or focus. Works with any model.", + 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"}, + _row("Langfuse Cloud", tag="Hosted Langfuse (cloud.langfuse.com)", post_setup="langfuse", + env_vars=[_key(*_LANGFUSE_PUBLIC, "https://cloud.langfuse.com"), _key(*_LANGFUSE_SECRET, "https://cloud.langfuse.com")]), + _row("Langfuse Self-Hosted", tag="Self-hosted Langfuse instance", post_setup="langfuse", + env_vars=[_key(*_LANGFUSE_PUBLIC), _key(*_LANGFUSE_SECRET), + _key("HERMES_LANGFUSE_BASE_URL", "Langfuse server URL (e.g. http://localhost:3000)", default="http://localhost:3000")]), ], }, } -# Env-var fallback for toolsets NOT in TOOL_CATEGORIES. `vision` is only a presence marker (registers it as -# configurable: reconfigure menu + "[no API key]" suffix); its setup runs through `_configure_vision_backend()` -# (full provider+model picker, never forcing OpenRouter) and `_toolset_has_keys("vision")` uses -# `resolve_vision_provider_client()`. -TOOLSET_ENV_REQUIREMENTS = { - "vision": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")], -} - +# Env-var fallback for toolsets NOT in TOOL_CATEGORIES. `vision` is only a presence marker (reconfigure menu + +# "[no API key]" suffix): setup runs `_configure_vision_backend()` and `_toolset_has_keys("vision")` uses +# `resolve_vision_provider_client()` — never forcing OpenRouter. +TOOLSET_ENV_REQUIREMENTS = {"vision": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")]} # --- 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"), -) + ("whatsapp", "WHATSAPP_ENABLED"), ("qqbot", "QQ_APP_ID")) def _get_enabled_platforms() -> List[str]: @@ -440,12 +414,8 @@ def _platform_toolset_summary(config: dict, platforms: Optional[List[str]] = Non 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, (bool, int)): + return bool(value) if isinstance(value, str): lowered = value.strip().lower() if lowered in {"true", "1", "yes", "on"}: @@ -456,10 +426,9 @@ def _parse_enabled_flag(value, default: bool = True) -> bool: def enabled_mcp_server_names(config: dict) -> Set[str]: - """Names of MCP servers globally enabled in config.yaml or by a plugin. Shared by the platform and cron - resolvers so every path agrees on MCP membership. A server is enabled unless ``enabled`` is explicitly - falsey (missing/unknown counts as enabled). Portable-plugin servers (in-memory, not in config.yaml) are - included so their tools reach the model's schema — enabling the plugin is the user's opt-in.""" + """MCP servers globally enabled in config.yaml or by a plugin (shared by platform + cron resolvers). Enabled + unless ``enabled`` is explicitly falsey; portable-plugin servers (in-memory) count — enabling the plugin is + the opt-in.""" mcp_servers = (config or {}).get("mcp_servers") or {} names = { str(name) for name, server_cfg in mcp_servers.items() @@ -475,16 +444,10 @@ def enabled_mcp_server_names(config: dict) -> Set[str]: def _exempt_explicit_platform_native(default_off: Set[str], platform: str, *, explicitly_configured: bool) -> None: - """Let platform-native default-off toolsets through on explicit config (mutates ``default_off``). - Default-off toolsets restricted to a platform (``discord`` on discord) are its native tools: off for - unconfigured platforms as a security opt-in, but once the user explicitly saves a toolset list, stripping - them silently would defeat that configuration.""" - if not explicitly_configured: - return - for ts in list(default_off): - allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts) - if allowed is not None and platform in allowed: - default_off.discard(ts) + """Let platform-native default-off toolsets (``discord`` on discord) through on explicit config (mutates + ``default_off``): off for unconfigured platforms as a security opt-in, but a saved list is that opt-in.""" + if explicitly_configured: + default_off -= {ts for ts in default_off if platform in (_TOOLSET_PLATFORM_RESTRICTIONS.get(ts) or ())} #: Toolsets young enough that absence from a saved ``platform_toolsets`` list means "never offered", not @@ -498,10 +461,9 @@ _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 (mutates ``enabled_toolsets``). - Both ways of saying no outlive this: unchecking in ``hermes tools`` records the toolset in - ``known_builtin_toolsets`` (declined from then on), and ``agent.disabled_toolsets`` is subtracted after - every rule in :func:`_get_platform_tools`.""" + """Turn on toolsets that shipped after this platform's saved list (mutates ``enabled_toolsets``). Both "no"s + outlive this: unchecking records ``known_builtin_toolsets`` (declined), and ``agent.disabled_toolsets`` is + subtracted last in :func:`_get_platform_tools`.""" from toolsets import resolve_toolset offered = (config.get("known_builtin_toolsets") or {}).get(platform) @@ -509,9 +471,7 @@ def _enable_recently_shipped_toolsets(enabled_toolsets: Set[str], config: dict, 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): + if ts_key in enabled_toolsets or ts_key in declined or not _toolset_allowed_for_platform(ts_key, platform): continue # Only enable where staying on the composite would have enabled it anyway; deliberately narrow # composites (hermes-acp, hermes-webhook) stay narrow. @@ -524,27 +484,23 @@ def _enable_recently_shipped_toolsets(enabled_toolsets: Set[str], config: dict, def _configurable_subset_of(tool_names: Set[str], platform: str) -> Set[str]: - """Configurable toolsets whose STATIC membership is contained in ``tool_names``. Uses - ``include_registry=False``: a tool registered into a toolset at runtime (delegate_cli -> delegation, - desktop-only read_terminal -> terminal) that the composite never listed must not drop the whole toolset.""" + """Configurable toolsets whose STATIC membership is within ``tool_names`` (``include_registry=False``: a + runtime-registered tool the composite never listed must not drop the whole toolset).""" 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) + if _toolset_allowed_for_platform(ts_key, platform): + ts_tools = set(resolve_toolset(ts_key, include_registry=False)) + if ts_tools and ts_tools <= 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``. - A platform whose own name is a default-off toolset (``homeassistant``) keeps it on first install, except - platform-restricted toolsets, which stay opt-in even on their own platform (``discord`` on discord stays - OFF). A configured HASS_TOKEN is an explicit opt-in (check_fn gates at runtime) and must not be stripped, - or HA silently vanishes from platforms like cron that resolve without a saved list.""" + """Toolsets to strip from an implicit (composite-derived) enable set. A platform named after a default-off + toolset (``homeassistant``) keeps it, except platform-restricted ones (``discord`` on discord stays OFF); a + configured HASS_TOKEN is an explicit opt-in that must survive platforms resolving without a saved list.""" default_off = set(_DEFAULT_OFF_TOOLSETS) if platform in default_off and platform not in _TOOLSET_PLATFORM_RESTRICTIONS: default_off.remove(platform) @@ -564,19 +520,16 @@ def _platform_default_keys() -> Set[str]: def _explicit_toolsets( toolset_names: List[str], explicit_known_keys: Set[str], config: dict, platform: str, - explicitly_configured: bool, -) -> Set[str]: - """Enabled set when the saved list names configurable/plugin keys directly. Direct membership avoids the - subset-inference bug where composites like ``hermes-cli`` (all _HERMES_CORE_TOOLS) re-enabled disabled - toolsets. A mixed list (``[hermes-cli, spotify]``) still expands the composite, else sessions keep only the - opt-ins and lose every native tool; _DEFAULT_OFF_TOOLSETS applies to that implicit expansion only.""" + explicitly_configured: bool) -> Set[str]: + """Enabled set when the saved list names configurable/plugin keys directly (subset inference over + ``hermes-cli`` would re-enable disabled toolsets). A mixed list (``[hermes-cli, spotify]``) still expands the + composite; _DEFAULT_OFF_TOOLSETS applies to that implicit expansion only.""" from toolsets import resolve_toolset, TOOLSETS enabled = {ts for ts in toolset_names if ts in explicit_known_keys and _toolset_allowed_for_platform(ts, platform)} - composite_tools: Set[str] = set() - for ts_name in toolset_names: - if ts_name not in explicit_known_keys and ts_name in TOOLSETS: - composite_tools.update(resolve_toolset(ts_name)) + composite_tools = { + t for ts_name in toolset_names if ts_name not in explicit_known_keys and ts_name in TOOLSETS + for t in resolve_toolset(ts_name)} if composite_tools: enabled |= _configurable_subset_of(composite_tools, platform) - _default_off_toolsets(platform, explicitly_configured) _enable_recently_shipped_toolsets(enabled, config, platform) @@ -584,15 +537,11 @@ def _explicit_toolsets( def _composite_toolsets(toolset_names: List[str], platform: str, explicitly_configured: bool) -> Set[str]: - """Enabled set inferred from composite names (``hermes-cli``) by reverse-mapping tool names. - ``x_search`` is a one-tool toolset the composite does NOT include, so the subset loop never picks it up; - inject it when xAI credentials exist (mirroring HASS_TOKEN → homeassistant) and carve it out of the - default-off subtraction. Only runs while no explicit list is saved — a saved list is authoritative.""" + """Enabled set inferred from composite names by reverse-mapping tool names (only while no explicit list is + saved). ``x_search`` is not in any composite, so inject it when xAI creds exist and exempt it from default-off.""" from toolsets import resolve_toolset - all_tool_names: Set[str] = set() - for ts_name in toolset_names: - all_tool_names.update(resolve_toolset(ts_name)) + all_tool_names = {t for ts_name in toolset_names for t in resolve_toolset(ts_name)} enabled = _configurable_subset_of(all_tool_names, platform) default_off = _default_off_toolsets(platform, explicitly_configured) if _toolset_allowed_for_platform("x_search", platform) and _xai_credentials_present(): @@ -602,9 +551,8 @@ def _composite_toolsets(toolset_names: List[str], platform: str, explicitly_conf def _enabled_plugin_toolsets(config: dict, platform: str, toolset_names: List[str], plugin_ts_keys: Set[str]) -> Set[str]: - """Plugin toolsets: on by default unless in _DEFAULT_OFF_TOOLSETS (bundled spotify — opt-in so non-users - don't get 7 schemas) or "known" for this platform (``known_plugin_toolsets`` is written on every - ``hermes tools`` save) and absent from the saved list.""" + """Plugin toolsets: on by default unless default-off (bundled spotify) or "known" for this platform + (``known_plugin_toolsets``, written on every save) and absent from the saved list.""" known_for_platform = set((config.get("known_plugin_toolsets", {}) or {}).get(platform, []) or []) return { pts for pts in plugin_ts_keys @@ -614,9 +562,7 @@ def _enabled_plugin_toolsets(config: dict, platform: str, toolset_names: List[st def _context_engine_active(config: dict) -> bool: context_cfg = config.get("context") or {} - if not isinstance(context_cfg, dict): - context_cfg = {} - name = str(context_cfg.get("engine") or "compressor").strip().lower() + name = str(context_cfg.get("engine") or "compressor").strip().lower() if isinstance(context_cfg, dict) else "compressor" return bool(name) and name != "compressor" @@ -658,7 +604,7 @@ def _get_platform_tools(config: dict, platform: str, *, include_default_mcp_serv # agent.disabled_toolsets is a global suppression list and runs LAST so it overrides everything above. It # may arrive as a JSON-array string ("['memory']") from `hermes config set` or a JSON-mode editor save. - disabled_toolsets = (config.get("agent") or {}).get("disabled_toolsets") or [] + disabled_toolsets = (config.get("agent") or {}).get("disabled_toolsets") 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()} @@ -669,21 +615,14 @@ def _get_platform_tools(config: dict, platform: str, *, include_default_mcp_serv def _recover_platform_native_toolsets(enabled_toolsets: Set[str], platform: str, *, skip: Set[str]) -> None: - """Add non-configurable platform toolsets (discord, feishu_doc, feishu_drive) in place. They are in the - platform's default composite but absent from CONFIGURABLE_TOOLSETS, so they can't appear in the checklist - or a saved config. Must run for BOTH branches of ``_get_platform_tools`` — otherwise saving via - ``hermes tools`` silently drops them.""" + """Add non-configurable platform toolsets (discord, feishu_*) in place: in the default composite but not in + CONFIGURABLE_TOOLSETS, so never in a checklist or saved list. Runs for BOTH ``_get_platform_tools`` branches.""" 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} + configurable_tool_universe = {t for ts_key, _, _ in CONFIGURABLE_TOOLSETS for t in resolve_toolset(ts_key)} + claimed = {t for ts_key in enabled_toolsets for t in resolve_toolset(ts_key)} + skip = skip | {k for k in TOOLSETS if k.startswith("hermes-")} | (set(_DEFAULT_OFF_TOOLSETS) - {platform}) for ts_key, ts_def in TOOLSETS.items(): # Posture toolsets (``coding``) are session-level selections made by agent/coding_context.py, not # per-platform capabilities to recover. @@ -692,11 +631,9 @@ def _recover_platform_native_toolsets(enabled_toolsets: Set[str], platform: str, # Static membership: a registry-added tool absent from the platform composite must not block recovery # of a non-configurable toolset whose authored tools the composite lists. ts_tools = set(resolve_toolset(ts_key, include_registry=False)) - if not ts_tools or not ts_tools.issubset(platform_tool_universe): + if not ts_tools or not ts_tools <= platform_tool_universe or ts_tools <= configurable_tool_universe: continue - if ts_tools.issubset(configurable_tool_universe): - continue - if not ts_tools.issubset(claimed): + if not ts_tools <= claimed: enabled_toolsets.add(ts_key) claimed.update(ts_tools) @@ -704,9 +641,8 @@ def _recover_platform_native_toolsets(enabled_toolsets: Set[str], platform: str, def _merge_mcp_servers( config: dict, toolset_names: List[str], explicit_passthrough: Set[str], include_default_mcp_servers: bool ) -> Set[str]: - """Explicit passthrough entries plus the MCP servers enabled for this platform. MCP servers are on for all - platforms by default; explicitly listed server names form an allowlist, otherwise every globally enabled - server is included (when ``include_default_mcp_servers``). The ``no_mcp`` sentinel disables all.""" + """Explicit passthrough entries plus this platform's MCP servers: listed names form an allowlist, else every + globally enabled server (when ``include_default_mcp_servers``); the ``no_mcp`` sentinel disables all.""" enabled_mcp_servers = enabled_mcp_server_names(config) no_mcp = "no_mcp" in toolset_names if no_mcp: @@ -721,9 +657,8 @@ def _merge_mcp_servers( def _warn_all_invalid_platform_toolsets(platform: str, explicit: list) -> None: - """Warn once when an explicitly configured platform has only invalid toolset names (e.g. ``hermes`` left - instead of ``hermes-cli`` makes resolve_toolset() return [] for every entry and the platform silently has - no native tools). Surfaced where tools are resolved for a session, not only in update/doctor.""" + """Warn once when an explicit platform list has only invalid names (``hermes`` for ``hermes-cli`` → no + native tools), at session tool resolution rather than only in update/doctor.""" from toolsets import validate_toolset named = [str(t) for t in explicit if isinstance(t, str) and t] @@ -733,8 +668,7 @@ def _warn_all_invalid_platform_toolsets(platform: str, explicit: list) -> None: "platform '%s' has no valid toolsets configured (unknown " "name(s): %s) - tools will be unavailable. Run `hermes tools` " "to reconfigure. See issue #38798.", - platform, ", ".join(named), - ) + platform, ", ".join(named)) def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[str]): @@ -744,21 +678,14 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[ # hand-edited config.yaml) can't turn on `discord` for Telegram. enabled_toolset_keys = {ts for ts in enabled_toolset_keys if _toolset_allowed_for_platform(ts, platform)} plugin_keys = _get_plugin_toolset_keys() - configurable_keys = _configurable_keys() | plugin_keys - # Platform defaults (hermes-cli, ...) resolve to ALL tools; preserving one would silently override the - # user's unchecked selections on the next read. - platform_default_keys = _platform_default_keys() + # Preserve only existing entries that are neither configurable nor platform defaults (i.e. MCP server + # names): platform defaults (hermes-cli, ...) resolve to ALL tools and would silently override the user's + # unchecked selections on the next read. Saving from the picker is consent to clear the "no_mcp" sentinel + # (no checkbox for it; users who once set it by hand could otherwise never re-enable MCP via the UI). + drop = _configurable_keys() | plugin_keys | _platform_default_keys() | {"no_mcp"} existing_toolsets = cfg_get(config, "platform_toolsets", platform, default=[]) - if not isinstance(existing_toolsets, list): - existing_toolsets = [] - # Preserve only entries that are neither configurable nor platform defaults (MCP server names). - preserved_entries = { - str(entry) for entry in existing_toolsets - if str(entry) not in configurable_keys and str(entry) not in platform_default_keys - } - # Saving from the picker is consent to clear the "no_mcp" sentinel: the picker has no checkbox for it, - # so users who once set it by hand could otherwise never re-enable MCP via the UI. - preserved_entries.discard("no_mcp") + preserved_entries = {str(e) for e in (existing_toolsets if isinstance(existing_toolsets, list) else []) + if str(e) not in drop} config["platform_toolsets"][platform] = sorted(enabled_toolset_keys | preserved_entries) # Record which plugin toolsets this platform "knows" (distinguishes "new plugin, default enabled" from # "user disabled it"). _cfg_section normalizes a present-but-null key that setdefault alone would not replace. @@ -796,8 +723,7 @@ def _toolset_has_keys( 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 + return resolve_vision_provider_client()[1] is not None except Exception: return False if ts_key in {"web", "image_gen", "video_gen", "tts", "stt", "browser"}: @@ -809,10 +735,7 @@ def _toolset_has_keys( # Provider-aware categories first: a no-key provider (Local Browser, Edge TTS) counts as configured. cat = TOOL_CATEGORIES.get(ts_key) if cat: - return any( - _provider_env_ready(provider) - for provider in _visible_providers(cat, config, force_fresh=force_fresh, features=features) - ) + return any(_provider_env_ready(p) for p in _visible_providers(cat, config, force_fresh=force_fresh, features=features)) return all(get_env_value(var) for var, _ in TOOLSET_ENV_REQUIREMENTS.get(ts_key, [])) @@ -828,8 +751,8 @@ _tool_token_cache: Optional[Dict[tuple[str, int], Dict[str, int]]] = None def _estimate_tool_tokens() -> Dict[str, int]: - """Estimated tiktoken (cl100k_base) tokens per tool name from the JSON-serialised OpenAI tool schema. - Triggers tool discovery on first call and caches per process; {} if tiktoken/registry unavailable.""" + """tiktoken (cl100k_base) tokens per tool name from the serialised OpenAI schema; cached per process and + registry generation, {} if tiktoken/registry unavailable.""" global _tool_token_cache from hermes_constants import hermes_home_key @@ -850,11 +773,10 @@ def _estimate_tool_tokens() -> Dict[str, int]: 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 the wire shape sent to the API - counts[name] = len(enc.encode(_json.dumps({"type": "function", "function": schema}))) + # Mirror the wire shape sent to the API. + counts = { + name: len(enc.encode(_json.dumps({"type": "function", "function": schema}))) + for name in registry.get_all_tool_names() if (schema := registry.get_schema(name))} _tool_token_cache[cache_key] = counts return counts @@ -868,14 +790,11 @@ def _prompt_toolset_checklist(platform_label: str, enabled: Set[str], platform: # Drop platform-scoped toolsets that don't apply here and config-only capabilities (stt). effective = [ (k, l, d) for (k, l, d) in _get_effective_configurable_toolsets() - if _toolset_allowed_for_platform(k, platform) and k not in _CONFIG_ONLY_TOOLSETS - ] - labels = [] - for ts_key, ts_label, ts_desc in effective: - suffix = "" - if not _toolset_has_keys(ts_key, force_fresh=force_fresh) and _is_configurable(ts_key): - suffix = " [no API key]" - labels.append(f"{ts_label} ({ts_desc}){suffix}") + if _toolset_allowed_for_platform(k, platform) and k not in _CONFIG_ONLY_TOOLSETS] + labels = [ + f"{ts_label} ({ts_desc})" + + (" [no API key]" if not _toolset_has_keys(ts_key, force_fresh=force_fresh) and _is_configurable(ts_key) else "") + for ts_key, ts_label, ts_desc in effective] pre_selected = {i for i, (ts_key, _, _) in enumerate(effective) if ts_key in enabled} status_fn = None @@ -888,9 +807,7 @@ def _prompt_toolset_checklist(platform_label: str, enabled: Set[str], platform: 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" + return f"Est. tool context: ~{total / 1000:.1f}k tokens" if total >= 1000 else 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, @@ -915,9 +832,7 @@ def _reconfigure_tool(config: dict, *, force_fresh: bool = True): 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) - ) - ] + or _toolset_enabled_for_reconfigure(ts_key, config))] if not configurable: _print_info("No configured tools to reconfigure.") return @@ -930,17 +845,15 @@ def _reconfigure_tool(config: dict, *, force_fresh: bool = True): def _toolset_enabled_for_reconfigure(ts_key: str, config: dict) -> bool: - """True if a configurable toolset is enabled anywhere. Reconfigure must include enabled-but-unconfigured - categories so users can finish provider/API-key setup without disabling and re-enabling the toolset.""" + """True if the toolset is enabled on any platform, so reconfigure covers enabled-but-unconfigured ones.""" 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) + if ts_key in _current_platform_tools(config, platform): + return True except Exception: continue - if ts_key in enabled: - return True return False @@ -948,8 +861,7 @@ def _toolset_enabled_for_reconfigure(ts_key: str, config: dict) -> bool: 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 = telemetry.get("shared_metrics") if isinstance(telemetry, dict) else None shared = shared if isinstance(shared, dict) else {} return shared.get("enabled") is True, shared.get("send") is True @@ -957,18 +869,12 @@ def _shared_metrics_state(config: dict) -> tuple[bool, bool]: 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" + state = "off" if not enabled else ("collecting + sending to Nous" if send else "collecting locally") return f"Configure shared metrics ({state})" def _configure_shared_metrics_interactive(config: dict) -> None: - """Toggle shared-metrics collection/sending from `hermes tools`. Delegates to the setup wizard's prompt so - the consent rules live in one place: sending requires collection; disabling collection disables sending.""" + """Toggle shared-metrics collection/sending via the setup wizard prompt (single home for the consent rules).""" from hermes_cli.setup import setup_telemetry before = _shared_metrics_state(config) @@ -987,8 +893,7 @@ def _print_toolset_diff(added: Set[str], removed: Set[str], *, indent: str = " def _toolsets_needing_setup(new_enabled: Set[str], config: dict) -> List[str]: - """Selected toolsets still missing provider/API-key setup, sorted. These must open configuration even when - the checklist selection didn't change (e.g. Web Search enabled but ``web.backend`` missing).""" + """Selected toolsets still missing provider/API-key setup, sorted (opened even when the selection is unchanged).""" 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) @@ -1003,7 +908,7 @@ def _configure_newly_added(added: Set[str], already: Set[str], config: dict) -> def _platform_menu_label(config: dict, pkey: str) -> str: - count = len(_get_platform_tools(config, pkey, include_default_mcp_servers=False)) + count = len(_current_platform_tools(config, pkey)) total = len(_get_effective_configurable_toolsets()) return f"Configure {PLATFORMS[pkey]['label']} ({count}/{total} enabled)" @@ -1013,14 +918,11 @@ def _print_tools_summary(config: dict, enabled_platforms: List[str]) -> None: total = len(_get_effective_configurable_toolsets()) print(color("⚕ Tool Summary", Colors.CYAN, Colors.BOLD)) print() - summary = _platform_toolset_summary(config, enabled_platforms) - for pkey in enabled_platforms: - enabled = summary.get(pkey, set()) + for pkey, enabled in _platform_toolset_summary(config, enabled_platforms).items(): print(color(f" {PLATFORMS[pkey]['label']}", Colors.BOLD) + color(f" ({len(enabled)}/{total})", Colors.DIM)) - if enabled: - for ts_key in sorted(enabled): - print(color(f" ✓ {_toolset_label(ts_key)}", Colors.GREEN)) - else: + for ts_key in sorted(enabled): + print(color(f" ✓ {_toolset_label(ts_key)}", Colors.GREEN)) + if not enabled: print(color(" (none enabled)", Colors.DIM)) print() @@ -1041,9 +943,8 @@ def _configure_list(to_configure: List[str], config: dict, *, selected: bool = T def _checklist_diff(new_enabled: Set[str], prev: Set[str], platform: str) -> tuple[Set[str], Set[str]]: - """``(added, removed)`` scoped to the checklist's universe: the resolved ``prev`` can include - non-configurable toolsets (``kanban``, recovered composites) the user never saw a checkbox for, which would - otherwise print spurious ``- kanban`` removals even though the config keeps them.""" + """``(added, removed)`` scoped to the checklist universe, so read-time toolsets (``kanban``) the user never + saw a checkbox for don't print as spurious removals.""" universe = _checklist_toolset_keys(platform) return (new_enabled - prev) & universe, (prev - new_enabled) & universe @@ -1052,7 +953,7 @@ def _first_install_flow(config: dict, enabled_platforms: List[str]) -> None: """Fresh install: one checklist per platform, no menu, keys prompted for every enabled tool.""" for pkey in enabled_platforms: pinfo = PLATFORMS[pkey] - current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False) + current_enabled = _current_platform_tools(config, pkey) new_enabled = _prompt_toolset_checklist(pinfo["label"], current_enabled - _DEFAULT_OFF_TOOLSETS, pkey) _print_toolset_diff(*_checklist_diff(new_enabled, current_enabled, pkey)) auto_configured = apply_nous_managed_defaults(config, enabled_toolsets=new_enabled, force_fresh=True) @@ -1063,19 +964,35 @@ def _first_install_flow(config: dict, enabled_platforms: List[str]) -> None: # Browserbase), TTS (Edge vs OpenAI vs ElevenLabs), etc. are shown even when a free provider exists. _configure_list( [ts for ts in sorted(new_enabled) if _is_configurable(ts) and ts not in auto_configured], - config, selected=False, - ) + config, selected=False) _save_platform_tools(config, pkey, new_enabled) save_config(config) print(color(f" ✓ Saved {pinfo['label']} tool configuration", Colors.GREEN)) print() +def _current_platform_tools(config: dict, pkey: str) -> Set[str]: + return _get_platform_tools(config, pkey, include_default_mcp_servers=False) + + +def _apply_platform_checklist(config: dict, pkey: str, new_enabled: Set[str], prev: Set[str], already: Set[str], + *, indent: str = " ", header: bool = False) -> None: + """Print the diff, configure newly added toolsets not in ``already``, and write the platform list. + Keys for newly enabled tools not already handled by the selected-tool pass, so a tool enabled globally + but lacking provider config doesn't drop the user back to the main menu.""" + added, removed = _checklist_diff(new_enabled, prev, pkey) + if header and (added or removed): + print(color(f" {PLATFORMS[pkey]['label']}:", Colors.DIM)) + _print_toolset_diff(added, removed, indent=indent) + _configure_newly_added(added, already, config) + _save_platform_tools(config, pkey, new_enabled) + + def _configure_all_platforms(config: dict, platform_keys: List[str]) -> bool: """'Configure all platforms (global)' menu entry. Returns True when config was saved.""" all_current: Set[str] = set() for pk in platform_keys: - all_current |= _get_platform_tools(config, pk, include_default_mcp_servers=False) + all_current |= _current_platform_tools(config, pk) new_enabled = _prompt_toolset_checklist("All platforms", all_current, force_fresh=True) selected_to_configure = _toolsets_needing_setup(new_enabled, config) _configure_list(selected_to_configure, config) @@ -1083,15 +1000,8 @@ def _configure_all_platforms(config: dict, platform_keys: List[str]) -> bool: print(color(" No changes", Colors.DIM)) return False for pk in platform_keys: - prev = _get_platform_tools(config, pk, include_default_mcp_servers=False) - added, removed = _checklist_diff(new_enabled, prev, pk) - if added or removed: - print(color(f" {PLATFORMS[pk]['label']}:", Colors.DIM)) - _print_toolset_diff(added, removed, indent=" ") - # Keys for newly enabled tools not already handled by the global selected-tool pass, so a tool enabled - # globally but lacking provider config doesn't drop the user back to the main menu. - _configure_newly_added(added, set(selected_to_configure), config) - _save_platform_tools(config, pk, new_enabled) + _apply_platform_checklist(config, pk, new_enabled, _current_platform_tools(config, pk), + set(selected_to_configure), indent=" ", header=True) save_config(config) print(color(" ✓ Saved configuration for all platforms", Colors.GREEN)) return True @@ -1100,25 +1010,21 @@ def _configure_all_platforms(config: dict, platform_keys: List[str]) -> bool: def _configure_one_platform(config: dict, pkey: str) -> None: """Per-platform checklist + key setup + save.""" pinfo = PLATFORMS[pkey] - current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False) + current_enabled = _current_platform_tools(config, pkey) new_enabled = _prompt_toolset_checklist(pinfo["label"], current_enabled, force_fresh=True) selected_to_configure = _toolsets_needing_setup(new_enabled, config) _configure_list(selected_to_configure, config) - if new_enabled != current_enabled or selected_to_configure: - added, removed = _checklist_diff(new_enabled, current_enabled, pkey) - _print_toolset_diff(added, removed) - _configure_newly_added(added, set(selected_to_configure), config) - _save_platform_tools(config, pkey, new_enabled) - save_config(config) - print(color(f" ✓ Saved {pinfo['label']} configuration", Colors.GREEN)) - else: + if new_enabled == current_enabled and not selected_to_configure: print(color(f" No changes to {pinfo['label']}", Colors.DIM)) + return + _apply_platform_checklist(config, pkey, new_enabled, current_enabled, set(selected_to_configure)) + save_config(config) + print(color(f" ✓ Saved {pinfo['label']} configuration", Colors.GREEN)) def tools_command(args=None, first_install: bool = False, config: dict = None): - """Entry point for `hermes tools` and `hermes setup tools`. ``first_install`` skips the platform menu, goes - straight to the CLI checklist and prompts for API keys on enabled tools. When the wizard passes its own - ``config`` dict, platform_toolsets are written into it so they survive the wizard's final save_config().""" + """Entry point for `hermes tools` / `hermes setup tools`. ``first_install`` skips the menu (checklist + key + prompts); a wizard-passed ``config`` receives platform_toolsets so its final save_config() keeps them.""" if config is None: config = load_config() enabled_platforms = _get_enabled_platforms() @@ -1140,21 +1046,20 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): platform_keys = list(enabled_platforms) platform_choices = [_platform_menu_label(config, pkey) for pkey in platform_keys] has_global = len(platform_keys) > 1 + has_mcp = bool(config.get("mcp_servers")) + global_idx = len(platform_keys) if has_global else -1 if has_global: platform_choices.append("Configure all platforms (global)") + reconfig_idx = len(platform_choices) platform_choices.append("Reconfigure an existing tool's provider or API key") + metrics_idx = len(platform_choices) platform_choices.append(_shared_metrics_menu_label(config)) - has_mcp = bool(config.get("mcp_servers")) + mcp_idx = len(platform_choices) if has_mcp else -1 if has_mcp: platform_choices.append("Configure MCP server tools") + done_idx = len(platform_choices) platform_choices.append("Done") - global_idx = len(platform_keys) if has_global else -1 - reconfig_idx = len(platform_keys) + (1 if has_global else 0) - metrics_idx = reconfig_idx + 1 - mcp_idx = (metrics_idx + 1) if has_mcp else -1 - done_idx = metrics_idx + (2 if has_mcp else 1) - while True: idx = _prompt_choice("Select an option:", platform_choices, default=0) if idx == done_idx: diff --git a/hermes_cli/tools_config_cua.py b/hermes_cli/tools_config_cua.py index 94ea7b0277..1db5ac6dae 100644 --- a/hermes_cli/tools_config_cua.py +++ b/hermes_cli/tools_config_cua.py @@ -11,19 +11,47 @@ from pathlib import Path from typing import List, Optional from hermes_cli.cli_output import ( - print_info as _print_info, print_success as _print_success, print_warning as _print_warning, -) + print_info as _print_info, print_success as _print_success, print_warning as _print_warning) logger = logging.getLogger("hermes_cli.tools_config") +# Ceiling for one upstream-installer run: must exceed the installer's own stale-lock recovery window +# (_install-rust.sh force-releases a dead holder's lock only after LOCK_STALE_AFTER_SECONDS=600; a shorter +# timeout kills every run before that fires — a permanent wedge). 660s = 600s + 60s headroom. +_CUA_INSTALLER_TIMEOUT = 660 + +# Grace for draining the installer's pipes after a timeout kill. The kill is best-effort (_reap_after_timeout) +# so the drain must be bounded: a surviving descendant holding the inherited stdout handle would otherwise +# make the read wait on an EOF that never comes. A successful kill closes the pipe at once, so this is free. +_CUA_INSTALLER_DRAIN_GRACE = 15 + +# Quiet unattended refreshes from ``hermes update``: bounded even when upstream waits on Read-Host or a +# consent prompt; explicit ``computer-use install --upgrade`` keeps the full ceiling. Safe because the +# lock/network preflights make a legitimate long wait impossible here. +_CUA_BACKGROUND_UPDATE_TIMEOUT = 120 + +# Upstream installer's stale-lock threshold (LOCK_STALE_AFTER_SECONDS in _install-rust.sh), so the +# pre-clear never yanks a lock a live-but-slow install still holds. +_CUA_LOCK_STALE_AFTER = 600 + +_CUA_INSTALL_PS1_URL = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.ps1" +_CUA_INSTALL_SH_URL = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh" +_CUA_MANUAL_README = "https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md" +_UPGRADE_CMD = "hermes computer-use install --upgrade" + + +def _run_text(cmd: list, *, timeout, capture_output: bool = True, **kwargs) -> subprocess.CompletedProcess: + """``subprocess.run`` with the utf-8/replace text decoding every helper here uses.""" + return subprocess.run( + cmd, capture_output=capture_output, text=True, encoding="utf-8", errors="replace", timeout=timeout, **kwargs, + ) + def _post_setup_no_window_flags(*, streams_to_console: bool = False) -> int: """Win32 creationflags that stop post-setup children flashing a console. - CREATE_NO_WINDOW hides console grandchildren (npm, pip, powershell) while keeping stdio inheritable (unlike DETACHED_PROCESS). Returns 0 on POSIX. ``streams_to_console`` children are only hidden when - our own stdout is not a console, so live installer output is never swallowed. - """ + 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() @@ -45,11 +73,7 @@ def _cua_driver_cmd() -> str: def _cua_version_summary(raw: str, *, limit: int = 120) -> str: """First non-empty line of ``--version`` output, bounded (an override may print a multi-line banner).""" - for line in (raw or "").splitlines(): - text = line.strip() - if text: - return text[:limit] - return "" + return next((line.strip()[:limit] for line in (raw or "").splitlines() if line.strip()), "") def _resolved_cua_driver_cmd() -> Optional[str]: @@ -76,7 +100,8 @@ _CUA_DRIVER_CONTRACT_CACHE: dict = {} def _cua_driver_contract_status(binary: Optional[str] = None) -> dict: - """Inspect whether an installed driver supports Hermes' runtime contract.""" + """Inspect whether an installed driver supports Hermes' runtime contract (30s cache keyed on the binary's + path/mtime/size fingerprint).""" import time from tools.computer_use.cua_backend import cua_driver_runtime_contract_status @@ -91,14 +116,12 @@ def _cua_driver_contract_status(binary: Optional[str] = None) -> dict: 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"]) + cache = _CUA_DRIVER_CONTRACT_CACHE + if cache.get("fingerprint") == fingerprint and now - cache.get("checked_at", 0.0) < 30.0: + return dict(cache["state"]) state = cua_driver_runtime_contract_status(resolved) - _CUA_DRIVER_CONTRACT_CACHE.update(fingerprint=fingerprint, checked_at=now, state=dict(state)) + cache.update(fingerprint=fingerprint, checked_at=now, state=dict(state)) return state @@ -111,13 +134,13 @@ def _cua_driver_install_ready() -> bool: def _pip_install(args: List[str], *, timeout: int = 300, capture_output: bool = True): """Install Python packages from a post-setup hook. - Order: ``uv pip install`` (fast, needs no pip in the venv), then ``python -m pip install``, then ``python -m ensurepip --upgrade`` and retry pip. The last tier exists because the Windows installer - creates the venv via ``uv venv``, which does NOT seed pip, so bare ``-m pip`` failed on fresh installs. - """ + creates the venv via ``uv venv``, which does NOT seed pip, so bare ``-m pip`` failed on fresh + installs.""" venv_root = Path(sys.executable).parent.parent uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)} + install_flags = _post_setup_no_window_flags(streams_to_console=not capture_output) # Managed uv first: $HERMES_HOME/bin is never on PATH, so a bare which() misses the uv Hermes installed; # ensure_uv() (not a pure lookup) because installing uv is in scope during setup. @@ -126,12 +149,8 @@ def _pip_install(args: List[str], *, timeout: int = 300, capture_output: bool = 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), - ) + result = _run_text([uv_bin, "pip", "install", *args], capture_output=capture_output, timeout=timeout, + env=uv_env, creationflags=install_flags) if result.returncode == 0: return result # Fall through to pip — uv may have failed for a reason pip can handle. @@ -141,31 +160,21 @@ def _pip_install(args: List[str], *, timeout: int = 300, capture_output: bool = 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(), - ) + probe = _run_text(pip_cmd + ["--version"], 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(), - ) + _run_text([sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], 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), - ) + return _run_text(pip_cmd + ["install", *args], capture_output=capture_output, timeout=timeout, + creationflags=install_flags) # No pre-install release/asset probe: cua-driver-rs releases are all prereleases, which GitHub's @@ -187,77 +196,62 @@ def _cua_install_target_writable() -> bool: def _cua_driver_version(binary: str) -> Optional[str]: """`` --version`` stdout (possibly ""), or None when the probe itself fails.""" try: - return subprocess.run( - [binary, "--version"], - capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, env=_cua_driver_env(), - creationflags=_post_setup_no_window_flags(), - ).stdout.strip() + return _run_text([binary, "--version"], timeout=5, env=_cua_driver_env(), + creationflags=_post_setup_no_window_flags()).stdout.strip() except Exception: return None def _confirmed_update_check(driver_cmd: str, require_confirmed_update: bool) -> tuple: """Ask the installed driver whether a newer release exists; returns ``(proceed, pin_version)``. - ``proceed=False`` means stop with success (already latest, or indeterminate under ``require_confirmed_update``). An old driver (no check-update verb) or offline check yields None: `hermes update` then keeps the installed version — an indeterminate check must never cost a - multi-minute silent reinstall on every update — while explicit `install --upgrade` falls through. - """ + multi-minute silent reinstall on every update — while explicit `install --upgrade` falls through.""" try: from tools.computer_use.cua_backend import cua_driver_update_check _state = cua_driver_update_check() except Exception: _state = None - if _state is not None and not _state.get("update_available"): + if _state is None: + if 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(f" Force a refresh with: {_UPGRADE_CMD}") + return False, None + return True, None + if not _state.get("update_available"): _print_success( f" {driver_cmd} is already on the latest release " - f"({_state.get('current_version') or 'unknown'})." - ) + f"({_state.get('current_version') or 'unknown'}).") return False, None - if _state is None and require_confirmed_update: - _print_info( - f" Could not confirm a newer {driver_cmd} release " - "(offline, rate-limited, or driver too old to check); " - "keeping the installed version." - ) - _print_info(" Force a refresh with: hermes computer-use install --upgrade") - return False, None - confirmed_version = None - if _state is not None and _state.get("update_available"): - # Windows routine upgrades run unattended-safe (stdin closed, version pinned, ceiling - # _CUA_BACKGROUND_UPDATE_TIMEOUT, preflights skip in seconds); only contract repairs and fresh - # installs stay interactive-only, where upstream needs a human (autostart elevation, SmartScreen). - # Pin to the release check-update confirmed: `latest_version` comes from the GitHub Releases API so - # its assets exist, unlike the installer's baked version on `main`, which is bumped before assets - # are published and 404s unpinned. Malformed values are ignored → unpinned fallback. - import re as _re + # Windows routine upgrades run unattended-safe (stdin closed, version pinned, ceiling + # _CUA_BACKGROUND_UPDATE_TIMEOUT, preflights skip in seconds); only contract repairs and fresh + # installs stay interactive-only, where upstream needs a human (autostart elevation, SmartScreen). + # Pin to the release check-update confirmed: `latest_version` comes from the GitHub Releases API so + # its assets exist, unlike the installer's baked version on `main`, which is bumped before assets + # are published and 404s unpinned. Malformed values are ignored → unpinned fallback. + import re as _re - _latest = str(_state.get("latest_version") or "").strip().lstrip("vV") - if _re.fullmatch(r"\d+(\.\d+)*", _latest): - confirmed_version = _latest - return True, confirmed_version + _latest = str(_state.get("latest_version") or "").strip().lstrip("vV") + return True, (_latest if _re.fullmatch(r"\d+(\.\d+)*", _latest) else None) def install_cua_driver( - upgrade: bool = False, - require_confirmed_update: bool = False, - show_installer_progress: bool = True, + upgrade: bool = False, require_confirmed_update: bool = False, show_installer_progress: bool = True, ) -> bool: """Install or refresh the cua-driver binary used by Computer Use. - The upstream installer always pulls the latest release tag, so re-running it is the canonical way to upgrade. ``upgrade=False`` (toolset enable flow) keeps a compatible installation, repairs an - old/incomplete one and installs when missing; ``upgrade=True`` always refreshes. - """ + old/incomplete one and installs when missing; ``upgrade=True`` always refreshes.""" import platform as _plat system = _plat.system() if system not in ("Darwin", "Windows", "Linux"): - if upgrade: - # Silent: `hermes update` calls this for every user. - return False - _print_warning(" Computer Use (cua-driver) is unsupported on this platform; skipping.") + if not upgrade: # silent under `hermes update`, which calls this for every user + _print_warning(" Computer Use (cua-driver) is unsupported on this platform; skipping.") return False is_windows = system == "Windows" @@ -284,7 +278,7 @@ def install_cua_driver( 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") + _print_info(f" {_CUA_MANUAL_README}") return False return _run_cua_driver_installer(label="Installing") @@ -298,10 +292,8 @@ def install_cua_driver( # Compatible existing install: no download, just the host-specific setup upstream normally owns. if binary and not upgrade and not repair_existing: version = _cua_driver_version(binary) - if version is None: - _print_success(f" {driver_cmd} already installed.") - else: - _print_success(f" {driver_cmd} already installed: {version or 'unknown version'}") + suffix = "" if version is None else f": {version or 'unknown version'}" + _print_success(f" {driver_cmd} already installed{suffix}.") 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 @@ -311,29 +303,20 @@ def install_cua_driver( if repair_existing: version = contract.get("version") or "unknown version" reason = contract.get("reason") or "required runtime features are missing" - _print_warning( - f" Found cua-driver {version}, but Hermes cannot use its current runtime contract: {reason}." - ) - if os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip(): - _print_info( - " Update the binary selected by HERMES_CUA_DRIVER_CMD, or unset " - "the override and run: hermes computer-use install --upgrade" - ) + _print_warning(f" Found cua-driver {version}, but Hermes cannot use its current runtime contract: {reason}.") + if override: + _print_info(f" Update the binary selected by HERMES_CUA_DRIVER_CMD, or unset the override and run: {_UPGRADE_CMD}") 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" - ) + _print_info(" Automatic Windows updates cannot safely run cua-driver's interactive repair installer.") + _print_info(f" Repair it from an interactive terminal with: {_UPGRADE_CMD}") return False _print_info(" Repairing it with the current upstream installer.") # upgrade=True path — refresh to the latest upstream release. if not _cua_install_target_writable(): _print_info(" /Applications is not writable; skipping cua-driver refresh.") - _print_info(" Run `hermes computer-use install --upgrade` from an admin account to update it.") + _print_info(f" Run `{_UPGRADE_CMD}` from an admin account to update it.") return bool(binary) if not shutil.which(fetch_tool): @@ -350,30 +333,23 @@ def install_cua_driver( # Missing binary (enabled but never installed, or wiped by a failed install): an automatic Windows # update must never launch install.ps1, which can demand console/UAC consent the hidden updater # cannot provide. - _print_info( - " cua-driver is not installed; automatic Windows updates " - "cannot safely run its interactive installer." - ) - _print_info(" Install it from an interactive terminal with: hermes computer-use install --upgrade") + _print_info(" cua-driver is not installed; automatic Windows updates cannot safely run its interactive installer.") + _print_info(f" Install it from an interactive terminal with: {_UPGRADE_CMD}") return False # Best-effort before/after version display. before = (_cua_driver_version(binary) or "") if binary else "" ok = _run_cua_driver_installer( - label="Repairing" if repair_existing else "Refreshing", - verbose=False, - pin_version=confirmed_version, + 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, - ) + 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'}." - ) + f"unusable: {repaired.get('reason') or 'unknown error'}.") _print_info(" Run: hermes computer-use doctor") return False if ok and before: @@ -385,26 +361,6 @@ def install_cua_driver( return ok -# Ceiling for one upstream-installer run: must exceed the installer's own stale-lock recovery window -# (_install-rust.sh force-releases a dead holder's lock only after LOCK_STALE_AFTER_SECONDS=600; a shorter -# timeout kills every run before that fires — a permanent wedge). 660s = 600s + 60s headroom. -_CUA_INSTALLER_TIMEOUT = 660 - -# Grace for draining the installer's pipes after a timeout kill. The kill is best-effort (_reap_after_timeout) -# so the drain must be bounded: a surviving descendant holding the inherited stdout handle would otherwise -# make the read wait on an EOF that never comes. A successful kill closes the pipe at once, so this is free. -_CUA_INSTALLER_DRAIN_GRACE = 15 - -# Quiet unattended refreshes from ``hermes update``: bounded even when upstream waits on Read-Host or a -# consent prompt; explicit ``computer-use install --upgrade`` keeps the full ceiling. Safe because the -# lock/network preflights make a legitimate long wait impossible here. -_CUA_BACKGROUND_UPDATE_TIMEOUT = 120 - -# Upstream installer's stale-lock threshold (LOCK_STALE_AFTER_SECONDS in _install-rust.sh), so the -# pre-clear never yanks a lock a live-but-slow install still holds. -_CUA_LOCK_STALE_AFTER = 600 - - def _cua_install_home() -> "Path": """Package home shared by the upstream POSIX and Windows installers.""" return Path(os.environ.get("CUA_DRIVER_RS_HOME") or str(Path.home() / ".cua-driver")) @@ -422,11 +378,9 @@ def _cua_windows_install_lock_file() -> "Path": def _clear_stale_windows_cua_install_lock() -> None: """Delete install.ps1's lock file only when no process still holds it. - install.ps1 locks with ``FileShare::None``; mirror it with a zero-share ``CreateFileW`` probe and ``FILE_FLAG_DELETE_ON_CLOSE`` so an unlocked leftover is removed atomically, with no window in which - a new installer could acquire the file between probe and delete. - """ + 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(): @@ -436,46 +390,31 @@ def _clear_stale_windows_cua_install_lock() -> None: 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 + delete_access, generic_read, generic_write = 0x00010000, 0x80000000, 0x40000000 + open_existing, file_attribute_normal, file_flag_delete_on_close = 3, 0x00000080, 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, - ] + _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: + str(lock_file), generic_read | generic_write | delete_access, 0, # 0 = FileShare::None + None, open_existing, file_attribute_normal | file_flag_delete_on_close, None) + if handle == _wintypes.HANDLE(-1).value: logger.debug( "Windows cua install lock at %s is still held or cannot be removed (winerror %s)", - lock_file, _ctypes.get_last_error(), - ) + 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(), - ) + lock_file, _ctypes.get_last_error()) return if lock_file.exists(): logger.debug("Windows cua install lock probe succeeded but %s remains", lock_file) @@ -489,11 +428,9 @@ def _clear_stale_windows_cua_install_lock() -> None: def _clear_stale_cua_install_lock() -> None: """Best-effort: remove a stale installer lock left by a dead holder. - POSIX stamps the holder pid into ``~/.cua-driver/packages/.install.lock.d/info``; Windows holds ``~/.cua-driver/install.lock`` open with ``FileShare::None``. Clear either artifact up front only - when its platform-specific liveness check proves that no install still holds it. - """ + when its platform-specific liveness check proves that no install still holds it.""" if sys.platform == "win32": _clear_stale_windows_cua_install_lock() return @@ -502,9 +439,8 @@ def _clear_stale_cua_install_lock() -> None: 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(): + for line in (lock_dir / "info").read_text(encoding="utf-8", errors="replace").splitlines(): if line.startswith("pid="): holder_pid = int(line.split("=", 1)[1].strip()) break @@ -538,23 +474,21 @@ def _clear_stale_cua_install_lock() -> None: 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. - """ + surviving lock artifact means a concurrent (or orphaned-but-alive) install owns it.""" try: - if sys.platform == "win32": - lock_file = _cua_windows_install_lock_file() - if not lock_file.is_file(): - return False - # install.ps1 holds the file with FileShare::None — any open fails with a sharing violation - # while held. Surviving the stale-clear = held; confirm with an open probe. - try: - with open(lock_file, "r+b"): - return False # opened fine → not held (racy leftover) - except OSError: # sharing violation surfaces as PermissionError - return True - return _cua_install_lock_dir().is_dir() + if sys.platform != "win32": + return _cua_install_lock_dir().is_dir() + lock_file = _cua_windows_install_lock_file() + if not lock_file.is_file(): + return False + # install.ps1 holds the file with FileShare::None — any open fails with a sharing violation + # while held. Surviving the stale-clear = held; confirm with an open probe. + try: + with open(lock_file, "r+b"): + return False # opened fine → not held (racy leftover) + except OSError: # sharing violation surfaces as PermissionError + return True except Exception as e: logger.debug("cua install lock probe failed: %s", e) return False @@ -562,11 +496,9 @@ def _cua_install_lock_held() -> bool: def _cua_release_endpoint_reachable(timeout: float = 5.0) -> bool: """Fast probe: can we reach GitHub's release download host at all? - - When github.com is down the installer dies slowly inside its own retries and eats the whole unattended - ceiling; a 5s HEAD decides in seconds. Only a connection-level failure counts as unreachable — any - HTTP response (even 4xx/5xx) proves the path works. - """ + When github.com is down the installer dies slowly inside its own retries and eats the whole + unattended ceiling; a 5s HEAD decides in seconds. Only a connection-level failure counts as + unreachable — any HTTP response (even 4xx/5xx) proves the path works.""" import urllib.error import urllib.request @@ -593,8 +525,7 @@ def _cua_driver_autostart_registered_windows() -> bool: try: result = subprocess.run( ["schtasks.exe", "/Query", "/TN", "cua-driver-serve"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, - ) + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) except Exception: return False return result.returncode == 0 @@ -602,14 +533,10 @@ def _cua_driver_autostart_registered_windows() -> bool: def _repair_cua_driver_autostart_windows(driver_cmd: str, *, verbose: bool) -> bool: """Best-effort repair for Windows installer autostart quoting failures. - - Older install.ps1 builds interpolated the binary path into a PowerShell command string, which split at - the first space. If the scheduled task is missing, retry via Start-Process's structured ``-FilePath`` / - ``-ArgumentList`` parameters instead. - """ - if sys.platform != "win32": - return True - if _cua_driver_autostart_registered_windows(): + 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" or _cua_driver_autostart_registered_windows(): return True binary = shutil.which(driver_cmd) @@ -622,20 +549,12 @@ def _repair_cua_driver_autostart_windows(driver_cmd: str, *, verbose: bool) -> b "$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...") + "exit $proc.ExitCode") + _print_info(" Registering cua-driver auto-start..." if verbose else " 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(), - ) + result = _run_text([ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd], timeout=300, + env=_cua_driver_env()) except subprocess.TimeoutExpired: _print_warning(" cua-driver autostart registration timed out.") return False @@ -646,9 +565,8 @@ def _repair_cua_driver_autostart_windows(driver_cmd: str, *, verbose: bool) -> b 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: + for line in (result.stderr or result.stdout or "").strip().splitlines()[-3:]: _print_info(f" {line[:200]}") _print_info(" From an elevated shell, run: cua-driver autostart enable") return False @@ -661,6 +579,11 @@ def _remove_quietly(path: str) -> None: pass +_MAC_PERMISSION_PATHS = ( + " System Settings > Privacy & Security > Accessibility", + " System Settings > Privacy & Security > Screen Recording") + + 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: @@ -668,18 +591,13 @@ def _print_cua_platform_notes(is_windows: bool, is_linux: bool, *, fresh_install _print_info(" Windows/SmartScreen may prompt the first time it runs.") elif is_linux: _print_warning(" Linux support is alpha.") - elif fresh_install: - _print_info(" IMPORTANT — grant macOS permissions now:") - _print_info(" System Settings > Privacy & Security > Accessibility") - _print_info(" System Settings > Privacy & Security > Screen Recording") - _print_info(" Both must allow the terminal / Hermes process.") else: - _print_info(" Grant macOS permissions if not done yet:") - _print_info(" System Settings > Privacy & Security > Accessibility") - _print_info(" System Settings > Privacy & Security > Screen Recording") - - -_CUA_INSTALL_PS1_URL = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.ps1" + _print_info(" IMPORTANT — grant macOS permissions now:" if fresh_install + else " Grant macOS permissions if not done yet:") + for line in _MAC_PERMISSION_PATHS: + _print_info(line) + if fresh_install: + _print_info(" Both must allow the terminal / Hermes process.") def _kill_installer_tree(proc, *, is_windows: bool) -> None: @@ -688,63 +606,52 @@ def _kill_installer_tree(proc, *, is_windows: bool) -> None: try: if not is_windows: os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) # windows-footgun: ok — POSIX branch only - else: - # PowerShell may leave download/install helpers alive after its direct process is killed. They - # inherit stdout and can keep communicate() and install.lock wedged, so kill the tree leaf-up. - import psutil as _psutil + return + # PowerShell may leave download/install helpers alive after its direct process is killed. They + # inherit stdout and can keep communicate() and install.lock wedged, so kill the tree leaf-up. + import psutil as _psutil - try: - parent = _psutil.Process(proc.pid) - descendants = parent.children(recursive=True) - except _psutil.NoSuchProcess: - return - except _psutil.Error as e: - logger.debug( - "could not enumerate cua-driver installer tree for pid %s: %s", proc.pid, e - ) - proc.kill() - return + 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) + for target, pid in [(child, child.pid) for child in reversed(descendants)] + [(parent, proc.pid)]: try: - parent.kill() + target.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() + what = "parent" if target is parent else "child" + logger.debug("could not kill cua-driver installer %s pid %s: %s", what, pid, e) + if target is parent: + proc.kill() except (OSError, ProcessLookupError): proc.kill() def _reap_after_timeout(proc, *, is_windows: bool) -> None: """Kill the installer tree, then drain its pipes under a deadline. - An unbounded drain blocks on an EOF that only arrives when someone kills a surviving descendant by - hand, so ``_CUA_INSTALLER_TIMEOUT`` would stop bounding anything. - """ + hand, so ``_CUA_INSTALLER_TIMEOUT`` would stop bounding anything.""" _kill_installer_tree(proc, is_windows=is_windows) try: drained_out, _ = proc.communicate(timeout=_CUA_INSTALLER_DRAIN_GRACE) # The partial output names WHERE the installer was stuck (lock wait, consent prompt, download). if drained_out: - logger.warning( - "cua-driver installer timed out; last output before kill:\n%s", drained_out[-2000:], - ) + logger.warning("cua-driver installer timed out; last output before kill:\n%s", drained_out[-2000:]) except subprocess.TimeoutExpired: # Deliberately not closing proc.stdout: communicate()'s reader threads are still blocked on that # handle and closing it underneath them races; they are daemon threads. logger.debug( "cua-driver installer pipes still open %ss after the kill — " "abandoning the drain, a surviving descendant holds the inherited handle", - _CUA_INSTALLER_DRAIN_GRACE, - ) + _CUA_INSTALLER_DRAIN_GRACE) except (OSError, ValueError) as e: logger.debug("cua-driver installer drain failed: %s", e) @@ -755,23 +662,18 @@ def _cua_installer_command(is_windows: bool): # Mirror the one-liner printed by cua_driver_install_hint(). ps_oneliner = f"irm {_CUA_INSTALL_PS1_URL} | iex" install_cmd = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_oneliner] - manual_hint = f'powershell -NoProfile -ExecutionPolicy Bypass -Command "{ps_oneliner}"' - return install_cmd, manual_hint, None + return install_cmd, f'powershell -NoProfile -ExecutionPolicy Bypass -Command "{ps_oneliner}"', None # Download-then-exec instead of `bash -c "$(curl …)"`: no shell=True, no command substitution, and the # script lands in a mkstemp file (unpredictable name, 0600) rather than a fixed /tmp path — avoiding # both shell injection and a symlink/TOCTOU race. The manual hint stays the upstream one-liner. import tempfile as _tempfile - install_url = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh" - manual_hint = f'/bin/bash -c "$(curl -fsSL {install_url})"' + manual_hint = f'/bin/bash -c "$(curl -fsSL {_CUA_INSTALL_SH_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, - ) + dl = _run_text(["curl", "-fsSL", "-o", script_path, _CUA_INSTALL_SH_URL], timeout=120) failure = None if dl.returncode == 0 else (dl.stderr or "").strip()[:200] except (subprocess.TimeoutExpired, OSError) as e: failure = str(e) @@ -784,24 +686,17 @@ def _cua_installer_command(is_windows: bool): def _unattended_installer_preflight(install_cmd: list, is_windows: bool): """Fail FAST on the two conditions that otherwise consume the whole unattended ceiling. - - 1. Install lock held by a live process — upstream would poll it for up to LOCK_STALE_AFTER_SECONDS=600 - before probing the holder (the 11-minute silent hang class). - 2. Release host unreachable — the installer would die slowly inside its own retries; a 5s HEAD answers. - Returns the (possibly rewritten) install command, or None to skip this refresh. Explicit - `computer-use install --upgrade` runs never come here and keep upstream's full lock-recovery. - """ + 1. Install lock held by a live process — upstream would poll it for up to + LOCK_STALE_AFTER_SECONDS=600 before probing the holder (the 11-minute silent hang class). + 2. Release host unreachable — the installer would die slowly inside its own retries; a 5s HEAD + answers. Returns the (possibly rewritten) install command, or None to skip this refresh. Explicit + `computer-use install --upgrade` runs never come here and keep upstream's full lock-recovery.""" if _cua_install_lock_held(): - _print_info( - " Another cua-driver install is in progress (upstream " - "install lock is held) — skipping this refresh." - ) - _print_info(" If no install is really running, retry with: hermes computer-use install --upgrade") + _print_info(" Another cua-driver install is in progress (upstream install lock is held) — skipping this refresh.") + _print_info(f" If no install is really running, retry with: {_UPGRADE_CMD}") return None if not _cua_release_endpoint_reachable(): - _print_info( - " github.com is unreachable — skipping cua-driver refresh (will retry on the next update)." - ) + _print_info(" github.com is unreachable — skipping cua-driver refresh (will retry on the next update).") return None if is_windows: # -NoAutoStart skips Register-CuaDriverAutostart — the ONLY branch of install.ps1 that self-elevates @@ -815,18 +710,12 @@ def _unattended_installer_preflight(install_cmd: list, is_windows: bool): 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: + 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. - """ + ceiling without weakening the explicit install path's stale-lock recovery window.""" import platform as _plat system = _plat.system() @@ -839,10 +728,8 @@ def _run_cua_driver_installer( install_cmd, manual_hint, script_path = prepared if show_progress: - if verbose: - _print_info(f" {label} cua-driver (background computer-use)...") - else: - _print_info(f"→ {label} cua-driver (Computer Use)...") + _print_info(f" {label} cua-driver (background computer-use)..." if verbose + else f"→ {label} cua-driver (Computer Use)...") driver_cmd = _cua_driver_cmd() timeout = _CUA_INSTALLER_TIMEOUT if installer_timeout is None else installer_timeout @@ -864,53 +751,41 @@ def _run_cua_driver_installer( # POSIX: own process group so a timeout kill takes out the whole `curl | bash` pipeline (and the exec'd # _install-rust.sh), not just the outer shell — surviving grandchildren would keep holding the install # lock and wedge every later run. - popen_kwargs = {} + popen_kwargs: dict = {"shell": False, "env": installer_env} if not is_windows: popen_kwargs["start_new_session"] = True + # Non-verbose (`hermes update` refresh): capture the installer's chatty "Next steps" wall and log it + # so a failure stays debuggable. Verbose interactive installs stream live. + if verbose: + popen_kwargs["creationflags"] = _post_setup_no_window_flags(streams_to_console=True) + else: + popen_kwargs.update( + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, encoding="utf-8", errors="replace", creationflags=_post_setup_no_window_flags(), + ) try: - # Non-verbose (`hermes update` refresh): capture the installer's chatty "Next steps" wall and log it - # so a failure stays debuggable. Verbose interactive installs stream live. - if verbose: - proc = subprocess.Popen( - install_cmd, shell=False, env=installer_env, - creationflags=_post_setup_no_window_flags(streams_to_console=True), - **popen_kwargs - ) - try: - proc.communicate(timeout=timeout) - except subprocess.TimeoutExpired: - _reap_after_timeout(proc, is_windows=is_windows) - raise - result = subprocess.CompletedProcess(install_cmd, proc.returncode, stdout=None, stderr=None) - else: - proc = subprocess.Popen( - install_cmd, shell=False, env=installer_env, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, encoding="utf-8", errors="replace", - creationflags=_post_setup_no_window_flags(), - **popen_kwargs - ) - try: - out, _ = proc.communicate(timeout=timeout) - except subprocess.TimeoutExpired: - _reap_after_timeout(proc, is_windows=is_windows) - raise - result = subprocess.CompletedProcess(install_cmd, proc.returncode, stdout=out, stderr=None) + proc = subprocess.Popen(install_cmd, **popen_kwargs) + try: + communicated = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + _reap_after_timeout(proc, is_windows=is_windows) + raise + out = None if verbose else communicated[0] + result = subprocess.CompletedProcess(install_cmd, proc.returncode, stdout=out, stderr=None) + if not verbose and result.stdout: # During `hermes update`, sys.stdout is the mirroring _UpdateOutputStream whose `_log` handle is # ~/.hermes/logs/update.log — write straight to it so the full installer output is kept # (success AND failure) without echoing it to the terminal. - if result.stdout: - _update_log = getattr(sys.stdout, "_log", None) - if _update_log is not None: - try: - _update_log.write("\n--- cua-driver installer output ---\n" + result.stdout + "\n") - _update_log.flush() - except Exception: - pass - if result.returncode != 0: - logger.debug("cua-driver installer output:\n%s", result.stdout) + _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): @@ -925,10 +800,7 @@ def _run_cua_driver_installer( except subprocess.TimeoutExpired: _print_warning(f" cua-driver {label.lower()} timed out after {timeout}s.") if not is_windows: - _print_info( - " If this repeats, a stale installer lock may be present — " - f"check {_cua_install_lock_dir()}" - ) + _print_info(f" If this repeats, a stale installer lock may be present — check {_cua_install_lock_dir()}") _print_info(f" Re-run manually: {manual_hint}") return False except Exception as e: diff --git a/hermes_cli/tools_config_mcp.py b/hermes_cli/tools_config_mcp.py index 69260a5091..dc11920dc5 100644 --- a/hermes_cli/tools_config_mcp.py +++ b/hermes_cli/tools_config_mcp.py @@ -5,25 +5,18 @@ from __future__ import annotations from typing import List, Set from hermes_cli.cli_output import ( - print_error as _print_error, - print_info as _print_info, - print_success as _print_success, - print_warning as _print_warning, -) + print_error as _print_error, print_info as _print_info, print_success as _print_success, + print_warning as _print_warning) from hermes_cli.colors import Colors, color from hermes_cli.toolset_scope import ( - _TOOLSET_PLATFORM_RESTRICTIONS, - toolset_allowed_for_platform as _toolset_allowed_for_platform, -) + _TOOLSET_PLATFORM_RESTRICTIONS, toolset_allowed_for_platform as _toolset_allowed_for_platform) def _mcp_match_filter(): """Runtime name-filter matcher (exact names or fnmatch globs), with a literal fallback. - - Must use the SAME semantics as tools/mcp_tool.py registration — a literal ``in`` check renders - glob excludes (e.g. ``*team_member*`` from catalog default_excluded manifests) as if nothing - were excluded. - """ + 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 @@ -68,8 +61,7 @@ def _apply_mcp_checklist(server_name: str, tools_cfg: dict, tool_names: List[str 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." - ) + "to enable them.") if new_exclude: tools_cfg["exclude"] = new_exclude else: @@ -83,9 +75,8 @@ def _apply_mcp_checklist(server_name: str, tools_cfg: dict, tool_names: List[str def _configure_mcp_tools_interactive(config: dict): """Probe each MCP server for its tools, show a per-server curses checklist, and write the result back as ``tools.exclude`` / ``tools.include`` entries in config.yaml.""" - from hermes_cli.tools_config import save_config - from hermes_cli.curses_ui import curses_checklist + from hermes_cli.tools_config import save_config mcp_servers = config.get("mcp_servers") or {} if not mcp_servers: @@ -135,7 +126,6 @@ def _configure_mcp_tools_interactive(config: dict): 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 @@ -276,18 +266,16 @@ def tools_disable_enable_command(args): 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] + for name in unknown_toolsets: + _print_error(f"Unknown toolset '{name}'") + toolset_targets = [t for t in toolset_targets if t in valid_toolsets] # Reject platform-scoped toolsets on platforms that don't allow them. restricted_targets = [t for t in toolset_targets if not _toolset_allowed_for_platform(t, platform)] - if restricted_targets: - for name in restricted_targets: - allowed = sorted(_TOOLSET_PLATFORM_RESTRICTIONS.get(name) or set()) - _print_error(f"Toolset '{name}' is not available on platform '{platform}' (only: {', '.join(allowed)})") - toolset_targets = [t for t in toolset_targets if t not in restricted_targets] + for name in restricted_targets: + allowed = sorted(_TOOLSET_PLATFORM_RESTRICTIONS.get(name) or set()) + _print_error(f"Toolset '{name}' is not available on platform '{platform}' (only: {', '.join(allowed)})") + toolset_targets = [t for t in toolset_targets if t not in restricted_targets] if toolset_targets: _apply_toolset_change(config, platform, toolset_targets, action) @@ -302,10 +290,8 @@ def tools_disable_enable_command(args): 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 t not in unknown_toolsets and t not in restricted_targets + and (":" not in t or t.split(":")[0] not in failed_servers)] if successful: verb = "Disabled" if action == "disable" else "Enabled" _print_success(f"{verb}: {', '.join(successful)}") diff --git a/hermes_cli/tools_config_post_setup.py b/hermes_cli/tools_config_post_setup.py index d86c7fa7ed..d58f5a55e5 100644 --- a/hermes_cli/tools_config_post_setup.py +++ b/hermes_cli/tools_config_post_setup.py @@ -11,17 +11,11 @@ from pathlib import Path from typing import Set from hermes_cli.cli_output import ( - print_error as _print_error, - print_info as _print_info, - print_success as _print_success, - print_warning as _print_warning, -) + print_error as _print_error, print_info as _print_info, print_success as _print_success, + print_warning as _print_warning) from hermes_cli.config import get_env_value from hermes_cli.tools_config_cua import ( - _cua_driver_install_ready, - _pip_install, - _post_setup_no_window_flags, - install_cua_driver, + _cua_driver_install_ready, _pip_install, _post_setup_no_window_flags, _run_text, install_cua_driver, ) logger = logging.getLogger("hermes_cli.tools_config") @@ -31,11 +25,9 @@ PROJECT_ROOT = Path(__file__).parent.parent.resolve() def _ensure_browser_use_cli(*, verbose_hints: bool = False) -> None: """Install the Browser Use CLI if it isn't already runnable. - Primary driver engine for EVERY browser backend except Camofox (Firefox-based, no CDP surface). MANAGED-FIRST: a browser-use on the user's PATH does NOT satisfy this check — only the - Hermes-managed ``$HERMES_HOME/bin`` copy does. - """ + Hermes-managed ``$HERMES_HOME/bin`` copy does.""" _print_info(" Ensuring browser-use CLI (managed install)...") try: from tools.browser_use_cli import install_cli @@ -47,10 +39,8 @@ def _ensure_browser_use_cli(*, verbose_hints: bool = False) -> None: 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/)") + _print_info(" Falling back to zero-install runs via `uvx browser-use`" if shutil.which("uvx") + else " 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)") @@ -75,48 +65,34 @@ def _install_chromium(install_cmd: list[str]) -> None: """Run the agent-browser Chromium install command and report the outcome.""" _print_info(" Installing Chromium (~170MB one-time download)...") try: - result = subprocess.run( - install_cmd, - capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=str(PROJECT_ROOT), timeout=600, - creationflags=_post_setup_no_window_flags(), - ) + result = _run_text(install_cmd, cwd=str(PROJECT_ROOT), timeout=600, creationflags=_post_setup_no_window_flags()) if result.returncode == 0: _print_success(" Chromium installed") # Invalidate the cached "missing" flag so later check_browser_requirements() calls see the install. import tools.browser_tool as _bt _bt._cached_chromium_installed = None - else: - _print_warning(" Chromium install failed:") - tail = (result.stderr or result.stdout or "").strip().splitlines()[-3:] - for line in tail: - _print_info(f" {line[:200]}") - _print_info(" Run manually: npx agent-browser install --with-deps") + return + _print_warning(" Chromium install failed:") + for line in (result.stderr or result.stdout or "").strip().splitlines()[-3:]: + _print_info(f" {line[:200]}") 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") + _print_info(" Run manually: npx agent-browser install --with-deps") def _post_setup_agent_browser(post_setup_key: str) -> None: """``agent_browser`` (local Chromium) and ``browserbase`` (cloud rows) hooks. - agent-browser is not a root package.json dependency — it resolves lazily via npx (or a - global/Hermes-managed install), so there is no ``npm install`` step here. - """ + global/Hermes-managed install), so there is no ``npm install`` step here.""" # Every non-Camofox backend drives through the Browser Use CLI — install it here too. _ensure_browser_use_cli() try: # Lazy import so the tools_config UI doesn't pull in browser_tool at import time. from tools.browser_tool import ( - _chromium_installed, - _running_in_docker, - _find_agent_browser, - _resolve_npx_bin, - _is_npx_agent_browser_sentinel, - AGENT_BROWSER_NPX_SPEC, - ) + _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 @@ -168,11 +144,8 @@ def _post_setup_camofox() -> None: elif _npm_bin: _print_info(" Installing Camofox browser server...") # Absolute npm path so the .cmd shim executes on Windows; --workspaces=false avoids resolving apps/desktop. - result = subprocess.run( - [_npm_bin, "install", "--silent", "--workspaces=false"], - capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=str(PROJECT_ROOT), - creationflags=_post_setup_no_window_flags(), - ) + result = _run_text([_npm_bin, "install", "--silent", "--workspaces=false"], timeout=None, + cwd=str(PROJECT_ROOT), creationflags=_post_setup_no_window_flags()) if result.returncode == 0: _print_success(" Camofox installed") else: @@ -189,10 +162,8 @@ def _post_setup_camofox() -> None: _KITTENTTS_WHEEL_URL = "https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl" -# Post-setup hooks that only pip-install a Python package. Fields: module (import probe; present → skip -# install), label (status lines), installing (progress line), args (_pip_install args — keep in sync with -# _RESTORABLE_PYTHON_TOOL_DEPENDENCIES), manual ("Run manually:" command on failure/timeout), -# on_install (info lines only after a fresh install), always (info lines whenever the package is present). +# pip-only post-setup hooks: module (import probe), label, installing (progress line), args (keep in sync with +# _RESTORABLE_PYTHON_TOOL_DEPENDENCIES), manual (fallback command), on_install (fresh-install notes), always. _PIP_POST_SETUP_HOOKS: dict = { "faster_whisper": { "module": "faster_whisper", "label": "faster-whisper", @@ -200,8 +171,7 @@ _PIP_POST_SETUP_HOOKS: dict = { "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": (), - }, + "always": ()}, "kittentts": { "module": "kittentts", "label": "kittentts", "installing": "Installing kittentts (~25-80MB model, CPU-only)...", @@ -209,8 +179,7 @@ _PIP_POST_SETUP_HOOKS: dict = { "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": (), - }, + "always": ()}, "piper": { "module": "piper", "label": "piper-tts", "installing": "Installing piper-tts (~14MB wheel, voices downloaded on first use)...", @@ -218,46 +187,43 @@ _PIP_POST_SETUP_HOOKS: dict = { "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"), - }, + "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."), - }, -} + "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 + lines = list(spec["always"]) try: __import__(spec["module"]) - _print_success(f" {label} is already installed") + installed = True except ImportError: + installed = False + if installed: + _print_success(f" {label} is already installed") + else: _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"]: + if result.returncode != 0: + _print_warning(f" {label} install failed:") + _print_info(f" {(result.stderr or '').strip()[:300]}") + _print_info(f" Run manually: {spec['manual']}") + return + _print_success(f" {label} installed") + lines = list(spec["on_install"]) + lines + for line in lines: _print_info(f" {line}") @@ -274,8 +240,7 @@ def _post_setup_spotify() -> None: _print_info(" Starting Spotify login...") try: login_spotify_command(SimpleNamespace( - client_id=None, redirect_uri=None, scope=None, no_browser=False, timeout=None, - )) + client_id=None, redirect_uri=None, scope=None, no_browser=False, timeout=None)) _print_success(" Spotify authenticated") except SystemExit as exc: # User aborted the wizard or OAuth failed — don't fail the toolset enable. @@ -315,10 +280,9 @@ def _post_setup_langfuse() -> None: 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. - """ + """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")) @@ -347,10 +311,8 @@ def _post_setup_xai_grok() -> None: 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, - ) + "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") @@ -392,40 +354,30 @@ def _run_post_setup(post_setup_key: str): def valid_post_setup_keys() -> Set[str]: """Return the set of post-setup keys declared by any visible provider (``TOOL_CATEGORIES`` plus plugin-registered providers). This is the allowlist ``post-setup`` and the dashboard endpoint - validate against, so a caller cannot drive ``_run_post_setup`` with an arbitrary key. - """ + validate against, so a caller cannot drive ``_run_post_setup`` with an arbitrary key.""" from hermes_cli.tools_config import ( TOOL_CATEGORIES, _plugin_browser_providers, _plugin_image_gen_providers, _plugin_video_gen_providers, - _plugin_web_search_providers, - ) + _plugin_web_search_providers) keys: Set[str] = set() for cat in TOOL_CATEGORIES.values(): - for prov in cat.get("providers", []): - ps = prov.get("post_setup") - if ps: - keys.add(ps) + keys.update(ps for prov in cat.get("providers", []) if (ps := prov.get("post_setup"))) for builder in ( _plugin_web_search_providers, _plugin_image_gen_providers, - _plugin_video_gen_providers, _plugin_browser_providers, - ): + _plugin_video_gen_providers, _plugin_browser_providers): try: - for prov in builder(): - ps = prov.get("post_setup") - if ps: - keys.add(ps) + keys.update(ps for prov in builder() if (ps := prov.get("post_setup"))) except Exception: # pragma: no cover — defensive; plugins optional continue return keys def run_post_setup_command(args) -> int: - """``hermes tools post-setup `` — non-interactive runner the dashboard spawns so the GUI can - drive backend setup without re-implementing install logic. Exit code: 0 ok, 2 unknown key. - """ + """``hermes tools post-setup `` — non-interactive runner the dashboard spawns so the GUI can drive + backend setup without re-implementing install logic. Exit code: 0 ok, 2 unknown key.""" key = getattr(args, "post_setup_key", None) if not key: _print_error("Usage: hermes tools post-setup ") @@ -449,9 +401,7 @@ def run_post_setup_command(args) -> int: # binary/dependency install (otherwise toggling the toolset on silently skips the hook). Only add an # entry when the post_setup is the ONLY install side-effect for a no-key provider and the check is # local, bounded, and import-light. -_POST_SETUP_INSTALLED: dict = { - "cua_driver": lambda: _cua_driver_install_ready(), -} +_POST_SETUP_INSTALLED: dict = {"cua_driver": lambda: _cua_driver_install_ready()} def _post_setup_already_installed(post_setup_key: str) -> bool: @@ -483,16 +433,14 @@ _RESTORABLE_PYTHON_TOOL_DEPENDENCIES: dict[str, tuple[str, tuple[str, ...]]] = { "kittentts": ("kittentts", ("-U", _KITTENTTS_WHEEL_URL, "soundfile")), "piper": ("piper", ("-U", "piper-tts")), "ddgs": ("ddgs", ("-U", "ddgs")), - "langfuse": ("langfuse", ("langfuse",)), -} + "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) - ] + if _module_installed(module_name)] def restorable_python_tool_dependency(name: str) -> tuple[str, tuple[str, ...]] | None: @@ -501,10 +449,9 @@ def restorable_python_tool_dependency(name: str) -> tuple[str, tuple[str, ...]] def _agent_browser_installed() -> bool: - """True when everything ``_run_post_setup("agent_browser")`` installs is present: the agent-browser - CLI *and* the Chromium build it drives (or the Lightpanda engine, which needs no Chromium), so - "Run setup" flips to installed only when re-running it would be a no-op. - """ + """True when everything ``_run_post_setup("agent_browser")`` installs is present: the agent-browser CLI + *and* the Chromium build it drives (or the Lightpanda engine, which needs no Chromium), so "Run + setup" flips to installed only when re-running it would be a no-op.""" from hermes_cli.nous_subscription import _local_browser_runnable # The hook runs in a spawned process; this probe runs in the long-lived web-server/CLI process whose @@ -546,5 +493,4 @@ _POST_SETUP_READY: dict = { "browserbase": lambda: _cloud_agent_browser_installed(), "camofox": lambda: _camofox_installed(), "lightpanda": lambda: _lightpanda_installed(), - "cua_driver": lambda: _cua_driver_install_ready(), -} + "cua_driver": lambda: _cua_driver_install_ready()} diff --git a/hermes_cli/tools_config_providers.py b/hermes_cli/tools_config_providers.py index 0c0bf99082..b1f7be4110 100644 --- a/hermes_cli/tools_config_providers.py +++ b/hermes_cli/tools_config_providers.py @@ -4,13 +4,11 @@ from __future__ import annotations import importlib import logging +from functools import partial from typing import Callable, Optional from hermes_cli.cli_output import ( - print_info as _print_info, - print_success as _print_success, - print_warning as _print_warning, - prompt as _prompt, + print_info as _print_info, print_success as _print_success, print_warning as _print_warning, prompt as _prompt, ) from hermes_cli.colors import Colors, color from hermes_cli.config import cfg_get, get_env_value, load_config, save_config, save_env_value @@ -38,19 +36,13 @@ def _plugin_registry(module: str): 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]: + registry_module: str, marker_keys: tuple[str, ...], *, require_name: bool = True, skip_builtin: bool = False, + flatten_variants: bool = False) -> list[dict]: """Picker-row dicts (TOOL_CATEGORIES-shaped) for a plugin registry's providers. - - ``marker_keys`` are all set to the registry name so downstream config/model-picker code routes through - the registry. ``skip_builtin`` drops names shadowing ``_BUILTIN_NAMES``; ``flatten_variants`` expands a - schema's tier ``variants`` into separate rows sharing one backend name, distinguished by ``web_tier``. - """ + ``marker_keys`` are all set to the registry name so downstream config/model-picker code routes + through the registry. ``skip_builtin`` drops names shadowing ``_BUILTIN_NAMES``; + ``flatten_variants`` expands a schema's tier ``variants`` into separate rows sharing one backend + name, distinguished by ``web_tier``.""" registry = _plugin_registry(registry_module) if registry is None: return [] @@ -89,66 +81,41 @@ def _plugin_provider_rows( return rows -# Category -> (registry module, marker keys, _plugin_provider_rows kwargs). image/video ``*_plugin_name`` route -# config writes + model pickers through the registry (video has no in-tree backend); all bundled web providers -# are plugins (only firecrawl setup-flow rows stay hardcoded); ``browser_provider`` is the legacy key written to -# ``browser.cloud_provider``; TTS rows render below the built-in rows and write ``tts.provider: ``. +# Category -> (registry module, marker keys, _plugin_provider_rows kwargs). ``*_plugin_name`` markers route +# config writes + model pickers through the registry; ``browser_provider`` is the legacy ``browser.cloud_provider`` +# key; TTS rows write ``tts.provider: ``. _PLUGIN_PROVIDER_ROW_SPECS = { "image_gen": ("agent.image_gen_registry", ("image_gen_plugin_name",), {"require_name": False}), "video_gen": ("agent.video_gen_registry", ("video_gen_plugin_name",), {"require_name": False}), "web": ("agent.web_search_registry", ("web_backend", "web_search_plugin_name"), {"flatten_variants": True}), "browser": ("agent.browser_registry", ("browser_provider", "browser_plugin_name"), {}), - "tts": ("agent.tts_registry", ("tts_provider", "tts_plugin_name"), {"skip_builtin": True}), -} + "tts": ("agent.tts_registry", ("tts_provider", "tts_plugin_name"), {"skip_builtin": True})} def _plugin_rows_for(category: str) -> list[dict]: + """Picker rows for the plugin-registered providers of one ``_PLUGIN_PROVIDER_ROW_SPECS`` category.""" module, markers, kwargs = _PLUGIN_PROVIDER_ROW_SPECS[category] return _plugin_provider_rows(module, markers, **kwargs) -def _plugin_image_gen_providers() -> list[dict]: - """Picker rows for plugin-registered image gen providers.""" - return _plugin_rows_for("image_gen") - - -def _plugin_video_gen_providers() -> list[dict]: - """Picker rows for plugin-registered video gen providers (the only Video Generation rows).""" - return _plugin_rows_for("video_gen") - - -def _plugin_web_search_providers() -> list[dict]: - """Picker rows for plugin-registered web search providers.""" - return _plugin_rows_for("web") - - -def _plugin_browser_providers() -> list[dict]: - """Picker rows for plugin-registered cloud browser providers.""" - return _plugin_rows_for("browser") - - -def _plugin_tts_providers() -> list[dict]: - """Picker rows for plugin-registered TTS providers.""" - return _plugin_rows_for("tts") +_plugin_image_gen_providers = partial(_plugin_rows_for, "image_gen") +_plugin_video_gen_providers = partial(_plugin_rows_for, "video_gen") # the only Video Generation rows +_plugin_web_search_providers = partial(_plugin_rows_for, "web") +_plugin_browser_providers = partial(_plugin_rows_for, "browser") +_plugin_tts_providers = partial(_plugin_rows_for, "tts") def web_provider_capabilities(backend: str) -> list: """Capabilities (``search`` / ``extract``) a web backend supports, per the registry provider instance. - - Lets the Capabilities GUI offer ``web.search_backend`` / ``web.extract_backend`` only where it makes sense - (ddgs and brave-free are search-only). Unknown backend or registry failure -> both. - """ + Lets the Capabilities GUI offer ``web.search_backend`` / ``web.extract_backend`` only where it makes + sense (ddgs and brave-free are search-only). Unknown backend or registry failure -> both.""" try: from agent.web_search_registry import get_provider 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 + return [cap for cap, supported in (("search", provider.supports_search()), + ("extract", provider.supports_extract())) if supported] except Exception: pass return ["search", "extract"] @@ -160,22 +127,15 @@ _PLUGIN_ROW_BUILDERS = { "Video Generation": _plugin_video_gen_providers, "Web Search & Extract": _plugin_web_search_providers, "Browser Automation": _plugin_browser_providers, - "Text-to-Speech": _plugin_tts_providers, -} + "Text-to-Speech": _plugin_tts_providers} def _visible_providers( - cat: dict, - config: dict, - *, - force_fresh: bool = False, - features: Optional[NousSubscriptionFeatures] = None, + cat: dict, config: dict, *, force_fresh: bool = False, features: Optional[NousSubscriptionFeatures] = None, ) -> list[dict]: """Provider entries visible for the current auth/config state. - - Nous-managed rows (``managed_nous_feature``) are always shown, even logged-out/unentitled, to advertise - the capability. - """ + Nous-managed rows (``managed_nous_feature``) are always shown, even logged-out/unentitled, to + advertise the capability.""" from hermes_cli.tools_config import get_nous_subscription_features if features is None: @@ -186,15 +146,12 @@ def _visible_providers( pool_only = bool(acct and acct.logged_in and acct.paid_service_access is not True and acct.tool_gateway_entitled) visible = [] for provider in cat.get("providers", []): + managed = provider.get("managed_nous_feature") # Managed rows stay visible regardless of auth (selecting one drives an inline Portal login); a # `requires_nous_auth` row without a managed feature hides until logged in. - if provider.get("requires_nous_auth") and not provider.get("managed_nous_feature") and not features.nous_auth_present: + if provider.get("requires_nous_auth") and not managed and not features.nous_auth_present: continue - if ( - pool_only - and provider.get("managed_nous_feature") == "video_gen" - and not (acct and acct.tool_gateway_entitled_for("fal-video")) - ): + if pool_only and managed == "video_gen" and not (acct and acct.tool_gateway_entitled_for("fal-video")): continue visible.append(provider) @@ -205,23 +162,12 @@ def _visible_providers( return visible -def provider_readiness_status( - provider: dict, - config: dict, - *, - features=None, - is_active: Optional[bool] = None, -) -> str: +def provider_readiness_status(provider: dict, config: dict, *, features=None, is_active: Optional[bool] = None) -> str: """Honest readiness state for a provider picker row. - - ``features`` avoids re-fetching portal state per row. ``is_active`` is the completed-setup fallback for - post_setup hooks with no registered installed-check (selecting a row runs its hook). - """ + ``features`` avoids re-fetching portal state per row. ``is_active`` is the completed-setup fallback + for post_setup hooks with no registered installed-check (selecting a row runs its hook).""" from hermes_cli.tools_config import ( - _POST_SETUP_READY, - _provider_env_ready, - _xai_credentials_present, - get_nous_subscription_features, + _POST_SETUP_READY, _provider_env_ready, _xai_credentials_present, get_nous_subscription_features, ) if provider.get("env_vars", []): @@ -261,12 +207,7 @@ def provider_readiness_status( return "ready" -def _toolset_needs_configuration_prompt( - ts_key: str, - config: dict, - *, - force_fresh: bool = False, -) -> bool: +def _toolset_needs_configuration_prompt(ts_key: str, config: dict, *, force_fresh: bool = False) -> bool: """Return True when enabling this toolset should open provider setup.""" from hermes_cli.tools_config import TOOL_CATEGORIES, _post_setup_already_installed, _toolset_has_keys @@ -311,19 +252,10 @@ def _any_plugin_provider_available(registry_module: str) -> bool: return False -def _configure_tool_category( - ts_key: str, - cat: dict, - config: dict, - *, - force_fresh: bool = True, - reconfigure: bool = 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`` ("Reconfigure an existing tool"): no setup note / skip row / Nous marker, and the chosen - provider goes through the key-update prompts instead of the new-enable prompts. - """ + ``reconfigure`` ("Reconfigure an existing tool"): no setup note / skip row / Nous marker, and the + chosen provider goes through the key-update prompts instead of the new-enable prompts.""" from hermes_cli.tools_config import _prompt_choice, _provider_env_ready, get_nous_subscription_features icon = cat.get("icon", "") @@ -387,11 +319,10 @@ def _configure_tool_category( def _web_tier_matches(provider: dict, config: dict) -> bool: """True when a web picker row's tier matches the configured tier (``web.provider_tier.``). - - Tiered rows (Exa/Parallel Free vs Paid) share one ``web_backend`` and differ only in ``web_tier``. No - ``web_tier`` on the row → matches; configured tier set → must equal the row's tier; unset → "auto": - paid when the row's key is present, free otherwise (highlight the row the runtime would actually use). - """ + Tiered rows (Exa/Parallel Free vs Paid) share one ``web_backend`` and differ only in ``web_tier``. + No ``web_tier`` on the row → matches; configured tier set → must equal the row's tier; unset → + "auto": paid when the row's key is present, free otherwise (highlight the row the runtime would + actually use).""" row_tier = provider.get("web_tier") if not row_tier: return True @@ -413,6 +344,18 @@ def _web_tier_matches(provider: dict, config: dict) -> bool: return row_tier == ("paid" if has_key else "free") +# Managed-row marker -> (config section, key) the pick writes, in check order. +_MANAGED_SELECTION_KEYS: tuple[tuple[str, str, str], ...] = ( + ("tts_provider", "tts", "provider"), ("stt_provider", "stt", "provider"), + ("browser_provider", "browser", "cloud_provider"), ("web_backend", "web", "backend")) + + +def _has_marker(provider: dict, marker: str) -> bool: + """``browser_provider`` is a membership test (a local row carries ``browser_provider: ""``); every other + marker is a truthiness test.""" + return marker in provider if marker == "browser_provider" else bool(provider.get(marker)) + + def _managed_provider_active(provider: dict, config: dict, managed_feature: str, force_fresh: bool) -> bool: """Active check for a Nous-managed row: the feature must be managed AND the category's selected provider must be the row's vendor or ``nous``.""" @@ -431,28 +374,18 @@ def _managed_provider_active(provider: dict, config: dict, managed_feature: str, 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) - ): + and not is_truthy_value(gen_cfg.get("use_gateway"), default=False)): return False return feature.managed_by_nous - if provider.get("tts_provider"): - current = cfg_get(config, "tts", "provider") - return feature.managed_by_nous and current in {provider["tts_provider"], NOUS_MANAGED_PROVIDER} - if provider.get("stt_provider"): - current = cfg_get(config, "stt", "provider") - return feature.managed_by_nous and current in {provider["stt_provider"], NOUS_MANAGED_PROVIDER} - if "browser_provider" in provider: - # Browser Use mode is a driver on top of the provider (attaches to its CDP endpoint), so the - # provider row stays active alongside the Browser Use row. - current = cfg_get(config, "browser", "cloud_provider") - return feature.managed_by_nous and current in {provider["browser_provider"], NOUS_MANAGED_PROVIDER} - if provider.get("web_backend"): - current = cfg_get(config, "web", "backend") - return ( - feature.managed_by_nous - and current in {provider["web_backend"], NOUS_MANAGED_PROVIDER} - and _web_tier_matches(provider, config) - ) + # Browser Use mode is a driver on top of the provider (attaches to its CDP endpoint), so the browser + # provider row stays active alongside the Browser Use row. + for marker, section, key in _MANAGED_SELECTION_KEYS: + if _has_marker(provider, marker): + current = cfg_get(config, section, key) + selected = current in {provider[marker], NOUS_MANAGED_PROVIDER} + if marker == "web_backend": + selected = selected and _web_tier_matches(provider, config) + return feature.managed_by_nous and selected return feature.managed_by_nous @@ -477,15 +410,6 @@ def _browser_use_default_active(config: dict) -> bool: return False -def _tts_active(provider: dict, config: dict) -> bool: - return cfg_get(config, "tts", "provider") == provider["tts_provider"] - - -def _stt_active(provider: dict, config: dict) -> bool: - # Default stt.provider is "local" — an unset key means Local Whisper. - return (cfg_get(config, "stt", "provider") or "local") == provider["stt_provider"] - - def _browser_provider_active(provider: dict, config: dict) -> bool: # Browser Use mode composes with the provider (driver over its CDP endpoint) — don't deactivate the row. if provider["browser_provider"] != cfg_get(config, "browser", "cloud_provider"): @@ -511,44 +435,28 @@ def _browser_backend_active(provider: dict, config: dict) -> bool: return _browser_use_default_active(config) -def _web_backend_active(provider: dict, config: dict) -> bool: - return cfg_get(config, "web", "backend") == provider["web_backend"] and _web_tier_matches(provider, config) - - -def _computer_use_active(provider: dict, config: dict) -> bool: - return cfg_get(config, "computer_use", "backend") == provider["computer_use_backend"] - - def _imagegen_backend_active(provider: dict, config: dict) -> bool: image_cfg = config.get("image_gen", {}) return ( isinstance(image_cfg, dict) and provider["imagegen_backend"] == "fal" and image_cfg.get("provider") in {None, "", "fal"} - and not is_truthy_value(image_cfg.get("use_gateway"), default=False) - ) + and not is_truthy_value(image_cfg.get("use_gateway"), default=False)) -# Non-managed active checks, evaluated in order; the first marker the row carries decides. ``browser_provider`` -# is a membership test (a local row carries ``browser_provider: ""``), every other marker is a truthiness test. +# Non-managed active checks, evaluated in order; the first marker the row carries decides (see ``_has_marker``). +# Default stt.provider is "local" — an unset key means Local Whisper. _ACTIVE_CHECKS: tuple[tuple[str, Callable[[dict, dict], bool]], ...] = ( - ("tts_provider", _tts_active), - ("stt_provider", _stt_active), + ("tts_provider", lambda p, c: cfg_get(c, "tts", "provider") == p["tts_provider"]), + ("stt_provider", lambda p, c: (cfg_get(c, "stt", "provider") or "local") == p["stt_provider"]), ("browser_provider", _browser_provider_active), ("browser_backend", _browser_backend_active), - ("web_backend", _web_backend_active), - ("computer_use_backend", _computer_use_active), - ("imagegen_backend", _imagegen_backend_active), -) -_MEMBERSHIP_MARKERS = frozenset({"browser_provider"}) + ("web_backend", lambda p, c: cfg_get(c, "web", "backend") == p["web_backend"] and _web_tier_matches(p, c)), + ("computer_use_backend", lambda p, c: cfg_get(c, "computer_use", "backend") == p["computer_use_backend"]), + ("imagegen_backend", _imagegen_backend_active)) -def _is_provider_active( - provider: dict, - config: dict, - *, - force_fresh: bool = False, -) -> bool: +def _is_provider_active(provider: dict, config: dict, *, force_fresh: bool = False) -> bool: """Check if a provider entry matches the currently active config.""" managed_feature = provider.get("managed_nous_feature") plugin_name = provider.get("image_gen_plugin_name") @@ -570,18 +478,12 @@ def _is_provider_active( return _managed_provider_active(provider, config, managed_feature, force_fresh) for marker, check in _ACTIVE_CHECKS: - present = marker in provider if marker in _MEMBERSHIP_MARKERS else provider.get(marker) - if present: + if _has_marker(provider, marker): return check(provider, config) return False -def _detect_active_provider_index( - providers: list, - config: dict, - *, - force_fresh: bool = False, -) -> int: +def _detect_active_provider_index(providers: list, config: dict, *, force_fresh: bool = False) -> int: """Return the index of the currently active provider, or 0.""" from hermes_cli.tools_config import _provider_env_ready @@ -602,8 +504,7 @@ def _fal_model_catalog(): # Per-backend model catalog (config_key = top-level config.yaml section, catalog_fn -> ({model_id: metadata}, # default_model)); a TOOL_CATEGORIES row tagged `imagegen_backend: ""` selects the catalog at picker time. IMAGEGEN_BACKENDS = { - "fal": {"display": "FAL.ai", "config_key": "image_gen", "catalog_fn": _fal_model_catalog}, -} + "fal": {"display": "FAL.ai", "config_key": "image_gen", "catalog_fn": _fal_model_catalog}} def _plugin_model_catalog(registry_module: str, plugin_name: str): @@ -627,31 +528,18 @@ def _plugin_model_catalog(registry_module: str, plugin_name: str): 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) +_plugin_image_gen_catalog = partial(_plugin_model_catalog, "agent.image_gen_registry") +_plugin_video_gen_catalog = partial(_plugin_model_catalog, "agent.video_gen_registry") def _pick_model_from_catalog( - catalog: dict, - default_model, - cfg_key: str, - display: str, - config: dict, - *, - row_indent: str = "", + 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 belonging to another provider (shared config key) or a drifted catalog default never - indexes the catalog. Safe when stdin is not a TTY — curses_radiolist keeps the current selection. - """ + Writes the choice to ``config[cfg_key]["model"]``. The current model is listed first so the cursor + lands on it; a saved model belonging to another provider (shared config key) or a drifted catalog + default never indexes the catalog. Safe when stdin is not a TTY — curses_radiolist keeps the current + selection.""" from hermes_cli.tools_config import _cfg_section, _prompt_choice if not catalog: @@ -666,8 +554,7 @@ def _pick_model_from_catalog( 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), - } + "strengths": max((len(catalog[m].get("strengths", "")) for m in model_ids), default=0)} print() header = (f" {'Model':<{widths['model']}} {'Speed':<{widths['speed']}} " @@ -705,9 +592,8 @@ def _configure_imagegen_model_for_plugin(plugin_name: str, config: dict) -> None def _configure_videogen_model_for_plugin(plugin_name: str, config: dict) -> None: - """Prompt for a video gen model from a plugin's catalog.""" + """Prompt for a video gen model from a plugin's catalog (rows keep their historical two-space indent).""" 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=" ") @@ -715,38 +601,29 @@ def _configure_xai_imagine_storage(section_name: str, config: dict) -> None: """Prompt for xAI Imagine stored public URL behavior.""" from hermes_cli.tools_config import _cfg_section, _prompt_choice - section = _cfg_section(config, section_name) - xai_cfg = _cfg_section(section, "xai") - storage_cfg = _cfg_section(xai_cfg, "storage") - + storage_cfg = _cfg_section(_cfg_section(_cfg_section(config, section_name), "xai"), "storage") _print_warning( " xAI Imagine can store generated media and create reusable public URLs. " - "xAI may bill for stored files and public URL hosting." - ) + "xAI may bill for stored files and public URL hosting.") choices = ["Enable public URLs without automatic expiry (recommended)", "Disable stored public URLs", "Enable public URLs for 2 days"] idx = _prompt_choice(" Stored public URLs:", choices, default=0) if idx == 1: storage_cfg["enabled"] = False _print_success(" xAI stored public URLs disabled") - 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") + return + storage_cfg["enabled"] = True + storage_cfg["public_url"] = True + storage_cfg["expires_after"] = 2 * 24 * 60 * 60 if idx == 2 else None + _print_success(" xAI stored public URLs enabled for 2 days" if idx == 2 + else " xAI stored public URLs enabled without automatic expiry") -def _select_plugin_gen_provider(section: str, plugin_name: str, config: dict, *, use_gateway: bool) -> None: +def _select_plugin_gen_provider(section: str, plugin_name: str, config: dict, *, use_gateway: bool = False) -> None: """Persist a plugin-backed image/video gen provider selection and run its model picker. - - ``use_gateway=True`` (Nous-managed pick) stores ``
.provider: nous``; BYOK picks store the plugin - name. Any legacy ``use_gateway`` key is removed so old read-time shims cannot override the selection. - """ + ``use_gateway=True`` (Nous-managed pick) stores ``
.provider: nous``; BYOK picks store the + plugin name. Any legacy ``use_gateway`` key is removed so old read-time shims cannot override the + selection.""" from hermes_cli.tools_config import _cfg_section cfg = _cfg_section(config, section) @@ -761,13 +638,8 @@ def _select_plugin_gen_provider(section: str, plugin_name: str, config: dict, *, _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) - +_select_plugin_image_gen_provider = partial(_select_plugin_gen_provider, "image_gen") +_select_plugin_video_gen_provider = partial(_select_plugin_gen_provider, "video_gen") # Per-provider STT model catalogs for the picker; keys are ``stt.`` sections, first entry is the # default. Kept in sync with the dashboard selects (web_server _CONFIG_FIELD_META) and the desktop settings @@ -776,8 +648,7 @@ 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": ["scribe_v2", "scribe_v1"]} # ElevenLabs historically uses ``model_id`` instead of ``model``. _STT_MODEL_CONFIG_KEY = {"elevenlabs": "model_id"} @@ -790,44 +661,36 @@ def _configure_stt_model(stt_provider: str, config: dict) -> None: 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) + prov_cfg = _cfg_section(_cfg_section(config, "stt"), 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] + chosen = ordered[_prompt_choice(" Select STT model:", ordered, default_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", + "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 _drop_use_gateway(section) -> None: + if isinstance(section, dict): + section.pop("use_gateway", None) + + 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) shared by the CLI and the GUI ``PUT .../provider`` endpoint. Each pick writes - exactly ONE provider string per category (``nous`` for managed rows) and removes any legacy - ``use_gateway`` key so the read-time shim cannot override the new choice. - """ + Pure, non-interactive core of :func:`_configure_provider` (no env prompts, post-setup hooks, Nous + auth gating or model pickers) shared by the CLI and the GUI ``PUT .../provider`` endpoint. Each pick + writes exactly ONE provider string per category (``nous`` for managed rows) and removes any legacy + ``use_gateway`` key so the read-time shim cannot override the new choice.""" from hermes_cli.tools_config import TOOL_CATEGORIES, _cfg_section - def _drop_use_gateway(section) -> None: - if isinstance(section, dict): - section.pop("use_gateway", None) - def _set_selection(section_key: str, name_key: str, vendor_value) -> None: section = _cfg_section(config, section_key) section[name_key] = NOUS_MANAGED_PROVIDER if managed_feature else vendor_value @@ -909,8 +772,7 @@ def apply_provider_selection(ts_key: str, provider_name: str, config: dict) -> N 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), - ] + ("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) @@ -920,11 +782,10 @@ def apply_provider_selection(ts_key: str, provider_name: str, config: dict) -> N def _nous_provider_gate(provider: dict, config: dict, managed_feature, *, force_fresh: bool) -> bool: """Return False (after printing why) when a Nous-gated row cannot be selected. - - Managed Tool Gateway rows are always listed but only *activate* with paid Nous Portal access — selecting - one runs an inline Portal login (auth + entitlement only, no inference-provider switch). Pure pre-auth UX - rows (``requires_nous_auth`` without a managed feature) keep the older logged-in + entitled gate. - """ + Managed Tool Gateway rows are always listed but only *activate* with paid Nous Portal access — + selecting one runs an inline Portal login (auth + entitlement only, no inference-provider switch). + Pure pre-auth UX rows (``requires_nous_auth`` without a managed feature) keep the older logged-in + + entitled gate.""" from hermes_cli.tools_config import get_nous_subscription_features if managed_feature: @@ -932,8 +793,7 @@ def _nous_provider_gate(provider: dict, config: dict, managed_feature, *, force_ if not ensure_nous_portal_access( capability=f"{provider.get('name', 'the Nous Tool Gateway')}", - coverage_category=MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature), - ): + 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 @@ -943,8 +803,7 @@ def _nous_provider_gate(provider: dict, config: dict, managed_feature, *, force_ entitled = bool(features.account_info and features.account_info.paid_service_access is True) if not features.nous_auth_present or not entitled: message = format_nous_portal_entitlement_message( - features.account_info, capability=f"{provider.get('name', 'Nous Subscription')}" - ) + features.account_info, capability=f"{provider.get('name', 'Nous Subscription')}") _print_warning(f" {message or 'Nous Subscription is only available after logging into Nous Portal.'}") return False return True @@ -994,10 +853,8 @@ def _print_provider_selection(provider: dict, managed_feature, *, reconfigure: b if provider.get("browser_engine") and provider["browser_engine"] != "auto": _print_success(f" Browser engine set to: {provider['browser_engine']}") if provider.get("web_backend"): - if reconfigure and provider.get("web_tier"): - _print_success(f" Web backend set to: {provider['web_backend']} ({provider['web_tier']} tier)") - else: - _print_success(f" Web backend set to: {provider['web_backend']}") + tier = f" ({provider['web_tier']} tier)" if reconfigure and provider.get("web_tier") else "" + _print_success(f" Web backend set to: {provider['web_backend']}{tier}") if reconfigure and provider.get("computer_use_backend"): _print_success(f" Computer Use backend set to: {provider['computer_use_backend']}") @@ -1019,61 +876,58 @@ def _show_portal_hint(provider: dict, config: dict, managed_feature, force_fresh return False +def _prompt_secret( + key: str, label: str, url: str, default_val: str, *, reconfigure: bool, url_label: str, strip: bool = False, +) -> bool: + """One env-var prompt; True unless the new-enable flow skipped the key. + Reconfigure mode shows the current value and re-prompts ("Enter to keep current"); the new-enable + flow prompts with ``default_val`` visible when one exists, else as a password, and ``strip`` decides + whether whitespace is trimmed (and whitespace-only counts as skipped).""" + if reconfigure: + existing = get_env_value(key) + if existing: + _print_info(f" {key}: configured ({existing[:8]}...)") + if url: + _print_info(f" {url_label}: {url}") + if reconfigure: + value = _prompt(f" {label} (Enter to keep current)", password=not default_val) + if value and value.strip(): + save_env_value(key, value.strip()) + _print_success(" Updated") + else: + _print_info(" Kept current") + return True + value = _prompt(f" {label}", default_val) if default_val else _prompt(f" {label}", password=True) + if strip: + value = (value or "").strip() + if value: + save_env_value(key, value) + _print_success(" Saved") + return True + _print_warning(" Skipped") + return False + + def _prompt_env_vars(env_vars: list, *, reconfigure: bool) -> bool: """Prompt for a provider's env vars; True when every key ended up configured. - - Reconfigure mode re-prompts every key ("Enter to keep current") and always returns True; the new-enable - flow keeps already-set keys without asking and reports False on any skipped key. - """ + Reconfigure mode re-prompts every key and always returns True; the new-enable flow keeps already-set + keys without asking and reports False on any skipped key.""" all_configured = True for var in env_vars: - existing = get_env_value(var["key"]) - if reconfigure: - if existing: - _print_info(f" {var['key']}: configured ({existing[:8]}...)") - url = var.get("url", "") - if url: - _print_info(f" Get yours at: {url}") - default_val = var.get("default", "") - value = _prompt(f" {var.get('prompt', var['key'])} (Enter to keep current)", password=not default_val) - if value and value.strip(): - save_env_value(var["key"], value.strip()) - _print_success(" Updated") - else: - _print_info(" Kept current") - continue - if existing: + if not reconfigure and get_env_value(var["key"]): _print_success(f" {var['key']}: already configured") continue - url = var.get("url", "") - if url: - _print_info(f" Get yours at: {url}") - default_val = var.get("default", "") - if default_val: - value = _prompt(f" {var.get('prompt', var['key'])}", default_val) - else: - value = _prompt(f" {var.get('prompt', var['key'])}", password=True) - if value: - save_env_value(var["key"], value) - _print_success(" Saved") - else: - _print_warning(" Skipped") - all_configured = False + ok = _prompt_secret(var["key"], var.get("prompt", var["key"]), var.get("url", ""), var.get("default", ""), + reconfigure=reconfigure, url_label="Get yours at") + all_configured = all_configured and ok return all_configured -def _configure_provider( - provider: dict, - config: dict, - *, - force_fresh: bool = True, - reconfigure: bool = False, -): +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`` (new-enable): already-set keys are kept without asking and the post-setup hook only - runs when every key was provided. ``reconfigure=True`` re-prompts every key and always runs the hook. - """ + ``reconfigure=False`` (new-enable): already-set keys are kept without asking and the post-setup hook + only runs when every key was provided. ``reconfigure=True`` re-prompts every key and always runs the + hook.""" from hermes_cli.tools_config import _run_post_setup env_vars = provider.get("env_vars", []) @@ -1114,10 +968,9 @@ def _reconfigure_provider(provider: dict, config: dict, *, force_fresh: bool = T def _configure_vision_backend() -> None: """Interactive vision-backend configuration (``auxiliary.vision.{provider,model,base_url}``). - - Offers any authenticated provider + model (same surface as ``hermes model``) or a custom endpoint rather - than forcing OpenRouter. "Auto" leaves the keys empty so the resolver uses the main-model fallback chain. - """ + Offers any authenticated provider + model (same surface as ``hermes model``) or a custom endpoint + rather than forcing OpenRouter. "Auto" leaves the keys empty so the resolver uses the main-model + fallback chain.""" from hermes_cli.tools_config import _cfg_section, _prompt_choice print() @@ -1128,8 +981,7 @@ def _configure_vision_backend() -> None: "Auto — use your main model / aggregator fallback (recommended)", "Pick a provider and model", "Custom OpenAI-compatible endpoint — base URL, API key, model", - "Skip", - ] + "Skip"] idx = _prompt_choice(" Configure vision backend", choices, 0) config = load_config() @@ -1141,13 +993,9 @@ def _configure_vision_backend() -> None: vision_cfg.pop(key, None) save_config(config) _print_success(" Vision set to auto (main model / aggregator fallback)") - return - - if idx == 1: + elif idx == 1: _configure_vision_provider_model(config, vision_cfg) - return - - if idx == 2: + elif 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" @@ -1169,18 +1017,15 @@ def _configure_vision_backend() -> None: vision_cfg.pop("model", None) save_config(config) _print_success(f" Vision set to custom endpoint{f' ({model})' if model else ''}") - return - - _print_info(" Skipped vision configuration") + else: + _print_info(" Skipped vision configuration") def _configure_vision_provider_model(config: dict, vision_cfg: dict) -> None: """Provider + model picker for vision, mirroring the ``/model`` surface. - - Rows come from ``build_aux_picker_rows()`` so this lists exactly what the ``hermes model`` aux-task picker - lists, including user-defined ``providers:`` / ``custom_providers:`` endpoints. Persists - ``auxiliary.vision.provider`` + ``.model``. - """ + Rows come from ``build_aux_picker_rows()`` so this lists exactly what the ``hermes model`` aux-task + picker lists, including user-defined ``providers:`` / ``custom_providers:`` endpoints. Persists + ``auxiliary.vision.provider`` + ``.model``.""" from hermes_cli.tools_config import _prompt_choice try: @@ -1203,8 +1048,7 @@ def _configure_vision_provider_model(config: dict, vision_cfg: dict) -> None: if not providers: _print_warning( " No authenticated providers found. Configure a provider first " - "with `hermes model`, then re-run this." - ) + "with `hermes model`, then re-run this.") return provider_labels = [label for _slug, label, _models in format_aux_picker_entries( @@ -1240,10 +1084,8 @@ def _configure_vision_provider_model(config: dict, vision_cfg: dict) -> None: def _configure_simple_requirements(ts_key: str, *, reconfigure: bool = False): """Fallback for toolsets that just need env vars (no provider selection). - Vision has its own provider/model picker — run it directly so neither flow falls back to the generic - single-key prompt (which would re-ask for OPENROUTER_API_KEY). - """ + single-key prompt (which would re-ask for OPENROUTER_API_KEY).""" from hermes_cli.tools_config import TOOLSET_ENV_REQUIREMENTS, _toolset_has_keys, _toolset_label if ts_key == "vision": @@ -1265,18 +1107,4 @@ def _configure_simple_requirements(ts_key: str, *, reconfigure: bool = False): 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") - elif reconfigure: - _print_info(" Kept current") - else: - _print_warning(" Skipped") + _prompt_secret(var, var, url, "", reconfigure=reconfigure, url_label="Get key at", strip=True) diff --git a/hermes_cli/toolset_scope.py b/hermes_cli/toolset_scope.py index 4bb107fba3..6e080324b2 100644 --- a/hermes_cli/toolset_scope.py +++ b/hermes_cli/toolset_scope.py @@ -4,10 +4,7 @@ from typing import Set # Toolsets without a restriction entry are available on every platform. -_TOOLSET_PLATFORM_RESTRICTIONS = { - "discord": {"discord"}, - "discord_admin": {"discord"}, -} +_TOOLSET_PLATFORM_RESTRICTIONS = {"discord": {"discord"}, "discord_admin": {"discord"}} def toolset_allowed_for_platform(ts_key: str, platform: str) -> bool: diff --git a/hermes_cli/toolset_validation.py b/hermes_cli/toolset_validation.py index ad34f3bacb..72cbc3a6b9 100644 --- a/hermes_cli/toolset_validation.py +++ b/hermes_cli/toolset_validation.py @@ -14,17 +14,12 @@ def _platform_default_toolset(platform: object) -> str: def _platform_default_is_valid( - platform: object, - default_toolset: str, - is_valid_toolset: Callable[[str], bool], - is_allowed_for_platform: Callable[[str, str], bool], -) -> bool: - if is_valid_toolset(default_toolset) and is_allowed_for_platform( - default_toolset, str(platform) - ): + platform: object, default_toolset: str, is_valid_toolset: Callable[[str], bool], + is_allowed_for_platform: Callable[[str, str], bool]) -> bool: + if is_valid_toolset(default_toolset) and is_allowed_for_platform(default_toolset, str(platform)): return True - # Dynamic plugin platforms are resolved by toolsets.resolve_toolset() even - # though their synthesized hermes- name is not in TOOLSETS. + # Dynamic plugin platforms are resolved by toolsets.resolve_toolset() even though their synthesized + # hermes- name is not in TOOLSETS. try: from gateway.platform_registry import platform_registry @@ -34,19 +29,15 @@ def _platform_default_is_valid( def validate_platform_toolsets( - platform_toolsets: object, - is_valid_toolset: Callable[[str], bool], + platform_toolsets: object, is_valid_toolset: Callable[[str], bool], is_allowed_for_platform: Callable[[str, str], bool] = toolset_allowed_for_platform, ) -> List[str]: """Return human-readable warnings for a ``platform_toolsets`` mapping. - - Reports: a toolset name ``is_valid_toolset`` rejects (suggesting ``hermes-`` when - that would have been valid); a non-empty mapping resolving to zero valid toolsets (agent - would start with no tools); a platform with no valid toolsets, checked per-platform because - the global net is suppressed once any platform is valid; and non-list platform values, which - fall back to the platform default. ``is_valid_toolset`` is injected so this does no registry - imports or I/O. - """ + Reports: a toolset name ``is_valid_toolset`` rejects (suggesting ``hermes-`` when that + would have been valid); a non-empty mapping resolving to zero valid toolsets (agent would start with + no tools); a platform with no valid toolsets, checked per-platform because the global net is + suppressed once any platform is valid; and non-list platform values, which fall back to the platform + default. ``is_valid_toolset`` is injected so this does no registry imports or I/O.""" warnings: List[str] = [] if not isinstance(platform_toolsets, dict) or not platform_toolsets: return warnings @@ -54,17 +45,13 @@ def validate_platform_toolsets( valid_count = 0 for platform, raw in platform_toolsets.items(): default = _platform_default_toolset(platform) - default_valid = _platform_default_is_valid( - platform, default, is_valid_toolset, is_allowed_for_platform - ) + default_valid = _platform_default_is_valid(platform, default, is_valid_toolset, is_allowed_for_platform) platform_valid_count = 0 if not isinstance(raw, list): if default_valid: valid_count += 1 platform_valid_count += 1 - fallback_detail = f"falling back to '{default}'" - else: - fallback_detail = f"falling back to unknown default '{default}'" + fallback_detail = f"falling back to '{default}'" if default_valid else f"falling back to unknown default '{default}'" if raw is None: value_detail = "a null toolset value" elif isinstance(raw, str): @@ -73,8 +60,7 @@ def validate_platform_toolsets( value_detail = f"invalid {type(raw).__name__} toolset value" warnings.append( f"platform '{platform}' has {value_detail} — " - f"{fallback_detail}. Run `hermes tools` to configure explicitly." - ) + f"{fallback_detail}. Run `hermes tools` to configure explicitly.") if platform_valid_count == 0: warnings.append(f"platform '{platform}' has no valid toolsets configured — {_NO_TOOLS}") continue @@ -82,21 +68,16 @@ def validate_platform_toolsets( for name in raw: if not isinstance(name, str) or not name: continue - if is_valid_toolset(name): - if is_allowed_for_platform(name, str(platform)): - valid_count += 1 - platform_valid_count += 1 - else: - warnings.append( - f"platform '{platform}' references toolset '{name}' " - "which is not available on this platform" - ) - continue - hint = f" — did you mean '{default}'?" if default_valid else "" - warnings.append( - f"platform '{platform}' references unknown toolset " - f"'{name}'{hint}" - ) + if not is_valid_toolset(name): + hint = f" — did you mean '{default}'?" if default_valid else "" + warnings.append(f"platform '{platform}' references unknown toolset '{name}'{hint}") + elif is_allowed_for_platform(name, str(platform)): + valid_count += 1 + platform_valid_count += 1 + else: + warnings.append( + f"platform '{platform}' references toolset '{name}' which is not available on this platform" + ) if platform_valid_count == 0: reason = "is configured with an empty toolset list" if not raw else "has no valid toolsets configured" @@ -105,6 +86,5 @@ def validate_platform_toolsets( if valid_count == 0: warnings.append( "platform_toolsets resolves to zero valid toolsets — the agent will " - "have no tools. Run `hermes tools` to reconfigure." - ) + "have no tools. Run `hermes tools` to reconfigure.") return warnings