/ 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)}")