/ hermes_cli / tools_config.py
tools_config.py
   1  """
   2  Unified tool configuration for Hermes Agent.
   3  
   4  `hermes tools` and `hermes setup tools` both enter this module.
   5  Select a platform → toggle toolsets on/off → for newly enabled tools
   6  that need API keys, run through provider-aware configuration.
   7  
   8  Saves per-platform tool configuration to ~/.hermes/config.yaml under
   9  the `platform_toolsets` key.
  10  """
  11  
  12  import json as _json
  13  import logging
  14  import os
  15  import sys
  16  from pathlib import Path
  17  from typing import Dict, List, Optional, Set
  18  
  19  
  20  from hermes_cli.config import (
  21      cfg_get,
  22      load_config, save_config, get_env_value, save_env_value,
  23  )
  24  from hermes_cli.colors import Colors, color
  25  from hermes_cli.nous_subscription import (
  26      apply_nous_managed_defaults,
  27      get_nous_subscription_features,
  28  )
  29  from tools.tool_backend_helpers import fal_key_is_configured, managed_nous_tools_enabled
  30  from utils import base_url_hostname, is_truthy_value
  31  
  32  logger = logging.getLogger(__name__)
  33  
  34  PROJECT_ROOT = Path(__file__).parent.parent.resolve()
  35  
  36  
  37  # ─── UI Helpers (shared with setup.py) ────────────────────────────────────────
  38  
  39  from hermes_cli.cli_output import (  # noqa: E402 — late import block
  40      print_error as _print_error,
  41      print_info as _print_info,
  42      print_success as _print_success,
  43      print_warning as _print_warning,
  44      prompt as _prompt,
  45  )
  46  
  47  # ─── Toolset Registry ─────────────────────────────────────────────────────────
  48  
  49  # Toolsets shown in the configurator, grouped for display.
  50  # Each entry: (toolset_name, label, description)
  51  # These map to keys in toolsets.py TOOLSETS dict.
  52  CONFIGURABLE_TOOLSETS = [
  53      ("web",             "🔍 Web Search & Scraping",    "web_search, web_extract"),
  54      ("browser",         "🌐 Browser Automation",       "navigate, click, type, scroll"),
  55      ("terminal",        "💻 Terminal & Processes",      "terminal, process"),
  56      ("file",            "📁 File Operations",           "read, write, patch, search"),
  57      ("code_execution",  "⚡ Code Execution",            "execute_code"),
  58      ("vision",          "👁️  Vision / Image Analysis",  "vision_analyze"),
  59      ("video",           "🎬 Video Analysis",            "video_analyze (requires video-capable model)"),
  60      ("image_gen",       "🎨 Image Generation",          "image_generate"),
  61      ("moa",             "🧠 Mixture of Agents",         "mixture_of_agents"),
  62      ("tts",             "🔊 Text-to-Speech",            "text_to_speech"),
  63      ("skills",          "📚 Skills",                    "list, view, manage"),
  64      ("todo",            "📋 Task Planning",             "todo"),
  65      ("memory",          "💾 Memory",                    "persistent memory across sessions"),
  66      ("session_search",  "🔎 Session Search",            "search past conversations"),
  67      ("clarify",         "❓ Clarifying Questions",      "clarify"),
  68      ("delegation",      "👥 Task Delegation",           "delegate_task"),
  69      ("cronjob",         "⏰ Cron Jobs",                 "create/list/update/pause/resume/run, with optional attached skills"),
  70      ("messaging",       "📨 Cross-Platform Messaging",  "send_message"),
  71      ("rl",              "🧪 RL Training",               "Tinker-Atropos training tools"),
  72      ("homeassistant",    "🏠 Home Assistant",           "smart home device control"),
  73      ("spotify",          "🎵 Spotify",                  "playback, search, playlists, library"),
  74      ("discord",         "💬 Discord (read/participate)", "fetch messages, search members, create thread"),
  75      ("discord_admin",   "🛡️  Discord Server Admin",    "list channels/roles, pin, assign roles"),
  76      ("yuanbao",          "🤖 Yuanbao",                  "group info, member queries, DM"),
  77  ]
  78  
  79  # Toolsets that are OFF by default for new installs.
  80  # They're still in _HERMES_CORE_TOOLS (available at runtime if enabled),
  81  # but the setup checklist won't pre-select them for first-time users.
  82  _DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify", "discord", "discord_admin", "video"}
  83  
  84  # Platform-scoped toolsets: only appear in the `hermes tools` checklist for
  85  # these platforms, and only resolve/save for these platforms.  A toolset
  86  # absent from this map is available on every platform (current behaviour).
  87  #
  88  # Use this for tools whose APIs only make sense on one platform (Discord
  89  # server admin, Slack workspace admin, etc.).  Keeps every other platform's
  90  # checklist from filling up with irrelevant toggles.
  91  _TOOLSET_PLATFORM_RESTRICTIONS: Dict[str, Set[str]] = {
  92      "discord": {"discord"},
  93      "discord_admin": {"discord"},
  94  }
  95  
  96  
  97  def _toolset_allowed_for_platform(ts_key: str, platform: str) -> bool:
  98      """Return True if ``ts_key`` is configurable on ``platform``.
  99  
 100      Toolsets without a restriction entry are allowed everywhere (the default).
 101      """
 102      allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts_key)
 103      return allowed is None or platform in allowed
 104  
 105  
 106  def _get_effective_configurable_toolsets():
 107      """Return CONFIGURABLE_TOOLSETS + any plugin-provided toolsets.
 108  
 109      Plugin toolsets are appended at the end so they appear after the
 110      built-in toolsets in the TUI checklist. A plugin whose toolset key
 111      already appears in ``CONFIGURABLE_TOOLSETS`` is skipped — bundled
 112      plugins (e.g. ``plugins/spotify``) share their toolset key with the
 113      built-in entry, and we want the built-in label/description to win.
 114      Without the dedupe, ``hermes tools`` → "reconfigure existing" would
 115      list the same toolset twice.
 116      """
 117      result = list(CONFIGURABLE_TOOLSETS)
 118      seen = {ts_key for ts_key, _, _ in result}
 119      try:
 120          from hermes_cli.plugins import discover_plugins, get_plugin_toolsets
 121          discover_plugins()  # idempotent — ensures plugins are loaded
 122          for entry in get_plugin_toolsets():
 123              if entry[0] in seen:
 124                  continue
 125              seen.add(entry[0])
 126              result.append(entry)
 127      except Exception:
 128          pass
 129      return result
 130  
 131  
 132  def _get_plugin_toolset_keys() -> set:
 133      """Return the set of toolset keys provided by plugins."""
 134      try:
 135          from hermes_cli.plugins import discover_plugins, get_plugin_toolsets
 136          discover_plugins()  # idempotent — ensures plugins are loaded
 137          return {ts_key for ts_key, _, _ in get_plugin_toolsets()}
 138      except Exception:
 139          return set()
 140  
 141  # Platform display config — derived from the canonical registry so every
 142  # module shares the same data.  Kept as dict-of-dicts for backward
 143  # compatibility with existing ``PLATFORMS[key]["label"]`` access patterns.
 144  from hermes_cli.platforms import PLATFORMS as _PLATFORMS_REGISTRY
 145  
 146  PLATFORMS = {
 147      k: {"label": info.label, "default_toolset": info.default_toolset}
 148      for k, info in _PLATFORMS_REGISTRY.items()
 149  }
 150  
 151  
 152  # ─── Tool Categories (provider-aware configuration) ──────────────────────────
 153  # Maps toolset keys to their provider options. When a toolset is newly enabled,
 154  # we use this to show provider selection and prompt for the right API keys.
 155  # Toolsets not in this map either need no config or use the simple fallback.
 156  
 157  TOOL_CATEGORIES = {
 158      "tts": {
 159          "name": "Text-to-Speech",
 160          "icon": "🔊",
 161          "providers": [
 162              {
 163                  "name": "Nous Subscription",
 164                  "badge": "subscription",
 165                  "tag": "Managed OpenAI TTS billed to your subscription",
 166                  "env_vars": [],
 167                  "tts_provider": "openai",
 168                  "requires_nous_auth": True,
 169                  "managed_nous_feature": "tts",
 170                  "override_env_vars": ["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"],
 171              },
 172              {
 173                  "name": "Microsoft Edge TTS",
 174                  "badge": "★ recommended · free",
 175                  "tag": "Good quality, no API key needed",
 176                  "env_vars": [],
 177                  "tts_provider": "edge",
 178              },
 179              {
 180                  "name": "OpenAI TTS",
 181                  "badge": "paid",
 182                  "tag": "High quality voices",
 183                  "env_vars": [
 184                      {"key": "VOICE_TOOLS_OPENAI_KEY", "prompt": "OpenAI API key", "url": "https://platform.openai.com/api-keys"},
 185                  ],
 186                  "tts_provider": "openai",
 187              },
 188              {
 189                  "name": "xAI TTS",
 190                  "tag": "Grok voices - requires xAI API key",
 191                  "env_vars": [
 192                      {"key": "XAI_API_KEY", "prompt": "xAI API key", "url": "https://console.x.ai/"},
 193                  ],
 194                  "tts_provider": "xai",
 195              },
 196              {
 197                  "name": "ElevenLabs",
 198                  "badge": "paid",
 199                  "tag": "Most natural voices",
 200                  "env_vars": [
 201                      {"key": "ELEVENLABS_API_KEY", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/app/settings/api-keys"},
 202                  ],
 203                  "tts_provider": "elevenlabs",
 204              },
 205              {
 206                  "name": "Mistral (Voxtral TTS)",
 207                  "badge": "paid",
 208                  "tag": "Multilingual, native Opus",
 209                  "env_vars": [
 210                      {"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"},
 211                  ],
 212                  "tts_provider": "mistral",
 213              },
 214              {
 215                  "name": "Google Gemini TTS",
 216                  "badge": "preview",
 217                  "tag": "30 prebuilt voices, controllable via prompts",
 218                  "env_vars": [
 219                      {"key": "GEMINI_API_KEY", "prompt": "Gemini API key", "url": "https://aistudio.google.com/app/apikey"},
 220                  ],
 221                  "tts_provider": "gemini",
 222              },
 223              {
 224                  "name": "KittenTTS",
 225                  "badge": "local · free",
 226                  "tag": "Lightweight local ONNX TTS (~25MB), no API key",
 227                  "env_vars": [],
 228                  "tts_provider": "kittentts",
 229                  "post_setup": "kittentts",
 230              },
 231              {
 232                  "name": "Piper",
 233                  "badge": "local · free",
 234                  "tag": "Local neural TTS, 44 languages (voices ~20-90MB)",
 235                  "env_vars": [],
 236                  "tts_provider": "piper",
 237                  "post_setup": "piper",
 238              },
 239          ],
 240      },
 241      "web": {
 242          "name": "Web Search & Extract",
 243          "setup_title": "Select Search Provider",
 244          "setup_note": "A free DuckDuckGo search skill is also included — skip this if you don't need a premium provider.",
 245          "icon": "🔍",
 246          "providers": [
 247              {
 248                  "name": "Nous Subscription",
 249                  "badge": "subscription",
 250                  "tag": "Managed Firecrawl billed to your subscription",
 251                  "web_backend": "firecrawl",
 252                  "env_vars": [],
 253                  "requires_nous_auth": True,
 254                  "managed_nous_feature": "web",
 255                  "override_env_vars": ["FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"],
 256              },
 257              {
 258                  "name": "Firecrawl Cloud",
 259                  "badge": "★ recommended",
 260                  "tag": "Full-featured search, extract, and crawl",
 261                  "web_backend": "firecrawl",
 262                  "env_vars": [
 263                      {"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
 264                  ],
 265              },
 266              {
 267                  "name": "Exa",
 268                  "badge": "paid",
 269                  "tag": "Neural search with semantic understanding",
 270                  "web_backend": "exa",
 271                  "env_vars": [
 272                      {"key": "EXA_API_KEY", "prompt": "Exa API key", "url": "https://exa.ai"},
 273                  ],
 274              },
 275              {
 276                  "name": "Parallel",
 277                  "badge": "paid",
 278                  "tag": "AI-powered search and extract",
 279                  "web_backend": "parallel",
 280                  "env_vars": [
 281                      {"key": "PARALLEL_API_KEY", "prompt": "Parallel API key", "url": "https://parallel.ai"},
 282                  ],
 283              },
 284              {
 285                  "name": "Tavily",
 286                  "badge": "free tier",
 287                  "tag": "Search, extract, and crawl — 1000 free searches/mo",
 288                  "web_backend": "tavily",
 289                  "env_vars": [
 290                      {"key": "TAVILY_API_KEY", "prompt": "Tavily API key", "url": "https://app.tavily.com/home"},
 291                  ],
 292              },
 293              {
 294                  "name": "Firecrawl Self-Hosted",
 295                  "badge": "free · self-hosted",
 296                  "tag": "Run your own Firecrawl instance (Docker)",
 297                  "web_backend": "firecrawl",
 298                  "env_vars": [
 299                      {"key": "FIRECRAWL_API_URL", "prompt": "Your Firecrawl instance URL (e.g., http://localhost:3002)"},
 300                  ],
 301              },
 302          ],
 303      },
 304      "image_gen": {
 305          "name": "Image Generation",
 306          "icon": "🎨",
 307          "providers": [
 308              {
 309                  "name": "Nous Subscription",
 310                  "badge": "subscription",
 311                  "tag": "Managed FAL image generation billed to your subscription",
 312                  "env_vars": [],
 313                  "requires_nous_auth": True,
 314                  "managed_nous_feature": "image_gen",
 315                  "override_env_vars": ["FAL_KEY"],
 316                  "imagegen_backend": "fal",
 317              },
 318              {
 319                  "name": "FAL.ai",
 320                  "badge": "paid",
 321                  "tag": "Pick from flux-2-klein, flux-2-pro, gpt-image, nano-banana, etc.",
 322                  "env_vars": [
 323                      {"key": "FAL_KEY", "prompt": "FAL API key", "url": "https://fal.ai/dashboard/keys"},
 324                  ],
 325                  "imagegen_backend": "fal",
 326              },
 327          ],
 328      },
 329      "browser": {
 330          "name": "Browser Automation",
 331          "icon": "🌐",
 332          "providers": [
 333              {
 334                  "name": "Nous Subscription (Browser Use cloud)",
 335                  "badge": "subscription",
 336                  "tag": "Managed Browser Use billed to your subscription",
 337                  "env_vars": [],
 338                  "browser_provider": "browser-use",
 339                  "requires_nous_auth": True,
 340                  "managed_nous_feature": "browser",
 341                  "override_env_vars": ["BROWSER_USE_API_KEY"],
 342                  "post_setup": "agent_browser",
 343              },
 344              {
 345                  "name": "Local Browser",
 346                  "badge": "★ recommended · free",
 347                  "tag": "Headless Chromium, no API key needed",
 348                  "env_vars": [],
 349                  "browser_provider": "local",
 350                  "post_setup": "agent_browser",
 351              },
 352              {
 353                  "name": "Browserbase",
 354                  "badge": "paid",
 355                  "tag": "Cloud browser with stealth and proxies",
 356                  "env_vars": [
 357                      {"key": "BROWSERBASE_API_KEY", "prompt": "Browserbase API key", "url": "https://browserbase.com"},
 358                      {"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"},
 359                  ],
 360                  "browser_provider": "browserbase",
 361                  "post_setup": "agent_browser",
 362              },
 363              {
 364                  "name": "Browser Use",
 365                  "badge": "paid",
 366                  "tag": "Cloud browser with remote execution",
 367                  "env_vars": [
 368                      {"key": "BROWSER_USE_API_KEY", "prompt": "Browser Use API key", "url": "https://browser-use.com"},
 369                  ],
 370                  "browser_provider": "browser-use",
 371                  "post_setup": "agent_browser",
 372              },
 373              {
 374                  "name": "Firecrawl",
 375                  "badge": "paid",
 376                  "tag": "Cloud browser with remote execution",
 377                  "env_vars": [
 378                      {"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
 379                  ],
 380                  "browser_provider": "firecrawl",
 381                  "post_setup": "agent_browser",
 382              },
 383              {
 384                  "name": "Camofox",
 385                  "badge": "free · local",
 386                  "tag": "Anti-detection browser (Firefox/Camoufox)",
 387                  "env_vars": [
 388                      {"key": "CAMOFOX_URL", "prompt": "Camofox server URL", "default": "http://localhost:9377",
 389                       "url": "https://github.com/jo-inc/camofox-browser"},
 390                  ],
 391                  "browser_provider": "camofox",
 392                  "post_setup": "camofox",
 393              },
 394          ],
 395      },
 396      "homeassistant": {
 397          "name": "Smart Home",
 398          "icon": "🏠",
 399          "providers": [
 400              {
 401                  "name": "Home Assistant",
 402                  "tag": "REST API integration",
 403                  "env_vars": [
 404                      {"key": "HASS_TOKEN", "prompt": "Home Assistant Long-Lived Access Token"},
 405                      {"key": "HASS_URL", "prompt": "Home Assistant URL", "default": "http://homeassistant.local:8123"},
 406                  ],
 407              },
 408          ],
 409      },
 410      "spotify": {
 411          "name": "Spotify",
 412          "icon": "🎵",
 413          "providers": [
 414              {
 415                  "name": "Spotify Web API",
 416                  "tag": "PKCE OAuth — opens the setup wizard",
 417                  "env_vars": [],
 418                  "post_setup": "spotify",
 419              },
 420          ],
 421      },
 422      "rl": {
 423          "name": "RL Training",
 424          "icon": "🧪",
 425          "requires_python": (3, 11),
 426          "providers": [
 427              {
 428                  "name": "Tinker / Atropos",
 429                  "tag": "RL training platform",
 430                  "env_vars": [
 431                      {"key": "TINKER_API_KEY", "prompt": "Tinker API key", "url": "https://tinker-console.thinkingmachines.ai/keys"},
 432                      {"key": "WANDB_API_KEY", "prompt": "WandB API key", "url": "https://wandb.ai/authorize"},
 433                  ],
 434                  "post_setup": "rl_training",
 435              },
 436          ],
 437      },
 438      "langfuse": {
 439          "name": "Langfuse Observability",
 440          "icon": "📊",
 441          "providers": [
 442              {
 443                  "name": "Langfuse Cloud",
 444                  "tag": "Hosted Langfuse (cloud.langfuse.com)",
 445                  "env_vars": [
 446                      {"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)", "url": "https://cloud.langfuse.com"},
 447                      {"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)", "url": "https://cloud.langfuse.com"},
 448                  ],
 449                  "post_setup": "langfuse",
 450              },
 451              {
 452                  "name": "Langfuse Self-Hosted",
 453                  "tag": "Self-hosted Langfuse instance",
 454                  "env_vars": [
 455                      {"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)"},
 456                      {"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)"},
 457                      {"key": "HERMES_LANGFUSE_BASE_URL", "prompt": "Langfuse server URL (e.g. http://localhost:3000)", "default": "http://localhost:3000"},
 458                  ],
 459                  "post_setup": "langfuse",
 460              },
 461          ],
 462      },
 463  }
 464  
 465  # Simple env-var requirements for toolsets NOT in TOOL_CATEGORIES.
 466  # Used as a fallback for tools like vision/moa that just need an API key.
 467  TOOLSET_ENV_REQUIREMENTS = {
 468      "vision":     [("OPENROUTER_API_KEY",   "https://openrouter.ai/keys")],
 469      "moa":        [("OPENROUTER_API_KEY",   "https://openrouter.ai/keys")],
 470  }
 471  
 472  
 473  # ─── Post-Setup Hooks ─────────────────────────────────────────────────────────
 474  
 475  def _run_post_setup(post_setup_key: str):
 476      """Run post-setup hooks for tools that need extra installation steps."""
 477      import shutil
 478      if post_setup_key in ("agent_browser", "browserbase"):
 479          node_modules = PROJECT_ROOT / "node_modules" / "agent-browser"
 480          npm_bin = shutil.which("npm")
 481          npx_bin = shutil.which("npx")
 482          # Step 1: install the agent-browser npm package into node_modules/
 483          if not node_modules.exists() and npm_bin:
 484              _print_info("    Installing Node.js dependencies for browser tools...")
 485              import subprocess
 486              result = subprocess.run(
 487                  ["npm", "install", "--silent"],
 488                  capture_output=True, text=True, cwd=str(PROJECT_ROOT)
 489              )
 490              if result.returncode == 0:
 491                  _print_success("    Node.js dependencies installed")
 492              else:
 493                  from hermes_constants import display_hermes_home
 494                  _print_warning(f"    npm install failed - run manually: cd {display_hermes_home()}/hermes-agent && npm install")
 495                  if result.stderr:
 496                      _print_info(f"      {result.stderr.strip()[:200]}")
 497          elif not node_modules.exists():
 498              _print_warning("    Node.js not found - browser tools require: npm install (in hermes-agent directory)")
 499              return
 500  
 501          # Step 2: only the local browser provider actually needs Chromium on
 502          # disk. Cloud providers (Browserbase, Browser Use, Firecrawl) host
 503          # their own Chromium and don't need the local install.
 504          if post_setup_key != "agent_browser":
 505              return
 506  
 507          # Step 3: ensure the Chromium / headless-shell build agent-browser
 508          # drives is actually installed. Without it the CLI hangs on first
 509          # use until the command timeout fires. Skip inside Docker — the
 510          # image bakes Chromium in at build time, and runtime users usually
 511          # can't write to PLAYWRIGHT_BROWSERS_PATH anyway.
 512          try:
 513              # Import lazily so the tools_config UI doesn't pull in the full
 514              # browser_tool module at import time.
 515              from tools.browser_tool import (
 516                  _chromium_installed,
 517                  _running_in_docker,
 518              )
 519          except Exception as exc:  # pragma: no cover — defensive
 520              _print_warning(f"    Could not check Chromium status: {exc}")
 521              return
 522  
 523          if _chromium_installed():
 524              _print_success("    Chromium browser already installed")
 525              return
 526  
 527          if _running_in_docker():
 528              _print_warning(
 529                  "    Chromium is missing but you're running in Docker."
 530              )
 531              _print_info(
 532                  "    Pull the latest image to get the bundled Chromium:"
 533              )
 534              _print_info(
 535                  "      docker pull ghcr.io/nousresearch/hermes-agent:latest"
 536              )
 537              return
 538  
 539          if not npx_bin:
 540              _print_warning(
 541                  "    npx not found - install Chromium manually: npx agent-browser install --with-deps"
 542              )
 543              return
 544  
 545          _print_info("    Installing Chromium (~170MB one-time download)...")
 546          import subprocess
 547          # Prefer the bundled agent-browser install subcommand so the
 548          # version of Chromium matches the CLI. Fall back to npx shim on
 549          # setups where the local bin stub isn't present.
 550          local_ab = PROJECT_ROOT / "node_modules" / ".bin" / "agent-browser"
 551          if sys.platform == "win32":
 552              local_ab_win = local_ab.with_suffix(".cmd")
 553              if local_ab_win.exists():
 554                  local_ab = local_ab_win
 555          install_cmd = (
 556              [str(local_ab), "install", "--with-deps"]
 557              if local_ab.exists()
 558              else [npx_bin, "-y", "agent-browser", "install", "--with-deps"]
 559          )
 560          try:
 561              result = subprocess.run(
 562                  install_cmd,
 563                  capture_output=True, text=True, cwd=str(PROJECT_ROOT), timeout=600,
 564              )
 565              if result.returncode == 0:
 566                  _print_success("    Chromium installed")
 567                  # Invalidate the cached "missing" result so subsequent
 568                  # check_browser_requirements() calls see the new install.
 569                  import tools.browser_tool as _bt
 570                  _bt._cached_chromium_installed = None
 571              else:
 572                  _print_warning("    Chromium install failed:")
 573                  tail = (result.stderr or result.stdout or "").strip().splitlines()[-3:]
 574                  for line in tail:
 575                      _print_info(f"      {line[:200]}")
 576                  _print_info("    Run manually: npx agent-browser install --with-deps")
 577          except subprocess.TimeoutExpired:
 578              _print_warning("    Chromium install timed out (>10min)")
 579              _print_info("    Run manually: npx agent-browser install --with-deps")
 580          except Exception as exc:
 581              _print_warning(f"    Chromium install failed: {exc}")
 582              _print_info("    Run manually: npx agent-browser install --with-deps")
 583  
 584      elif post_setup_key == "camofox":
 585          camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser"
 586          if not camofox_dir.exists() and shutil.which("npm"):
 587              _print_info("    Installing Camofox browser server...")
 588              import subprocess
 589              result = subprocess.run(
 590                  ["npm", "install", "--silent"],
 591                  capture_output=True, text=True, cwd=str(PROJECT_ROOT)
 592              )
 593              if result.returncode == 0:
 594                  _print_success("    Camofox installed")
 595              else:
 596                  _print_warning("    npm install failed - run manually: npm install")
 597          if camofox_dir.exists():
 598              _print_info("    Start the Camofox server:")
 599              _print_info("      npx @askjo/camofox-browser")
 600              _print_info("    First run downloads the Camoufox engine (~300MB)")
 601              _print_info("    Or use Docker: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser")
 602          elif not shutil.which("npm"):
 603              _print_warning("    Node.js not found. Install Camofox via Docker:")
 604              _print_info("      docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser")
 605  
 606      elif post_setup_key == "kittentts":
 607          try:
 608              __import__("kittentts")
 609              _print_success("    kittentts is already installed")
 610              return
 611          except ImportError:
 612              pass
 613          import subprocess
 614          _print_info("    Installing kittentts (~25-80MB model, CPU-only)...")
 615          wheel_url = (
 616              "https://github.com/KittenML/KittenTTS/releases/download/"
 617              "0.8.1/kittentts-0.8.1-py3-none-any.whl"
 618          )
 619          try:
 620              result = subprocess.run(
 621                  [sys.executable, "-m", "pip", "install", "-U", wheel_url, "soundfile", "--quiet"],
 622                  capture_output=True, text=True, timeout=300,
 623              )
 624              if result.returncode == 0:
 625                  _print_success("    kittentts installed")
 626                  _print_info("    Voices: Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo")
 627                  _print_info("    Models: KittenML/kitten-tts-nano-0.8-int8 (25MB), micro (41MB), mini (80MB)")
 628              else:
 629                  _print_warning("    kittentts install failed:")
 630                  _print_info(f"      {result.stderr.strip()[:300]}")
 631                  _print_info(f"    Run manually: python -m pip install -U '{wheel_url}' soundfile")
 632          except subprocess.TimeoutExpired:
 633              _print_warning("    kittentts install timed out (>5min)")
 634              _print_info(f"    Run manually: python -m pip install -U '{wheel_url}' soundfile")
 635  
 636      elif post_setup_key == "piper":
 637          try:
 638              __import__("piper")
 639              _print_success("    piper-tts is already installed")
 640          except ImportError:
 641              import subprocess
 642              _print_info("    Installing piper-tts (~14MB wheel, voices downloaded on first use)...")
 643              try:
 644                  result = subprocess.run(
 645                      [sys.executable, "-m", "pip", "install", "-U", "piper-tts", "--quiet"],
 646                      capture_output=True, text=True, timeout=300,
 647                  )
 648                  if result.returncode == 0:
 649                      _print_success("    piper-tts installed")
 650                  else:
 651                      _print_warning("    piper-tts install failed:")
 652                      _print_info(f"      {result.stderr.strip()[:300]}")
 653                      _print_info("    Run manually: python -m pip install -U piper-tts")
 654                      return
 655              except subprocess.TimeoutExpired:
 656                  _print_warning("    piper-tts install timed out (>5min)")
 657                  _print_info("    Run manually: python -m pip install -U piper-tts")
 658                  return
 659          _print_info("    Default voice: en_US-lessac-medium (downloaded on first TTS call)")
 660          _print_info("    Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md")
 661          _print_info("    Switch voices by setting tts.piper.voice in ~/.hermes/config.yaml")
 662  
 663      elif post_setup_key == "spotify":
 664          # Run the full `hermes auth spotify` flow — if the user has no
 665          # client_id yet, this drops them into the interactive wizard
 666          # (opens the Spotify dashboard, prompts for client_id, persists
 667          # to ~/.hermes/.env), then continues straight into PKCE. If they
 668          # already have an app, it skips the wizard and just does OAuth.
 669          from types import SimpleNamespace
 670          try:
 671              from hermes_cli.auth import login_spotify_command
 672          except Exception as exc:
 673              _print_warning(f"    Could not load Spotify auth: {exc}")
 674              _print_info("    Run manually: hermes auth spotify")
 675              return
 676          _print_info("    Starting Spotify login...")
 677          try:
 678              login_spotify_command(SimpleNamespace(
 679                  client_id=None, redirect_uri=None, scope=None,
 680                  no_browser=False, timeout=None,
 681              ))
 682              _print_success("    Spotify authenticated")
 683          except SystemExit as exc:
 684              # User aborted the wizard, or OAuth failed — don't fail the
 685              # toolset enable; they can retry with `hermes auth spotify`.
 686              _print_warning(f"    Spotify login did not complete: {exc}")
 687              _print_info("    Run later: hermes auth spotify")
 688          except Exception as exc:
 689              _print_warning(f"    Spotify login failed: {exc}")
 690              _print_info("    Run manually: hermes auth spotify")
 691  
 692      elif post_setup_key == "rl_training":
 693          try:
 694              __import__("tinker_atropos")
 695          except ImportError:
 696              tinker_dir = PROJECT_ROOT / "tinker-atropos"
 697              if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists():
 698                  _print_info("    Installing tinker-atropos submodule...")
 699                  import subprocess
 700                  uv_bin = shutil.which("uv")
 701                  if uv_bin:
 702                      result = subprocess.run(
 703                          [uv_bin, "pip", "install", "--python", sys.executable, "-e", str(tinker_dir)],
 704                          capture_output=True, text=True
 705                      )
 706                  else:
 707                      result = subprocess.run(
 708                          [sys.executable, "-m", "pip", "install", "-e", str(tinker_dir)],
 709                          capture_output=True, text=True
 710                      )
 711                  if result.returncode == 0:
 712                      _print_success("    tinker-atropos installed")
 713                  else:
 714                      _print_warning("    tinker-atropos install failed - run manually:")
 715                      _print_info('      uv pip install -e "./tinker-atropos"')
 716              else:
 717                  _print_warning("    tinker-atropos submodule not found - run:")
 718                  _print_info("      git submodule update --init --recursive")
 719                  _print_info('      uv pip install -e "./tinker-atropos"')
 720  
 721      elif post_setup_key == "langfuse":
 722          # Install the langfuse SDK.
 723          try:
 724              __import__("langfuse")
 725              _print_success("    langfuse SDK already installed")
 726          except ImportError:
 727              import subprocess
 728              _print_info("    Installing langfuse SDK...")
 729              result = subprocess.run(
 730                  [sys.executable, "-m", "pip", "install", "langfuse", "--quiet"],
 731                  capture_output=True, text=True, timeout=120,
 732              )
 733              if result.returncode == 0:
 734                  _print_success("    langfuse SDK installed")
 735              else:
 736                  _print_warning("    langfuse SDK install failed — run manually: pip install langfuse")
 737          # Opt the bundled observability/langfuse plugin into plugins.enabled.
 738          # The plugin ships in the repo but doesn't load until the user enables
 739          # it (standalone plugins are opt-in).
 740          try:
 741              from hermes_cli.plugins_cmd import _get_enabled_set, _save_enabled_set
 742              enabled = _get_enabled_set()
 743              if "observability/langfuse" in enabled or "langfuse" in enabled:
 744                  _print_success("    Plugin observability/langfuse already enabled")
 745              else:
 746                  enabled.add("observability/langfuse")
 747                  _save_enabled_set(enabled)
 748                  _print_success("    Plugin observability/langfuse enabled")
 749          except Exception as exc:
 750              _print_warning(f"    Could not enable plugin automatically: {exc}")
 751              _print_info("    Run manually: hermes plugins enable observability/langfuse")
 752          _print_info("    Restart Hermes for tracing to take effect.")
 753          _print_info("    Verify: hermes plugins list")
 754  
 755  
 756  # ─── Platform / Toolset Helpers ───────────────────────────────────────────────
 757  
 758  def _get_enabled_platforms() -> List[str]:
 759      """Return platform keys that are configured (have tokens or are CLI)."""
 760      enabled = ["cli"]
 761      if get_env_value("TELEGRAM_BOT_TOKEN"):
 762          enabled.append("telegram")
 763      if get_env_value("DISCORD_BOT_TOKEN"):
 764          enabled.append("discord")
 765      if get_env_value("SLACK_BOT_TOKEN"):
 766          enabled.append("slack")
 767      if get_env_value("WHATSAPP_ENABLED"):
 768          enabled.append("whatsapp")
 769      if get_env_value("QQ_APP_ID"):
 770          enabled.append("qqbot")
 771      return enabled
 772  
 773  
 774  def _platform_toolset_summary(config: dict, platforms: Optional[List[str]] = None) -> Dict[str, Set[str]]:
 775      """Return a summary of enabled toolsets per platform.
 776  
 777      When ``platforms`` is None, this uses ``_get_enabled_platforms`` to
 778      auto-detect platforms. Tests can pass an explicit list to avoid relying
 779      on environment variables.
 780      """
 781      if platforms is None:
 782          platforms = _get_enabled_platforms()
 783  
 784      summary: Dict[str, Set[str]] = {}
 785      for pkey in platforms:
 786          summary[pkey] = _get_platform_tools(config, pkey)
 787      return summary
 788  
 789  
 790  def _parse_enabled_flag(value, default: bool = True) -> bool:
 791      """Parse bool-like config values used by tool/platform settings."""
 792      if value is None:
 793          return default
 794      if isinstance(value, bool):
 795          return value
 796      if isinstance(value, int):
 797          return value != 0
 798      if isinstance(value, str):
 799          lowered = value.strip().lower()
 800          if lowered in {"true", "1", "yes", "on"}:
 801              return True
 802          if lowered in {"false", "0", "no", "off"}:
 803              return False
 804      return default
 805  
 806  
 807  def _get_platform_tools(
 808      config: dict,
 809      platform: str,
 810      *,
 811      include_default_mcp_servers: bool = True,
 812  ) -> Set[str]:
 813      """Resolve which individual toolset names are enabled for a platform."""
 814      from toolsets import resolve_toolset, TOOLSETS
 815  
 816      platform_toolsets = config.get("platform_toolsets") or {}
 817      toolset_names = platform_toolsets.get(platform)
 818  
 819      if toolset_names is None or not isinstance(toolset_names, list):
 820          plat_info = PLATFORMS.get(platform)
 821          if plat_info:
 822              default_ts = plat_info["default_toolset"]
 823          else:
 824              # Plugin platform — derive toolset name from platform key
 825              default_ts = f"hermes-{platform}"
 826          toolset_names = [default_ts]
 827  
 828      # YAML may parse bare numeric names (e.g. ``12306:``) as int.
 829      # Normalise to str so downstream sorted() never mixes types.
 830      toolset_names = [str(ts) for ts in toolset_names]
 831  
 832      configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
 833      plugin_ts_keys = _get_plugin_toolset_keys()
 834      platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()}
 835  
 836      # If the saved list contains any configurable keys directly, the user
 837      # has explicitly configured this platform — use direct membership.
 838      # This avoids the subset-inference bug where composite toolsets like
 839      # "hermes-cli" (which include all _HERMES_CORE_TOOLS) cause disabled
 840      # toolsets to re-appear as enabled.
 841      has_explicit_config = any(ts in configurable_keys for ts in toolset_names)
 842  
 843      if has_explicit_config:
 844          enabled_toolsets = {
 845              ts for ts in toolset_names
 846              if ts in configurable_keys and _toolset_allowed_for_platform(ts, platform)
 847          }
 848      else:
 849          # No explicit config — fall back to resolving composite toolset names
 850          # (e.g. "hermes-cli") to individual tool names and reverse-mapping.
 851          all_tool_names = set()
 852          for ts_name in toolset_names:
 853              all_tool_names.update(resolve_toolset(ts_name))
 854  
 855          enabled_toolsets = set()
 856          for ts_key, _, _ in CONFIGURABLE_TOOLSETS:
 857              if not _toolset_allowed_for_platform(ts_key, platform):
 858                  continue
 859              ts_tools = set(resolve_toolset(ts_key))
 860              if ts_tools and ts_tools.issubset(all_tool_names):
 861                  enabled_toolsets.add(ts_key)
 862  
 863          default_off = set(_DEFAULT_OFF_TOOLSETS)
 864          # Legacy safety: if the platform's own name matches a default-off
 865          # toolset (e.g. `homeassistant` platform + `homeassistant` toolset),
 866          # keep that toolset enabled on first install.  Skip this dodge for
 867          # platform-restricted toolsets — those are always opt-in even on
 868          # their own platform (e.g. `discord` + `discord` should stay OFF).
 869          if platform in default_off and platform not in _TOOLSET_PLATFORM_RESTRICTIONS:
 870              default_off.remove(platform)
 871          # Home Assistant is already runtime-gated by its check_fn (requires
 872          # HASS_TOKEN to register any tools). When a user has configured
 873          # HASS_TOKEN, they've explicitly opted in — don't also strip it via
 874          # _DEFAULT_OFF_TOOLSETS, which would silently drop HA from platforms
 875          # (e.g. cron) that run through _get_platform_tools without an
 876          # explicit saved toolset list. Without this, Norbert's HA cron jobs
 877          # regressed after #14798 made cron honor per-platform tool config.
 878          if "homeassistant" in default_off and os.getenv("HASS_TOKEN"):
 879              default_off.remove("homeassistant")
 880          enabled_toolsets -= default_off
 881  
 882      # Recover non-configurable platform toolsets (e.g. discord, feishu_doc,
 883      # feishu_drive).  These are part of the platform's default composite but
 884      # absent from CONFIGURABLE_TOOLSETS, so they can't appear in the TUI
 885      # checklist or in a user-saved config.  Must run in BOTH branches —
 886      # otherwise saving via `hermes tools` (which flips has_explicit_config
 887      # to True) silently drops them.
 888      _plat_info = PLATFORMS.get(platform)
 889      _default_ts = _plat_info["default_toolset"] if _plat_info else f"hermes-{platform}"
 890      platform_tool_universe = set(resolve_toolset(_default_ts))
 891      configurable_tool_universe = set()
 892      for ck in configurable_keys:
 893          configurable_tool_universe.update(resolve_toolset(ck))
 894      claimed = set()
 895      for ts_key in enabled_toolsets:
 896          claimed.update(resolve_toolset(ts_key))
 897      skip = configurable_keys | plugin_ts_keys | platform_default_keys
 898      skip |= {k for k in TOOLSETS if k.startswith("hermes-")}
 899      skip |= set(_DEFAULT_OFF_TOOLSETS) - {platform}
 900      for ts_key, ts_def in TOOLSETS.items():
 901          if ts_key in skip:
 902              continue
 903          if ts_def.get("includes"):
 904              continue
 905          ts_tools = set(resolve_toolset(ts_key))
 906          if not ts_tools or not ts_tools.issubset(platform_tool_universe):
 907              continue
 908          if ts_tools.issubset(configurable_tool_universe):
 909              continue
 910          if not ts_tools.issubset(claimed):
 911              enabled_toolsets.add(ts_key)
 912              claimed.update(ts_tools)
 913  
 914      # Plugin toolsets: enabled by default unless explicitly disabled, or
 915      # unless the toolset is in _DEFAULT_OFF_TOOLSETS (e.g. spotify —
 916      # shipped as a bundled plugin but user must opt in via `hermes tools`
 917      # so we don't ship 7 Spotify tool schemas to users who don't use it).
 918      # A plugin toolset is "known" for a platform once `hermes tools`
 919      # has been saved for that platform (tracked via known_plugin_toolsets).
 920      # Unknown plugins default to enabled; known-but-absent = disabled.
 921      if plugin_ts_keys:
 922          known_map = config.get("known_plugin_toolsets", {})
 923          known_for_platform = set(known_map.get(platform, []))
 924          for pts in plugin_ts_keys:
 925              if pts in toolset_names:
 926                  # Explicitly listed in config — enabled
 927                  enabled_toolsets.add(pts)
 928              elif pts in _DEFAULT_OFF_TOOLSETS:
 929                  # Opt-in plugin toolset — stay off until user picks it
 930                  continue
 931              elif pts not in known_for_platform:
 932                  # New plugin not yet seen by hermes tools — default enabled
 933                  enabled_toolsets.add(pts)
 934              # else: known but not in config = user disabled it
 935  
 936      # Preserve any explicit non-configurable toolset entries (for example,
 937      # custom toolsets or MCP server names saved in platform_toolsets).
 938      explicit_passthrough = {
 939          ts
 940          for ts in toolset_names
 941          if ts not in configurable_keys
 942          and ts not in plugin_ts_keys
 943          and ts not in platform_default_keys
 944      }
 945  
 946      # MCP servers are expected to be available on all platforms by default.
 947      # If the platform explicitly lists one or more MCP server names, treat that
 948      # as an allowlist. Otherwise include every globally enabled MCP server.
 949      # Special sentinel: "no_mcp" in the toolset list disables all MCP servers.
 950      mcp_servers = config.get("mcp_servers") or {}
 951      enabled_mcp_servers = {
 952          str(name)
 953          for name, server_cfg in mcp_servers.items()
 954          if isinstance(server_cfg, dict)
 955          and _parse_enabled_flag(server_cfg.get("enabled", True), default=True)
 956      }
 957      # Allow "no_mcp" sentinel to opt out of all MCP servers for this platform
 958      if "no_mcp" in toolset_names:
 959          explicit_mcp_servers = set()
 960          enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers - {"no_mcp"})
 961      else:
 962          explicit_mcp_servers = explicit_passthrough & enabled_mcp_servers
 963          enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers)
 964      if include_default_mcp_servers:
 965          if explicit_mcp_servers or "no_mcp" in toolset_names:
 966              enabled_toolsets.update(explicit_mcp_servers)
 967          else:
 968              enabled_toolsets.update(enabled_mcp_servers)
 969      else:
 970          enabled_toolsets.update(explicit_mcp_servers)
 971  
 972      # Honor agent.disabled_toolsets from config.yaml — allows users to
 973      # globally suppress specific toolsets (e.g. "memory") across all
 974      # platforms without per-platform toolset configuration.  This runs
 975      # last so it overrides everything above.
 976      agent_cfg = config.get("agent") or {}
 977      disabled_toolsets = agent_cfg.get("disabled_toolsets") or []
 978      if disabled_toolsets:
 979          disabled_set = {str(ts) for ts in disabled_toolsets}
 980          enabled_toolsets -= disabled_set
 981  
 982      return enabled_toolsets
 983  
 984  
 985  def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[str]):
 986      """Save the selected toolset keys for a platform to config.
 987  
 988      Preserves any non-configurable toolset entries (like MCP server names)
 989      that were already in the config for this platform.
 990      """
 991      config.setdefault("platform_toolsets", {})
 992  
 993      # Drop platform-scoped toolsets that don't apply here.  Prevents the
 994      # "Configure all platforms" checklist (or a hand-edited config.yaml)
 995      # from turning on, say, the `discord` toolset for Telegram.
 996      enabled_toolset_keys = {
 997          ts for ts in enabled_toolset_keys
 998          if _toolset_allowed_for_platform(ts, platform)
 999      }
1000  
1001      # Get the set of all configurable toolset keys (built-in + plugin)
1002      configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
1003      plugin_keys = _get_plugin_toolset_keys()
1004      configurable_keys |= plugin_keys
1005  
1006      # Also exclude platform default toolsets (hermes-cli, hermes-telegram, etc.)
1007      # These are "super" toolsets that resolve to ALL tools, so preserving them
1008      # would silently override the user's unchecked selections on the next read.
1009      platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()}
1010  
1011      # Get existing toolsets for this platform
1012      existing_toolsets = cfg_get(config, "platform_toolsets", platform, default=[])
1013      if not isinstance(existing_toolsets, list):
1014          existing_toolsets = []
1015      existing_toolsets = [str(ts) for ts in existing_toolsets]
1016  
1017      # Preserve any entries that are NOT configurable toolsets and NOT platform
1018      # defaults (i.e. only MCP server names should be preserved)
1019      preserved_entries = {
1020          entry for entry in existing_toolsets
1021          if entry not in configurable_keys and entry not in platform_default_keys
1022      }
1023      # Opening `hermes tools` is the user's opt-in to reconfigure tools, so treat
1024      # saving from the picker as consent to clear the "no_mcp" sentinel. The
1025      # picker has no checkbox for no_mcp, so without this users who once set it
1026      # by hand could never re-enable MCP servers through the UI.
1027      preserved_entries.discard("no_mcp")
1028  
1029      # Merge preserved entries with new enabled toolsets
1030      config["platform_toolsets"][platform] = sorted(enabled_toolset_keys | preserved_entries)
1031  
1032      # Track which plugin toolsets are "known" for this platform so we can
1033      # distinguish "new plugin, default enabled" from "user disabled it".
1034      if plugin_keys:
1035          config.setdefault("known_plugin_toolsets", {})
1036          config["known_plugin_toolsets"][platform] = sorted(plugin_keys)
1037  
1038      save_config(config)
1039  
1040  
1041  def _toolset_has_keys(ts_key: str, config: dict = None) -> bool:
1042      """Check if a toolset's required API keys are configured."""
1043      if config is None:
1044          config = load_config()
1045  
1046      if ts_key == "vision":
1047          try:
1048              from agent.auxiliary_client import resolve_vision_provider_client
1049  
1050              _provider, client, _model = resolve_vision_provider_client()
1051              return client is not None
1052          except Exception:
1053              return False
1054  
1055      if ts_key in {"web", "image_gen", "tts", "browser"}:
1056          features = get_nous_subscription_features(config)
1057          feature = features.features.get(ts_key)
1058          if feature and (feature.available or feature.managed_by_nous):
1059              return True
1060  
1061      # Check TOOL_CATEGORIES first (provider-aware)
1062      cat = TOOL_CATEGORIES.get(ts_key)
1063      if cat:
1064          for provider in _visible_providers(cat, config):
1065              env_vars = provider.get("env_vars", [])
1066              if not env_vars:
1067                  return True  # No-key provider (e.g. Local Browser, Edge TTS)
1068              if all(get_env_value(e["key"]) for e in env_vars):
1069                  return True
1070          return False
1071  
1072      # Fallback to simple requirements
1073      requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, [])
1074      if not requirements:
1075          return True
1076      return all(get_env_value(var) for var, _ in requirements)
1077  
1078  
1079  # ─── Menu Helpers ─────────────────────────────────────────────────────────────
1080  
1081  def _prompt_choice(question: str, choices: list, default: int = 0) -> int:
1082      """Single-select menu (arrow keys). Delegates to curses_radiolist."""
1083      from hermes_cli.curses_ui import curses_radiolist
1084      return curses_radiolist(question, choices, selected=default, cancel_returns=default)
1085  
1086  
1087  # ─── Token Estimation ────────────────────────────────────────────────────────
1088  
1089  # Module-level cache so discovery + tokenization runs at most once per process.
1090  _tool_token_cache: Optional[Dict[str, int]] = None
1091  
1092  
1093  def _estimate_tool_tokens() -> Dict[str, int]:
1094      """Return estimated token counts per individual tool name.
1095  
1096      Uses tiktoken (cl100k_base) to count tokens in the JSON-serialised
1097      OpenAI-format tool schema.  Triggers tool discovery on first call,
1098      then caches the result for the rest of the process.
1099  
1100      Returns an empty dict when tiktoken or the registry is unavailable.
1101      """
1102      global _tool_token_cache
1103      if _tool_token_cache is not None:
1104          return _tool_token_cache
1105  
1106      try:
1107          import tiktoken
1108          enc = tiktoken.get_encoding("cl100k_base")
1109      except Exception:
1110          logger.debug("tiktoken unavailable; skipping tool token estimation")
1111          _tool_token_cache = {}
1112          return _tool_token_cache
1113  
1114      try:
1115          # Trigger full tool discovery (imports all tool modules).
1116          import model_tools  # noqa: F401
1117          from tools.registry import registry
1118      except Exception:
1119          logger.debug("Tool registry unavailable; skipping token estimation")
1120          _tool_token_cache = {}
1121          return _tool_token_cache
1122  
1123      counts: Dict[str, int] = {}
1124      for name in registry.get_all_tool_names():
1125          schema = registry.get_schema(name)
1126          if schema:
1127              # Mirror what gets sent to the API:
1128              # {"type": "function", "function": <schema>}
1129              text = _json.dumps({"type": "function", "function": schema})
1130              counts[name] = len(enc.encode(text))
1131      _tool_token_cache = counts
1132      return _tool_token_cache
1133  
1134  
1135  def _prompt_toolset_checklist(platform_label: str, enabled: Set[str], platform: str = "cli") -> Set[str]:
1136      """Multi-select checklist of toolsets. Returns set of selected toolset keys."""
1137      from hermes_cli.curses_ui import curses_checklist
1138      from toolsets import resolve_toolset
1139  
1140      # Pre-compute per-tool token counts (cached after first call).
1141      tool_tokens = _estimate_tool_tokens()
1142  
1143      effective_all = _get_effective_configurable_toolsets()
1144      # Drop platform-scoped toolsets that don't apply to this platform.
1145      effective = [
1146          (k, l, d) for (k, l, d) in effective_all
1147          if _toolset_allowed_for_platform(k, platform)
1148      ]
1149  
1150      labels = []
1151      for ts_key, ts_label, ts_desc in effective:
1152          suffix = ""
1153          if not _toolset_has_keys(ts_key) and (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
1154              suffix = "  [no API key]"
1155          labels.append(f"{ts_label}  ({ts_desc}){suffix}")
1156  
1157      pre_selected = {
1158          i for i, (ts_key, _, _) in enumerate(effective)
1159          if ts_key in enabled
1160      }
1161  
1162      # Build a live status function that shows deduplicated total token cost.
1163      status_fn = None
1164      if tool_tokens:
1165          ts_keys = [ts_key for ts_key, _, _ in effective]
1166  
1167          def status_fn(chosen: set) -> str:
1168              # Collect unique tool names across all selected toolsets
1169              all_tools: set = set()
1170              for idx in chosen:
1171                  all_tools.update(resolve_toolset(ts_keys[idx]))
1172              total = sum(tool_tokens.get(name, 0) for name in all_tools)
1173              if total >= 1000:
1174                  return f"Est. tool context: ~{total / 1000:.1f}k tokens"
1175              return f"Est. tool context: ~{total} tokens"
1176  
1177      chosen = curses_checklist(
1178          f"Tools for {platform_label}",
1179          labels,
1180          pre_selected,
1181          cancel_returns=pre_selected,
1182          status_fn=status_fn,
1183      )
1184      return {effective[i][0] for i in chosen}
1185  
1186  
1187  # ─── Provider-Aware Configuration ────────────────────────────────────────────
1188  
1189  def _configure_toolset(ts_key: str, config: dict):
1190      """Configure a toolset - provider selection + API keys.
1191      
1192      Uses TOOL_CATEGORIES for provider-aware config, falls back to simple
1193      env var prompts for toolsets not in TOOL_CATEGORIES.
1194      """
1195      cat = TOOL_CATEGORIES.get(ts_key)
1196  
1197      if cat:
1198          _configure_tool_category(ts_key, cat, config)
1199      else:
1200          # Simple fallback for vision, moa, etc.
1201          _configure_simple_requirements(ts_key)
1202  
1203  
1204  def _plugin_image_gen_providers() -> list[dict]:
1205      """Build picker-row dicts from plugin-registered image gen providers.
1206  
1207      Each returned dict looks like a regular ``TOOL_CATEGORIES`` provider
1208      row but carries an ``image_gen_plugin_name`` marker so downstream
1209      code (config writing, model picker) knows to route through the
1210      plugin registry instead of the in-tree FAL backend.
1211  
1212      FAL is skipped — it's already exposed by the hardcoded
1213      ``TOOL_CATEGORIES["image_gen"]`` entries. When FAL gets ported to
1214      a plugin in a follow-up PR, the hardcoded entries go away and this
1215      function surfaces it alongside OpenAI automatically.
1216      """
1217      try:
1218          from agent.image_gen_registry import list_providers
1219          from hermes_cli.plugins import _ensure_plugins_discovered
1220  
1221          _ensure_plugins_discovered()
1222          providers = list_providers()
1223      except Exception:
1224          return []
1225  
1226      rows: list[dict] = []
1227      for provider in providers:
1228          if getattr(provider, "name", None) == "fal":
1229              # FAL has its own hardcoded rows today.
1230              continue
1231          try:
1232              schema = provider.get_setup_schema()
1233          except Exception:
1234              continue
1235          if not isinstance(schema, dict):
1236              continue
1237          rows.append(
1238              {
1239                  "name": schema.get("name", provider.display_name),
1240                  "badge": schema.get("badge", ""),
1241                  "tag": schema.get("tag", ""),
1242                  "env_vars": schema.get("env_vars", []),
1243                  "image_gen_plugin_name": provider.name,
1244              }
1245          )
1246      return rows
1247  
1248  
1249  def _visible_providers(cat: dict, config: dict) -> list[dict]:
1250      """Return provider entries visible for the current auth/config state."""
1251      features = get_nous_subscription_features(config)
1252      visible = []
1253      for provider in cat.get("providers", []):
1254          if provider.get("managed_nous_feature") and not managed_nous_tools_enabled():
1255              continue
1256          if provider.get("requires_nous_auth") and not features.nous_auth_present:
1257              continue
1258          visible.append(provider)
1259  
1260      # Inject plugin-registered image_gen backends (OpenAI today, more
1261      # later) so the picker lists them alongside FAL / Nous Subscription.
1262      if cat.get("name") == "Image Generation":
1263          visible.extend(_plugin_image_gen_providers())
1264  
1265      return visible
1266  
1267  
1268  def _toolset_needs_configuration_prompt(ts_key: str, config: dict) -> bool:
1269      """Return True when enabling this toolset should open provider setup."""
1270      cat = TOOL_CATEGORIES.get(ts_key)
1271      if not cat:
1272          return not _toolset_has_keys(ts_key, config)
1273  
1274      if ts_key == "tts":
1275          tts_cfg = config.get("tts", {})
1276          return not isinstance(tts_cfg, dict) or "provider" not in tts_cfg
1277      if ts_key == "web":
1278          web_cfg = config.get("web", {})
1279          return not isinstance(web_cfg, dict) or "backend" not in web_cfg
1280      if ts_key == "browser":
1281          browser_cfg = config.get("browser", {})
1282          return not isinstance(browser_cfg, dict) or "cloud_provider" not in browser_cfg
1283      if ts_key == "image_gen":
1284          # Satisfied when the in-tree FAL backend is configured OR any
1285          # plugin-registered image gen provider is available.
1286          if fal_key_is_configured():
1287              return False
1288          try:
1289              from agent.image_gen_registry import list_providers
1290              from hermes_cli.plugins import _ensure_plugins_discovered
1291  
1292              _ensure_plugins_discovered()
1293              for provider in list_providers():
1294                  try:
1295                      if provider.is_available():
1296                          return False
1297                  except Exception:
1298                      continue
1299          except Exception:
1300              pass
1301          return True
1302  
1303      return not _toolset_has_keys(ts_key, config)
1304  
1305  
1306  def _configure_tool_category(ts_key: str, cat: dict, config: dict):
1307      """Configure a tool category with provider selection."""
1308      icon = cat.get("icon", "")
1309      name = cat["name"]
1310      providers = _visible_providers(cat, config)
1311  
1312      # Check Python version requirement
1313      if cat.get("requires_python"):
1314          req = cat["requires_python"]
1315          if sys.version_info < req:
1316              print()
1317              _print_error(f"  {name} requires Python {req[0]}.{req[1]}+ (current: {sys.version_info.major}.{sys.version_info.minor})")
1318              _print_info("  Upgrade Python and reinstall to enable this tool.")
1319              return
1320  
1321      if len(providers) == 1:
1322          # Single provider - configure directly
1323          provider = providers[0]
1324          print()
1325          print(color(f"  --- {icon} {name} ({provider['name']}) ---", Colors.CYAN))
1326          if provider.get("tag"):
1327              _print_info(f"  {provider['tag']}")
1328          # For single-provider tools, show a note if available
1329          if cat.get("setup_note"):
1330              _print_info(f"  {cat['setup_note']}")
1331          _configure_provider(provider, config)
1332      else:
1333          # Multiple providers - let user choose
1334          print()
1335          # Use custom title if provided (e.g. "Select Search Provider")
1336          title = cat.get("setup_title", "Choose a provider")
1337          print(color(f"  --- {icon} {name} - {title} ---", Colors.CYAN))
1338          if cat.get("setup_note"):
1339              _print_info(f"  {cat['setup_note']}")
1340          print()
1341  
1342          # Plain text labels only (no ANSI codes in menu items)
1343          provider_choices = []
1344          for p in providers:
1345              badge = f" [{p['badge']}]" if p.get("badge") else ""
1346              tag = f" — {p['tag']}" if p.get("tag") else ""
1347              configured = ""
1348              env_vars = p.get("env_vars", [])
1349              if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
1350                  if _is_provider_active(p, config):
1351                      configured = " [active]"
1352                  elif not env_vars:
1353                      configured = ""
1354                  else:
1355                      configured = " [configured]"
1356              provider_choices.append(f"{p['name']}{badge}{tag}{configured}")
1357  
1358          # Add skip option
1359          provider_choices.append("Skip — keep defaults / configure later")
1360  
1361          # Detect current provider as default
1362          default_idx = _detect_active_provider_index(providers, config)
1363  
1364          provider_idx = _prompt_choice(f"  {title}:", provider_choices, default_idx)
1365  
1366          # Skip selected
1367          if provider_idx >= len(providers):
1368              _print_info(f"  Skipped {name}")
1369              return
1370  
1371          _configure_provider(providers[provider_idx], config)
1372  
1373  
1374  def _is_provider_active(provider: dict, config: dict) -> bool:
1375      """Check if a provider entry matches the currently active config."""
1376      plugin_name = provider.get("image_gen_plugin_name")
1377      if plugin_name:
1378          image_cfg = config.get("image_gen", {})
1379          return isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name
1380  
1381      managed_feature = provider.get("managed_nous_feature")
1382      if managed_feature:
1383          features = get_nous_subscription_features(config)
1384          feature = features.features.get(managed_feature)
1385          if feature is None:
1386              return False
1387          if managed_feature == "image_gen":
1388              image_cfg = config.get("image_gen", {})
1389              if isinstance(image_cfg, dict):
1390                  configured_provider = image_cfg.get("provider")
1391                  if configured_provider not in (None, "", "fal"):
1392                      return False
1393                  if image_cfg.get("use_gateway") is not None and not is_truthy_value(image_cfg.get("use_gateway"), default=False):
1394                      return False
1395              return feature.managed_by_nous
1396          if provider.get("tts_provider"):
1397              return (
1398                  feature.managed_by_nous
1399                  and cfg_get(config, "tts", "provider") == provider["tts_provider"]
1400              )
1401          if "browser_provider" in provider:
1402              current = cfg_get(config, "browser", "cloud_provider")
1403              return feature.managed_by_nous and provider["browser_provider"] == current
1404          if provider.get("web_backend"):
1405              current = cfg_get(config, "web", "backend")
1406              return feature.managed_by_nous and current == provider["web_backend"]
1407          return feature.managed_by_nous
1408  
1409      if provider.get("tts_provider"):
1410          return cfg_get(config, "tts", "provider") == provider["tts_provider"]
1411      if "browser_provider" in provider:
1412          current = cfg_get(config, "browser", "cloud_provider")
1413          return provider["browser_provider"] == current
1414      if provider.get("web_backend"):
1415          current = cfg_get(config, "web", "backend")
1416          return current == provider["web_backend"]
1417      if provider.get("imagegen_backend"):
1418          image_cfg = config.get("image_gen", {})
1419          if not isinstance(image_cfg, dict):
1420              return False
1421          configured_provider = image_cfg.get("provider")
1422          return (
1423              provider["imagegen_backend"] == "fal"
1424              and configured_provider in (None, "", "fal")
1425              and not is_truthy_value(image_cfg.get("use_gateway"), default=False)
1426          )
1427      return False
1428  
1429  
1430  def _detect_active_provider_index(providers: list, config: dict) -> int:
1431      """Return the index of the currently active provider, or 0."""
1432      for i, p in enumerate(providers):
1433          if _is_provider_active(p, config):
1434              return i
1435          # Fallback: env vars present → likely configured
1436          env_vars = p.get("env_vars", [])
1437          if env_vars and all(get_env_value(v["key"]) for v in env_vars):
1438              return i
1439      return 0
1440  
1441  
1442  # ─── Image Generation Model Pickers ───────────────────────────────────────────
1443  #
1444  # IMAGEGEN_BACKENDS is a per-backend catalog. Each entry exposes:
1445  #   - config_key:        top-level config.yaml key for this backend's settings
1446  #   - model_catalog_fn:  returns an OrderedDict-like {model_id: metadata}
1447  #   - default_model:     fallback when nothing is configured
1448  #
1449  # This prepares for future imagegen backends (Replicate, Stability, etc.):
1450  # each new backend registers its own entry; the FAL provider entry in
1451  # TOOL_CATEGORIES tags itself with `imagegen_backend: "fal"` to select the
1452  # right catalog at picker time.
1453  
1454  
1455  def _fal_model_catalog():
1456      """Lazy-load the FAL model catalog from the tool module."""
1457      from tools.image_generation_tool import FAL_MODELS, DEFAULT_MODEL
1458      return FAL_MODELS, DEFAULT_MODEL
1459  
1460  
1461  IMAGEGEN_BACKENDS = {
1462      "fal": {
1463          "display": "FAL.ai",
1464          "config_key": "image_gen",
1465          "catalog_fn": _fal_model_catalog,
1466      },
1467  }
1468  
1469  
1470  def _format_imagegen_model_row(model_id: str, meta: dict, widths: dict) -> str:
1471      """Format a single picker row with column-aligned speed / strengths / price."""
1472      return (
1473          f"{model_id:<{widths['model']}}  "
1474          f"{meta.get('speed', ''):<{widths['speed']}}  "
1475          f"{meta.get('strengths', ''):<{widths['strengths']}}  "
1476          f"{meta.get('price', '')}"
1477      )
1478  
1479  
1480  def _configure_imagegen_model(backend_name: str, config: dict) -> None:
1481      """Prompt the user to pick a model for the given imagegen backend.
1482  
1483      Writes selection to ``config[backend_config_key]["model"]``. Safe to
1484      call even when stdin is not a TTY — curses_radiolist falls back to
1485      keeping the current selection.
1486      """
1487      backend = IMAGEGEN_BACKENDS.get(backend_name)
1488      if not backend:
1489          return
1490  
1491      catalog, default_model = backend["catalog_fn"]()
1492      if not catalog:
1493          return
1494  
1495      cfg_key = backend["config_key"]
1496      cur_cfg = config.setdefault(cfg_key, {})
1497      if not isinstance(cur_cfg, dict):
1498          cur_cfg = {}
1499          config[cfg_key] = cur_cfg
1500      current_model = cur_cfg.get("model") or default_model
1501      if current_model not in catalog:
1502          current_model = default_model
1503  
1504      model_ids = list(catalog.keys())
1505      # Put current model at the top so the cursor lands on it by default.
1506      ordered = [current_model] + [m for m in model_ids if m != current_model]
1507  
1508      # Column widths
1509      widths = {
1510          "model": max(len(m) for m in model_ids),
1511          "speed": max((len(catalog[m].get("speed", "")) for m in model_ids), default=6),
1512          "strengths": max((len(catalog[m].get("strengths", "")) for m in model_ids), default=0),
1513      }
1514  
1515      print()
1516      header = (
1517          f"  {'Model':<{widths['model']}}  "
1518          f"{'Speed':<{widths['speed']}}  "
1519          f"{'Strengths':<{widths['strengths']}}  "
1520          f"Price"
1521      )
1522      print(color(header, Colors.CYAN))
1523  
1524      rows = []
1525      for mid in ordered:
1526          row = _format_imagegen_model_row(mid, catalog[mid], widths)
1527          if mid == current_model:
1528              row += "  ← currently in use"
1529          rows.append(row)
1530  
1531      idx = _prompt_choice(
1532          f"  Choose {backend['display']} model:",
1533          rows,
1534          default=0,
1535      )
1536  
1537      chosen = ordered[idx]
1538      cur_cfg["model"] = chosen
1539      _print_success(f"  Model set to: {chosen}")
1540  
1541  
1542  def _plugin_image_gen_catalog(plugin_name: str):
1543      """Return ``(catalog_dict, default_model_id)`` for a plugin provider.
1544  
1545      ``catalog_dict`` is shaped like the legacy ``FAL_MODELS`` table —
1546      ``{model_id: {"display", "speed", "strengths", "price", ...}}`` —
1547      so the existing picker code paths work without change. Returns
1548      ``({}, None)`` if the provider isn't registered or has no models.
1549      """
1550      try:
1551          from agent.image_gen_registry import get_provider
1552          from hermes_cli.plugins import _ensure_plugins_discovered
1553  
1554          _ensure_plugins_discovered()
1555          provider = get_provider(plugin_name)
1556      except Exception:
1557          return {}, None
1558      if provider is None:
1559          return {}, None
1560      try:
1561          models = provider.list_models() or []
1562          default = provider.default_model()
1563      except Exception:
1564          return {}, None
1565      catalog = {m["id"]: m for m in models if isinstance(m, dict) and "id" in m}
1566      return catalog, default
1567  
1568  
1569  def _configure_imagegen_model_for_plugin(plugin_name: str, config: dict) -> None:
1570      """Prompt the user to pick a model for a plugin-registered backend.
1571  
1572      Writes selection to ``image_gen.model``. Mirrors
1573      :func:`_configure_imagegen_model` but sources its catalog from the
1574      plugin registry instead of :data:`IMAGEGEN_BACKENDS`.
1575      """
1576      catalog, default_model = _plugin_image_gen_catalog(plugin_name)
1577      if not catalog:
1578          return
1579  
1580      cur_cfg = config.setdefault("image_gen", {})
1581      if not isinstance(cur_cfg, dict):
1582          cur_cfg = {}
1583          config["image_gen"] = cur_cfg
1584      current_model = cur_cfg.get("model") or default_model
1585      if current_model not in catalog:
1586          current_model = default_model
1587  
1588      model_ids = list(catalog.keys())
1589      ordered = [current_model] + [m for m in model_ids if m != current_model]
1590  
1591      widths = {
1592          "model": max(len(m) for m in model_ids),
1593          "speed": max((len(catalog[m].get("speed", "")) for m in model_ids), default=6),
1594          "strengths": max((len(catalog[m].get("strengths", "")) for m in model_ids), default=0),
1595      }
1596  
1597      print()
1598      header = (
1599          f"  {'Model':<{widths['model']}}  "
1600          f"{'Speed':<{widths['speed']}}  "
1601          f"{'Strengths':<{widths['strengths']}}  "
1602          f"Price"
1603      )
1604      print(color(header, Colors.CYAN))
1605  
1606      rows = []
1607      for mid in ordered:
1608          row = _format_imagegen_model_row(mid, catalog[mid], widths)
1609          if mid == current_model:
1610              row += "  ← currently in use"
1611          rows.append(row)
1612  
1613      idx = _prompt_choice(
1614          f"  Choose {plugin_name} model:",
1615          rows,
1616          default=0,
1617      )
1618  
1619      chosen = ordered[idx]
1620      cur_cfg["model"] = chosen
1621      _print_success(f"  Model set to: {chosen}")
1622  
1623  
1624  def _select_plugin_image_gen_provider(plugin_name: str, config: dict) -> None:
1625      """Persist a plugin-backed image generation provider selection."""
1626      img_cfg = config.setdefault("image_gen", {})
1627      if not isinstance(img_cfg, dict):
1628          img_cfg = {}
1629          config["image_gen"] = img_cfg
1630      img_cfg["provider"] = plugin_name
1631      img_cfg["use_gateway"] = False
1632      _print_success(f"  image_gen.provider set to: {plugin_name}")
1633      _configure_imagegen_model_for_plugin(plugin_name, config)
1634  
1635  
1636  def _configure_provider(provider: dict, config: dict):
1637      """Configure a single provider - prompt for API keys and set config."""
1638      env_vars = provider.get("env_vars", [])
1639      managed_feature = provider.get("managed_nous_feature")
1640  
1641      if provider.get("requires_nous_auth"):
1642          features = get_nous_subscription_features(config)
1643          if not features.nous_auth_present:
1644              _print_warning("  Nous Subscription is only available after logging into Nous Portal.")
1645              return
1646  
1647      # Set TTS provider in config if applicable
1648      if provider.get("tts_provider"):
1649          tts_cfg = config.setdefault("tts", {})
1650          tts_cfg["provider"] = provider["tts_provider"]
1651          tts_cfg["use_gateway"] = bool(managed_feature)
1652  
1653      # Set browser cloud provider in config if applicable
1654      if "browser_provider" in provider:
1655          bp = provider["browser_provider"]
1656          browser_cfg = config.setdefault("browser", {})
1657          if bp == "local":
1658              browser_cfg["cloud_provider"] = "local"
1659              _print_success("  Browser set to local mode")
1660          elif bp:
1661              browser_cfg["cloud_provider"] = bp
1662              _print_success(f"  Browser cloud provider set to: {bp}")
1663          browser_cfg["use_gateway"] = bool(managed_feature)
1664  
1665      # Set web search backend in config if applicable
1666      if provider.get("web_backend"):
1667          web_cfg = config.setdefault("web", {})
1668          web_cfg["backend"] = provider["web_backend"]
1669          web_cfg["use_gateway"] = bool(managed_feature)
1670          _print_success(f"  Web backend set to: {provider['web_backend']}")
1671  
1672      # For tools without a specific config key (e.g. image_gen), still
1673      # track use_gateway so the runtime knows the user's intent.
1674      if managed_feature and managed_feature not in ("web", "tts", "browser"):
1675          config.setdefault(managed_feature, {})["use_gateway"] = True
1676      elif not managed_feature:
1677          # User picked a non-gateway provider — find which category this
1678          # belongs to and clear use_gateway if it was previously set.
1679          for cat_key, cat in TOOL_CATEGORIES.items():
1680              if provider in cat.get("providers", []):
1681                  section = config.get(cat_key)
1682                  if isinstance(section, dict) and section.get("use_gateway"):
1683                      section["use_gateway"] = False
1684                  break
1685  
1686      if not env_vars:
1687          if provider.get("post_setup"):
1688              _run_post_setup(provider["post_setup"])
1689          _print_success(f"  {provider['name']} - no configuration needed!")
1690          if managed_feature:
1691              _print_info("  Requests for this tool will be billed to your Nous subscription.")
1692          # Plugin-registered image_gen provider: write image_gen.provider
1693          # and route model selection to the plugin's own catalog.
1694          plugin_name = provider.get("image_gen_plugin_name")
1695          if plugin_name:
1696              _select_plugin_image_gen_provider(plugin_name, config)
1697              return
1698          # Imagegen backends prompt for model selection after backend pick.
1699          backend = provider.get("imagegen_backend")
1700          if backend:
1701              _configure_imagegen_model(backend, config)
1702              # In-tree FAL is the only non-plugin backend today. Keep
1703              # image_gen.provider clear so the dispatch shim falls through
1704              # to the legacy FAL path.
1705              img_cfg = config.setdefault("image_gen", {})
1706              if isinstance(img_cfg, dict) and img_cfg.get("provider") not in (None, "", "fal"):
1707                  img_cfg["provider"] = "fal"
1708          return
1709  
1710      # Prompt for each required env var
1711      all_configured = True
1712      for var in env_vars:
1713          existing = get_env_value(var["key"])
1714          if existing:
1715              _print_success(f"  {var['key']}: already configured")
1716              # Don't ask to update - this is a new enable flow.
1717              # Reconfigure is handled separately.
1718          else:
1719              url = var.get("url", "")
1720              if url:
1721                  _print_info(f"  Get yours at: {url}")
1722  
1723              default_val = var.get("default", "")
1724              if default_val:
1725                  value = _prompt(f"    {var.get('prompt', var['key'])}", default_val)
1726              else:
1727                  value = _prompt(f"    {var.get('prompt', var['key'])}", password=True)
1728  
1729              if value:
1730                  save_env_value(var["key"], value)
1731                  _print_success("    Saved")
1732              else:
1733                  _print_warning("    Skipped")
1734                  all_configured = False
1735  
1736      # Run post-setup hooks if needed
1737      if provider.get("post_setup") and all_configured:
1738          _run_post_setup(provider["post_setup"])
1739  
1740      if all_configured:
1741          _print_success(f"  {provider['name']} configured!")
1742          plugin_name = provider.get("image_gen_plugin_name")
1743          if plugin_name:
1744              _select_plugin_image_gen_provider(plugin_name, config)
1745              return
1746          # Imagegen backends prompt for model selection after env vars are in.
1747          backend = provider.get("imagegen_backend")
1748          if backend:
1749              _configure_imagegen_model(backend, config)
1750              img_cfg = config.setdefault("image_gen", {})
1751              if isinstance(img_cfg, dict) and img_cfg.get("provider") not in (None, "", "fal"):
1752                  img_cfg["provider"] = "fal"
1753  
1754  
1755  def _configure_simple_requirements(ts_key: str):
1756      """Simple fallback for toolsets that just need env vars (no provider selection)."""
1757      if ts_key == "vision":
1758          if _toolset_has_keys("vision"):
1759              return
1760          print()
1761          print(color("  Vision / Image Analysis requires a multimodal backend:", Colors.YELLOW))
1762          choices = [
1763              "OpenRouter — uses Gemini",
1764              "OpenAI-compatible endpoint — base URL, API key, and vision model",
1765              "Skip",
1766          ]
1767          idx = _prompt_choice("  Configure vision backend", choices, 2)
1768          if idx == 0:
1769              _print_info("  Get key at: https://openrouter.ai/keys")
1770              value = _prompt("    OPENROUTER_API_KEY", password=True)
1771              if value and value.strip():
1772                  save_env_value("OPENROUTER_API_KEY", value.strip())
1773                  _print_success("    Saved")
1774              else:
1775                  _print_warning("    Skipped")
1776          elif idx == 1:
1777              base_url = _prompt("    OPENAI_BASE_URL (blank for OpenAI)").strip() or "https://api.openai.com/v1"
1778              is_native_openai = base_url_hostname(base_url) == "api.openai.com"
1779              key_label = "    OPENAI_API_KEY" if is_native_openai else "    API key"
1780              api_key = _prompt(key_label, password=True)
1781              if api_key and api_key.strip():
1782                  save_env_value("OPENAI_API_KEY", api_key.strip())
1783                  # Save vision base URL to config (not .env — only secrets go there)
1784                  _cfg = load_config()
1785                  _aux = _cfg.setdefault("auxiliary", {}).setdefault("vision", {})
1786                  _aux["base_url"] = base_url
1787                  save_config(_cfg)
1788                  if is_native_openai:
1789                      save_env_value("AUXILIARY_VISION_MODEL", "gpt-4o-mini")
1790                  _print_success("    Saved")
1791              else:
1792                  _print_warning("    Skipped")
1793          return
1794  
1795      requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, [])
1796      if not requirements:
1797          return
1798  
1799      missing = [(var, url) for var, url in requirements if not get_env_value(var)]
1800      if not missing:
1801          return
1802  
1803      ts_label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key)
1804      print()
1805      print(color(f"  {ts_label} requires configuration:", Colors.YELLOW))
1806  
1807      for var, url in missing:
1808          if url:
1809              _print_info(f"  Get key at: {url}")
1810          value = _prompt(f"    {var}", password=True)
1811          if value and value.strip():
1812              save_env_value(var, value.strip())
1813              _print_success("    Saved")
1814          else:
1815              _print_warning("    Skipped")
1816  
1817  
1818  def _reconfigure_tool(config: dict):
1819      """Let user reconfigure an existing tool's provider or API key."""
1820      # Build list of configurable tools that are currently set up
1821      configurable = []
1822      for ts_key, ts_label, _ in _get_effective_configurable_toolsets():
1823          cat = TOOL_CATEGORIES.get(ts_key)
1824          reqs = TOOLSET_ENV_REQUIREMENTS.get(ts_key)
1825          if cat or reqs:
1826              if _toolset_has_keys(ts_key, config) or _toolset_enabled_for_reconfigure(ts_key, config):
1827                  configurable.append((ts_key, ts_label))
1828  
1829      if not configurable:
1830          _print_info("No configured tools to reconfigure.")
1831          return
1832  
1833      choices = [label for _, label in configurable]
1834      choices.append("Cancel")
1835  
1836      idx = _prompt_choice("  Which tool would you like to reconfigure?", choices, len(choices) - 1)
1837  
1838      if idx >= len(configurable):
1839          return  # Cancel
1840  
1841      ts_key, ts_label = configurable[idx]
1842      cat = TOOL_CATEGORIES.get(ts_key)
1843  
1844      if cat:
1845          _configure_tool_category_for_reconfig(ts_key, cat, config)
1846      else:
1847          _reconfigure_simple_requirements(ts_key)
1848  
1849      save_config(config)
1850  
1851  
1852  def _toolset_enabled_for_reconfigure(ts_key: str, config: dict) -> bool:
1853      """Return True if a configurable toolset is enabled anywhere.
1854  
1855      Reconfigure must include enabled-but-unconfigured categories so users can
1856      finish provider/API-key setup without disabling and re-enabling the toolset.
1857      """
1858      for platform in PLATFORMS:
1859          if not _toolset_allowed_for_platform(ts_key, platform):
1860              continue
1861          try:
1862              enabled = _get_platform_tools(
1863                  config,
1864                  platform,
1865                  include_default_mcp_servers=False,
1866              )
1867          except Exception:
1868              continue
1869          if ts_key in enabled:
1870              return True
1871      return False
1872  
1873  
1874  def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
1875      """Reconfigure a tool category - provider selection + API key update."""
1876      icon = cat.get("icon", "")
1877      name = cat["name"]
1878      providers = _visible_providers(cat, config)
1879  
1880      if len(providers) == 1:
1881          provider = providers[0]
1882          print()
1883          print(color(f"  --- {icon} {name} ({provider['name']}) ---", Colors.CYAN))
1884          _reconfigure_provider(provider, config)
1885      else:
1886          print()
1887          print(color(f"  --- {icon} {name} - Choose a provider ---", Colors.CYAN))
1888          print()
1889  
1890          provider_choices = []
1891          for p in providers:
1892              badge = f" [{p['badge']}]" if p.get("badge") else ""
1893              tag = f" — {p['tag']}" if p.get("tag") else ""
1894              configured = ""
1895              env_vars = p.get("env_vars", [])
1896              if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
1897                  if _is_provider_active(p, config):
1898                      configured = " [active]"
1899                  elif not env_vars:
1900                      configured = ""
1901                  else:
1902                      configured = " [configured]"
1903              provider_choices.append(f"{p['name']}{badge}{tag}{configured}")
1904  
1905          default_idx = _detect_active_provider_index(providers, config)
1906  
1907          provider_idx = _prompt_choice("  Select provider:", provider_choices, default_idx)
1908          _reconfigure_provider(providers[provider_idx], config)
1909  
1910  
1911  def _reconfigure_provider(provider: dict, config: dict):
1912      """Reconfigure a provider - update API keys."""
1913      env_vars = provider.get("env_vars", [])
1914      managed_feature = provider.get("managed_nous_feature")
1915  
1916      if provider.get("requires_nous_auth"):
1917          features = get_nous_subscription_features(config)
1918          if not features.nous_auth_present:
1919              _print_warning("  Nous Subscription is only available after logging into Nous Portal.")
1920              return
1921  
1922      if provider.get("tts_provider"):
1923          tts_cfg = config.setdefault("tts", {})
1924          tts_cfg["provider"] = provider["tts_provider"]
1925          tts_cfg["use_gateway"] = bool(managed_feature)
1926          _print_success(f"  TTS provider set to: {provider['tts_provider']}")
1927  
1928      if "browser_provider" in provider:
1929          bp = provider["browser_provider"]
1930          browser_cfg = config.setdefault("browser", {})
1931          if bp == "local":
1932              browser_cfg["cloud_provider"] = "local"
1933              _print_success("  Browser set to local mode")
1934          elif bp:
1935              browser_cfg["cloud_provider"] = bp
1936              _print_success(f"  Browser cloud provider set to: {bp}")
1937          browser_cfg["use_gateway"] = bool(managed_feature)
1938  
1939      # Set web search backend in config if applicable
1940      if provider.get("web_backend"):
1941          web_cfg = config.setdefault("web", {})
1942          web_cfg["backend"] = provider["web_backend"]
1943          web_cfg["use_gateway"] = bool(managed_feature)
1944          _print_success(f"  Web backend set to: {provider['web_backend']}")
1945  
1946      if managed_feature and managed_feature not in ("web", "tts", "browser"):
1947          section = config.setdefault(managed_feature, {})
1948          if not isinstance(section, dict):
1949              section = {}
1950              config[managed_feature] = section
1951          section["use_gateway"] = True
1952      elif not managed_feature:
1953          for cat_key, cat in TOOL_CATEGORIES.items():
1954              if provider in cat.get("providers", []):
1955                  section = config.get(cat_key)
1956                  if isinstance(section, dict) and section.get("use_gateway"):
1957                      section["use_gateway"] = False
1958                  break
1959  
1960      if not env_vars:
1961          if provider.get("post_setup"):
1962              _run_post_setup(provider["post_setup"])
1963          _print_success(f"  {provider['name']} - no configuration needed!")
1964          if managed_feature:
1965              _print_info("  Requests for this tool will be billed to your Nous subscription.")
1966          plugin_name = provider.get("image_gen_plugin_name")
1967          if plugin_name:
1968              _select_plugin_image_gen_provider(plugin_name, config)
1969              return
1970          # Imagegen backends prompt for model selection on reconfig too.
1971          backend = provider.get("imagegen_backend")
1972          if backend:
1973              _configure_imagegen_model(backend, config)
1974              if backend == "fal":
1975                  img_cfg = config.setdefault("image_gen", {})
1976                  if isinstance(img_cfg, dict):
1977                      img_cfg["provider"] = "fal"
1978                      img_cfg["use_gateway"] = False
1979          return
1980  
1981      for var in env_vars:
1982          existing = get_env_value(var["key"])
1983          if existing:
1984              _print_info(f"  {var['key']}: configured ({existing[:8]}...)")
1985          url = var.get("url", "")
1986          if url:
1987              _print_info(f"  Get yours at: {url}")
1988          default_val = var.get("default", "")
1989          value = _prompt(f"    {var.get('prompt', var['key'])} (Enter to keep current)", password=not default_val)
1990          if value and value.strip():
1991              save_env_value(var["key"], value.strip())
1992              _print_success("    Updated")
1993          else:
1994              _print_info("    Kept current")
1995  
1996      # Imagegen backends prompt for model selection on reconfig too.
1997      plugin_name = provider.get("image_gen_plugin_name")
1998      if plugin_name:
1999          _select_plugin_image_gen_provider(plugin_name, config)
2000          return
2001  
2002      backend = provider.get("imagegen_backend")
2003      if backend:
2004          _configure_imagegen_model(backend, config)
2005          if backend == "fal":
2006              img_cfg = config.setdefault("image_gen", {})
2007              if isinstance(img_cfg, dict):
2008                  img_cfg["provider"] = "fal"
2009                  img_cfg["use_gateway"] = False
2010  
2011  
2012  def _reconfigure_simple_requirements(ts_key: str):
2013      """Reconfigure simple env var requirements."""
2014      requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, [])
2015      if not requirements:
2016          return
2017  
2018      ts_label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key)
2019      print()
2020      print(color(f"  {ts_label}:", Colors.CYAN))
2021  
2022      for var, url in requirements:
2023          existing = get_env_value(var)
2024          if existing:
2025              _print_info(f"  {var}: configured ({existing[:8]}...)")
2026          if url:
2027              _print_info(f"  Get key at: {url}")
2028          value = _prompt(f"    {var} (Enter to keep current)", password=True)
2029          if value and value.strip():
2030              save_env_value(var, value.strip())
2031              _print_success("    Updated")
2032          else:
2033              _print_info("    Kept current")
2034  
2035  
2036  # ─── Main Entry Point ─────────────────────────────────────────────────────────
2037  
2038  def tools_command(args=None, first_install: bool = False, config: dict = None):
2039      """Entry point for `hermes tools` and `hermes setup tools`.
2040  
2041      Args:
2042          first_install: When True (set by the setup wizard on fresh installs),
2043              skip the platform menu, go straight to the CLI checklist, and
2044              prompt for API keys on all enabled tools that need them.
2045          config: Optional config dict to use.  When called from the setup
2046              wizard, the wizard passes its own dict so that platform_toolsets
2047              are written into it and survive the wizard's final save_config().
2048      """
2049      if config is None:
2050          config = load_config()
2051      enabled_platforms = _get_enabled_platforms()
2052  
2053      print()
2054  
2055      # Non-interactive summary mode for CLI usage
2056      if getattr(args, "summary", False):
2057          total = len(_get_effective_configurable_toolsets())
2058          print(color("⚕ Tool Summary", Colors.CYAN, Colors.BOLD))
2059          print()
2060          summary = _platform_toolset_summary(config, enabled_platforms)
2061          for pkey in enabled_platforms:
2062              pinfo = PLATFORMS[pkey]
2063              enabled = summary.get(pkey, set())
2064              count = len(enabled)
2065              print(color(f"  {pinfo['label']}", Colors.BOLD) + color(f"  ({count}/{total})", Colors.DIM))
2066              if enabled:
2067                  for ts_key in sorted(enabled):
2068                      label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key)
2069                      print(color(f"    ✓ {label}", Colors.GREEN))
2070              else:
2071                  print(color("    (none enabled)", Colors.DIM))
2072          print()
2073          return
2074      print(color("⚕ Hermes Tool Configuration", Colors.CYAN, Colors.BOLD))
2075      print(color("  Enable or disable tools per platform.", Colors.DIM))
2076      print(color("  Tools that need API keys will be configured when enabled.", Colors.DIM))
2077      print(color("  Guide: https://hermes-agent.nousresearch.com/docs/user-guide/features/tools", Colors.DIM))
2078      print()
2079  
2080      # ── First-time install: linear flow, no platform menu ──
2081      if first_install:
2082          for pkey in enabled_platforms:
2083              pinfo = PLATFORMS[pkey]
2084              current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False)
2085  
2086              # Uncheck toolsets that should be off by default
2087              checklist_preselected = current_enabled - _DEFAULT_OFF_TOOLSETS
2088  
2089              # Show checklist
2090              new_enabled = _prompt_toolset_checklist(pinfo["label"], checklist_preselected, pkey)
2091  
2092              added = new_enabled - current_enabled
2093              removed = current_enabled - new_enabled
2094              if added:
2095                  for ts in sorted(added):
2096                      label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
2097                      print(color(f"  + {label}", Colors.GREEN))
2098              if removed:
2099                  for ts in sorted(removed):
2100                      label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
2101                      print(color(f"  - {label}", Colors.RED))
2102  
2103              auto_configured = apply_nous_managed_defaults(
2104                  config,
2105                  enabled_toolsets=new_enabled,
2106              )
2107              if managed_nous_tools_enabled():
2108                  for ts_key in sorted(auto_configured):
2109                      label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key)
2110                      print(color(f"  ✓ {label}: using your Nous subscription defaults", Colors.GREEN))
2111  
2112              # Walk through ALL selected tools that have provider options or
2113              # need API keys.  This ensures browser (Local vs Browserbase),
2114              # TTS (Edge vs OpenAI vs ElevenLabs), etc. are shown even when
2115              # a free provider exists.
2116              to_configure = [
2117                  ts_key for ts_key in sorted(new_enabled)
2118                  if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key))
2119                  and ts_key not in auto_configured
2120              ]
2121  
2122              if to_configure:
2123                  print()
2124                  print(color(f"  Configuring {len(to_configure)} tool(s):", Colors.YELLOW))
2125                  for ts_key in to_configure:
2126                      label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts_key), ts_key)
2127                      print(color(f"    • {label}", Colors.DIM))
2128                  print(color("  You can skip any tool you don't need right now.", Colors.DIM))
2129                  print()
2130                  for ts_key in to_configure:
2131                      _configure_toolset(ts_key, config)
2132  
2133              _save_platform_tools(config, pkey, new_enabled)
2134              save_config(config)
2135              print(color(f"  ✓ Saved {pinfo['label']} tool configuration", Colors.GREEN))
2136              print()
2137  
2138          return
2139  
2140      # ── Returning user: platform menu loop ──
2141      # Build platform choices
2142      platform_choices = []
2143      platform_keys = []
2144      for pkey in enabled_platforms:
2145          pinfo = PLATFORMS[pkey]
2146          current = _get_platform_tools(config, pkey, include_default_mcp_servers=False)
2147          count = len(current)
2148          total = len(_get_effective_configurable_toolsets())
2149          platform_choices.append(f"Configure {pinfo['label']}  ({count}/{total} enabled)")
2150          platform_keys.append(pkey)
2151  
2152      if len(platform_keys) > 1:
2153          platform_choices.append("Configure all platforms (global)")
2154      platform_choices.append("Reconfigure an existing tool's provider or API key")
2155  
2156      # Show MCP option if any MCP servers are configured
2157      _has_mcp = bool(config.get("mcp_servers"))
2158      if _has_mcp:
2159          platform_choices.append("Configure MCP server tools")
2160  
2161      platform_choices.append("Done")
2162  
2163      # Index offsets for the extra options after per-platform entries
2164      _global_idx = len(platform_keys) if len(platform_keys) > 1 else -1
2165      _reconfig_idx = len(platform_keys) + (1 if len(platform_keys) > 1 else 0)
2166      _mcp_idx = (_reconfig_idx + 1) if _has_mcp else -1
2167      _done_idx = _reconfig_idx + (2 if _has_mcp else 1)
2168  
2169      while True:
2170          idx = _prompt_choice("Select an option:", platform_choices, default=0)
2171  
2172          # "Done" selected
2173          if idx == _done_idx:
2174              break
2175  
2176          # "Reconfigure" selected
2177          if idx == _reconfig_idx:
2178              _reconfigure_tool(config)
2179              print()
2180              continue
2181  
2182          # "Configure MCP tools" selected
2183          if idx == _mcp_idx:
2184              _configure_mcp_tools_interactive(config)
2185              print()
2186              continue
2187  
2188          # "Configure all platforms (global)" selected
2189          if idx == _global_idx:
2190              # Use the union of all platforms' current tools as the starting state
2191              all_current = set()
2192              for pk in platform_keys:
2193                  all_current |= _get_platform_tools(config, pk, include_default_mcp_servers=False)
2194              new_enabled = _prompt_toolset_checklist("All platforms", all_current)
2195              if new_enabled != all_current:
2196                  for pk in platform_keys:
2197                      prev = _get_platform_tools(config, pk, include_default_mcp_servers=False)
2198                      added = new_enabled - prev
2199                      removed = prev - new_enabled
2200                      pinfo_inner = PLATFORMS[pk]
2201                      if added or removed:
2202                          print(color(f"  {pinfo_inner['label']}:", Colors.DIM))
2203                          for ts in sorted(added):
2204                              label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
2205                              print(color(f"    + {label}", Colors.GREEN))
2206                          for ts in sorted(removed):
2207                              label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
2208                              print(color(f"    - {label}", Colors.RED))
2209                      # Configure API keys for newly enabled tools
2210                      for ts_key in sorted(added):
2211                          if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
2212                              if _toolset_needs_configuration_prompt(ts_key, config):
2213                                  _configure_toolset(ts_key, config)
2214                      _save_platform_tools(config, pk, new_enabled)
2215                  save_config(config)
2216                  print(color("  ✓ Saved configuration for all platforms", Colors.GREEN))
2217                  # Update choice labels
2218                  for ci, pk in enumerate(platform_keys):
2219                      new_count = len(_get_platform_tools(config, pk, include_default_mcp_servers=False))
2220                      total = len(_get_effective_configurable_toolsets())
2221                      platform_choices[ci] = f"Configure {PLATFORMS[pk]['label']}  ({new_count}/{total} enabled)"
2222              else:
2223                  print(color("  No changes", Colors.DIM))
2224              print()
2225              continue
2226  
2227          pkey = platform_keys[idx]
2228          pinfo = PLATFORMS[pkey]
2229  
2230          # Get current enabled toolsets for this platform
2231          current_enabled = _get_platform_tools(config, pkey, include_default_mcp_servers=False)
2232  
2233          # Show checklist
2234          new_enabled = _prompt_toolset_checklist(pinfo["label"], current_enabled)
2235  
2236          if new_enabled != current_enabled:
2237              added = new_enabled - current_enabled
2238              removed = current_enabled - new_enabled
2239  
2240              if added:
2241                  for ts in sorted(added):
2242                      label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
2243                      print(color(f"  + {label}", Colors.GREEN))
2244              if removed:
2245                  for ts in sorted(removed):
2246                      label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
2247                      print(color(f"  - {label}", Colors.RED))
2248  
2249              # Configure newly enabled toolsets that need API keys
2250              for ts_key in sorted(added):
2251                  if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
2252                      if _toolset_needs_configuration_prompt(ts_key, config):
2253                          _configure_toolset(ts_key, config)
2254  
2255              _save_platform_tools(config, pkey, new_enabled)
2256              save_config(config)
2257              print(color(f"  ✓ Saved {pinfo['label']} configuration", Colors.GREEN))
2258          else:
2259              print(color(f"  No changes to {pinfo['label']}", Colors.DIM))
2260  
2261          print()
2262  
2263          # Update the choice label with new count
2264          new_count = len(_get_platform_tools(config, pkey, include_default_mcp_servers=False))
2265          total = len(_get_effective_configurable_toolsets())
2266          platform_choices[idx] = f"Configure {pinfo['label']}  ({new_count}/{total} enabled)"
2267  
2268      print()
2269      from hermes_constants import display_hermes_home
2270      print(color(f"  Tool configuration saved to {display_hermes_home()}/config.yaml", Colors.DIM))
2271      print(color("  Changes take effect on next 'hermes' or gateway restart.", Colors.DIM))
2272      print()
2273  
2274  
2275  # ─── MCP Tools Interactive Configuration ─────────────────────────────────────
2276  
2277  
2278  def _configure_mcp_tools_interactive(config: dict):
2279      """Probe MCP servers for available tools and let user toggle them on/off.
2280  
2281      Connects to each configured MCP server, discovers tools, then shows
2282      a per-server curses checklist.  Writes changes back as ``tools.exclude``
2283      entries in config.yaml.
2284      """
2285      from hermes_cli.curses_ui import curses_checklist
2286  
2287      mcp_servers = config.get("mcp_servers") or {}
2288      if not mcp_servers:
2289          _print_info("No MCP servers configured.")
2290          return
2291  
2292      # Count enabled servers
2293      enabled_names = [
2294          k for k, v in mcp_servers.items()
2295          if v.get("enabled", True) not in (False, "false", "0", "no", "off")
2296      ]
2297      if not enabled_names:
2298          _print_info("All MCP servers are disabled.")
2299          return
2300  
2301      print()
2302      print(color("  Discovering tools from MCP servers...", Colors.YELLOW))
2303      print(color(f"  Connecting to {len(enabled_names)} server(s): {', '.join(enabled_names)}", Colors.DIM))
2304  
2305      try:
2306          from tools.mcp_tool import probe_mcp_server_tools
2307          server_tools = probe_mcp_server_tools()
2308      except Exception as exc:
2309          _print_error(f"Failed to probe MCP servers: {exc}")
2310          return
2311  
2312      if not server_tools:
2313          _print_warning("Could not discover tools from any MCP server.")
2314          _print_info("Check that server commands/URLs are correct and dependencies are installed.")
2315          return
2316  
2317      # Report discovery results
2318      failed = [n for n in enabled_names if n not in server_tools]
2319      if failed:
2320          for name in failed:
2321              _print_warning(f"  Could not connect to '{name}'")
2322  
2323      total_tools = sum(len(tools) for tools in server_tools.values())
2324      print(color(f"  Found {total_tools} tool(s) across {len(server_tools)} server(s)", Colors.GREEN))
2325      print()
2326  
2327      any_changes = False
2328  
2329      for server_name, tools in server_tools.items():
2330          if not tools:
2331              _print_info(f"  {server_name}: no tools found")
2332              continue
2333  
2334          srv_cfg = mcp_servers.get(server_name, {})
2335          tools_cfg = srv_cfg.get("tools") or {}
2336          include_list = tools_cfg.get("include") or []
2337          exclude_list = tools_cfg.get("exclude") or []
2338  
2339          # Build checklist labels
2340          labels = []
2341          for tool_name, description in tools:
2342              desc_short = description[:70] + "..." if len(description) > 70 else description
2343              if desc_short:
2344                  labels.append(f"{tool_name}  ({desc_short})")
2345              else:
2346                  labels.append(tool_name)
2347  
2348          # Determine which tools are currently enabled
2349          pre_selected: Set[int] = set()
2350          tool_names = [t[0] for t in tools]
2351          for i, tool_name in enumerate(tool_names):
2352              if include_list:
2353                  # Include mode: only included tools are selected
2354                  if tool_name in include_list:
2355                      pre_selected.add(i)
2356              elif exclude_list:
2357                  # Exclude mode: everything except excluded
2358                  if tool_name not in exclude_list:
2359                      pre_selected.add(i)
2360              else:
2361                  # No filter: all enabled
2362                  pre_selected.add(i)
2363  
2364          chosen = curses_checklist(
2365              f"MCP Server: {server_name}  ({len(tools)} tools)",
2366              labels,
2367              pre_selected,
2368              cancel_returns=pre_selected,
2369          )
2370  
2371          if chosen == pre_selected:
2372              _print_info(f"  {server_name}: no changes")
2373              continue
2374  
2375          # Compute new exclude list based on unchecked tools
2376          new_exclude = [tool_names[i] for i in range(len(tool_names)) if i not in chosen]
2377  
2378          # Update config
2379          srv_cfg = mcp_servers.setdefault(server_name, {})
2380          tools_cfg = srv_cfg.setdefault("tools", {})
2381  
2382          if new_exclude:
2383              tools_cfg["exclude"] = new_exclude
2384              # Remove include if present — we're switching to exclude mode
2385              tools_cfg.pop("include", None)
2386          else:
2387              # All tools enabled — clear filters
2388              tools_cfg.pop("exclude", None)
2389              tools_cfg.pop("include", None)
2390  
2391          enabled_count = len(chosen)
2392          disabled_count = len(tools) - enabled_count
2393          _print_success(
2394              f"  {server_name}: {enabled_count} enabled, {disabled_count} disabled"
2395          )
2396          any_changes = True
2397  
2398      if any_changes:
2399          save_config(config)
2400          print()
2401          print(color("  ✓ MCP tool configuration saved", Colors.GREEN))
2402      else:
2403          print(color("  No changes to MCP tools", Colors.DIM))
2404  
2405  
2406  # ─── Non-interactive disable/enable ──────────────────────────────────────────
2407  
2408  
2409  def _apply_toolset_change(config: dict, platform: str, toolset_names: List[str], action: str):
2410      """Add or remove built-in toolsets for a platform."""
2411      enabled = _get_platform_tools(config, platform, include_default_mcp_servers=False)
2412      if action == "disable":
2413          updated = enabled - set(toolset_names)
2414      else:
2415          updated = enabled | set(toolset_names)
2416      _save_platform_tools(config, platform, updated)
2417  
2418  
2419  def _apply_mcp_change(config: dict, targets: List[str], action: str) -> Set[str]:
2420      """Add or remove specific MCP tools from a server's exclude list.
2421  
2422      Returns the set of server names that were not found in config.
2423      """
2424      failed_servers: Set[str] = set()
2425      mcp_servers = config.get("mcp_servers") or {}
2426  
2427      for target in targets:
2428          server_name, tool_name = target.split(":", 1)
2429          if server_name not in mcp_servers:
2430              failed_servers.add(server_name)
2431              continue
2432          tools_cfg = mcp_servers[server_name].setdefault("tools", {})
2433          exclude = list(tools_cfg.get("exclude") or [])
2434          if action == "disable":
2435              if tool_name not in exclude:
2436                  exclude.append(tool_name)
2437          else:
2438              exclude = [t for t in exclude if t != tool_name]
2439          tools_cfg["exclude"] = exclude
2440  
2441      return failed_servers
2442  
2443  
2444  def _print_tools_list(enabled_toolsets: set, mcp_servers: dict, platform: str = "cli"):
2445      """Print a summary of enabled/disabled toolsets and MCP tool filters."""
2446      effective_all = _get_effective_configurable_toolsets()
2447      effective = [
2448          (k, l, d) for (k, l, d) in effective_all
2449          if _toolset_allowed_for_platform(k, platform)
2450      ]
2451      builtin_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
2452  
2453      print(f"Built-in toolsets ({platform}):")
2454      for ts_key, label, _ in effective:
2455          if ts_key not in builtin_keys:
2456              continue
2457          status = (color("✓ enabled", Colors.GREEN) if ts_key in enabled_toolsets
2458                    else color("✗ disabled", Colors.RED))
2459          print(f"  {status}  {ts_key}  {color(label, Colors.DIM)}")
2460  
2461      # Plugin toolsets
2462      plugin_entries = [(k, l) for k, l, _ in effective if k not in builtin_keys]
2463      if plugin_entries:
2464          print()
2465          print(f"Plugin toolsets ({platform}):")
2466          for ts_key, label in plugin_entries:
2467              status = (color("✓ enabled", Colors.GREEN) if ts_key in enabled_toolsets
2468                        else color("✗ disabled", Colors.RED))
2469              print(f"  {status}  {ts_key}  {color(label, Colors.DIM)}")
2470  
2471      if mcp_servers:
2472          print()
2473          print("MCP servers:")
2474          for srv_name, srv_cfg in mcp_servers.items():
2475              tools_cfg = srv_cfg.get("tools") or {}
2476              exclude = tools_cfg.get("exclude") or []
2477              include = tools_cfg.get("include") or []
2478              if include:
2479                  _print_info(f"{srv_name}  [include only: {', '.join(include)}]")
2480              elif exclude:
2481                  _print_info(f"{srv_name}  [excluded: {color(', '.join(exclude), Colors.YELLOW)}]")
2482              else:
2483                  _print_info(f"{srv_name}  {color('all tools enabled', Colors.DIM)}")
2484  
2485  
2486  def tools_disable_enable_command(args):
2487      """Enable, disable, or list tools for a platform.
2488  
2489      Built-in toolsets use plain names (e.g. ``web``, ``memory``).
2490      MCP tools use ``server:tool`` notation (e.g. ``github:create_issue``).
2491      """
2492      action = args.tools_action
2493      platform = getattr(args, "platform", "cli")
2494      config = load_config()
2495  
2496      if platform not in PLATFORMS:
2497          _print_error(f"Unknown platform '{platform}'. Valid: {', '.join(PLATFORMS)}")
2498          return
2499  
2500      if action == "list":
2501          _print_tools_list(_get_platform_tools(config, platform, include_default_mcp_servers=False),
2502                            config.get("mcp_servers") or {}, platform)
2503          return
2504  
2505      targets: List[str] = args.names
2506      toolset_targets = [t for t in targets if ":" not in t]
2507      mcp_targets = [t for t in targets if ":" in t]
2508  
2509      valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} | _get_plugin_toolset_keys()
2510      unknown_toolsets = [t for t in toolset_targets if t not in valid_toolsets]
2511      if unknown_toolsets:
2512          for name in unknown_toolsets:
2513              _print_error(f"Unknown toolset '{name}'")
2514          toolset_targets = [t for t in toolset_targets if t in valid_toolsets]
2515  
2516      # Reject platform-scoped toolsets on platforms that don't allow them.
2517      restricted_targets = [
2518          t for t in toolset_targets
2519          if not _toolset_allowed_for_platform(t, platform)
2520      ]
2521      if restricted_targets:
2522          for name in restricted_targets:
2523              allowed = sorted(_TOOLSET_PLATFORM_RESTRICTIONS.get(name) or set())
2524              _print_error(
2525                  f"Toolset '{name}' is not available on platform '{platform}' "
2526                  f"(only: {', '.join(allowed)})"
2527              )
2528          toolset_targets = [t for t in toolset_targets if t not in restricted_targets]
2529  
2530      if toolset_targets:
2531          _apply_toolset_change(config, platform, toolset_targets, action)
2532  
2533      failed_servers: Set[str] = set()
2534      if mcp_targets:
2535          failed_servers = _apply_mcp_change(config, mcp_targets, action)
2536          for srv in failed_servers:
2537              _print_error(f"MCP server '{srv}' not found in config")
2538  
2539      save_config(config)
2540  
2541      successful = [
2542          t for t in targets
2543          if t not in unknown_toolsets and (":" not in t or t.split(":")[0] not in failed_servers)
2544      ]
2545      if successful:
2546          verb = "Disabled" if action == "disable" else "Enabled"
2547          _print_success(f"{verb}: {', '.join(successful)}")