From 82b18e8ac22bdd8e098ee80c7594ef8b57bb83e0 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Fri, 6 Mar 2026 18:11:35 -0800 Subject: [PATCH] feat: unify hermes tools and hermes setup tools into single flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both 'hermes tools' and 'hermes setup tools' now use the same unified flow in tools_config.py: 1. Select platform (CLI, Telegram, Discord, etc.) 2. Toggle all 18 toolsets on/off in checklist 3. Newly enabled tools that need API keys โ†’ provider-aware config (e.g., TTS shows Edge/OpenAI/ElevenLabs picker) 4. Already-configured tools that stay enabled โ†’ silent, no prompts 5. Menu option: 'Reconfigure an existing tool' for updating providers or API keys on tools that are already set up Key changes: - Move TOOL_CATEGORIES, provider config, and post-setup hooks from setup.py to tools_config.py - Replace flat _check_and_prompt_requirements() with provider-aware _configure_toolset() that uses TOOL_CATEGORIES - Add _reconfigure_tool() flow for updating existing configs - setup.py's setup_tools() now delegates to tools_command() - tools_command() menu adds 'Reconfigure' option alongside platforms - Only prompt for API keys on tools that are NEWLY toggled on AND don't already have keys configured No breaking changes. All 2013 tests pass. --- hermes_cli/setup.py | 368 +--------------------- hermes_cli/tools_config.py | 627 ++++++++++++++++++++++++++++++++----- 2 files changed, 558 insertions(+), 437 deletions(-) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 9b6086255..cf0b91405 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -460,191 +460,9 @@ def _prompt_container_resources(config: dict): pass -# ============================================================================= -# Tool Categories โ€” category-first UX for tool configuration -# ============================================================================= -# Each category represents a tool type. Within each category, users choose -# a provider. This avoids showing "OpenAI Voice" and "ElevenLabs" as separate -# tools โ€” instead they see "Text-to-Speech" then pick a provider. -TOOL_CATEGORIES = [ - { - "name": "Text-to-Speech", - "icon": "๐ŸŽค", - "description": "Convert text to voice messages", - "providers": [ - { - "name": "Microsoft Edge TTS", - "tag": "Free - no API key needed", - "env_vars": [], - "tts_provider": "edge", - }, - { - "name": "OpenAI TTS", - "tag": "Premium - high quality voices", - "env_vars": [ - {"key": "VOICE_TOOLS_OPENAI_KEY", "prompt": "OpenAI API key", "url": "https://platform.openai.com/api-keys"}, - ], - "tts_provider": "openai", - }, - { - "name": "ElevenLabs", - "tag": "Premium - most natural voices", - "env_vars": [ - {"key": "ELEVENLABS_API_KEY", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/app/settings/api-keys"}, - ], - "tts_provider": "elevenlabs", - }, - ], - }, - { - "name": "Web Search & Extract", - "icon": "๐Ÿ”", - "description": "Search the web and extract content from URLs", - "providers": [ - { - "name": "Firecrawl Cloud", - "tag": "Recommended - hosted service", - "env_vars": [ - {"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"}, - ], - }, - { - "name": "Firecrawl Self-Hosted", - "tag": "Free - run your own instance", - "env_vars": [ - {"key": "FIRECRAWL_API_URL", "prompt": "Your Firecrawl instance URL (e.g., http://localhost:3002)"}, - ], - }, - ], - }, - { - "name": "Image Generation", - "icon": "๐ŸŽจ", - "description": "Generate images from text prompts (FLUX 2 Pro + upscaling)", - "providers": [ - { - "name": "FAL.ai", - "tag": "FLUX 2 Pro with auto-upscaling", - "env_vars": [ - {"key": "FAL_KEY", "prompt": "FAL API key", "url": "https://fal.ai/dashboard/keys"}, - ], - }, - ], - }, - { - "name": "Browser Automation", - "icon": "๐ŸŒ", - "description": "Control a cloud browser for web interactions", - "providers": [ - { - "name": "Browserbase", - "tag": "Cloud browser with stealth mode", - "env_vars": [ - {"key": "BROWSERBASE_API_KEY", "prompt": "Browserbase API key", "url": "https://browserbase.com"}, - {"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"}, - ], - "post_setup": "browserbase", - }, - ], - }, - { - "name": "Smart Home", - "icon": "๐Ÿ ", - "description": "Control Home Assistant lights, switches, and devices", - "providers": [ - { - "name": "Home Assistant", - "tag": "REST API integration", - "env_vars": [ - {"key": "HASS_TOKEN", "prompt": "Home Assistant Long-Lived Access Token"}, - {"key": "HASS_URL", "prompt": "Home Assistant URL", "default": "http://homeassistant.local:8123"}, - ], - }, - ], - }, - { - "name": "RL Training", - "icon": "๐Ÿงช", - "description": "Run reinforcement learning training jobs", - "requires_python": (3, 11), - "providers": [ - { - "name": "Tinker / Atropos", - "tag": "RL training platform", - "env_vars": [ - {"key": "TINKER_API_KEY", "prompt": "Tinker API key", "url": "https://tinker-console.thinkingmachines.ai/keys"}, - {"key": "WANDB_API_KEY", "prompt": "WandB API key", "url": "https://wandb.ai/authorize"}, - ], - "post_setup": "rl_training", - }, - ], - }, - { - "name": "GitHub Integration", - "icon": "๐Ÿ”ง", - "description": "Higher rate limits for Skills Hub + PR publishing", - "providers": [ - { - "name": "GitHub Personal Access Token", - "tag": "For skill search, install, and publishing", - "env_vars": [ - {"key": "GITHUB_TOKEN", "prompt": "GitHub Token (ghp_...)", "url": "https://github.com/settings/tokens"}, - ], - }, - ], - }, -] - - -def _run_post_setup(post_setup_key: str): - """Run post-setup hooks for tools that need extra installation steps.""" - if post_setup_key == "browserbase": - import shutil - node_modules = PROJECT_ROOT / "node_modules" / "agent-browser" - if not node_modules.exists() and shutil.which("npm"): - print_info(" Installing Node.js dependencies for browser tools...") - import subprocess - result = subprocess.run( - ["npm", "install", "--silent"], - capture_output=True, text=True, cwd=str(PROJECT_ROOT) - ) - if result.returncode == 0: - print_success(" Node.js dependencies installed") - else: - print_warning(" npm install failed โ€” run manually: cd ~/.hermes/hermes-agent && npm install") - elif not node_modules.exists(): - print_warning(" Node.js not found โ€” browser tools require: npm install (in the hermes-agent directory)") - - elif post_setup_key == "rl_training": - try: - __import__("tinker_atropos") - except ImportError: - tinker_dir = PROJECT_ROOT / "tinker-atropos" - if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists(): - print_info(" Installing tinker-atropos submodule...") - import subprocess - import shutil - uv_bin = shutil.which("uv") - if uv_bin: - result = subprocess.run( - [uv_bin, "pip", "install", "-e", str(tinker_dir)], - capture_output=True, text=True - ) - else: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-e", str(tinker_dir)], - capture_output=True, text=True - ) - if result.returncode == 0: - print_success(" tinker-atropos installed") - else: - print_warning(" tinker-atropos install failed โ€” run manually:") - print_info(' uv pip install -e "./tinker-atropos"') - else: - print_warning(" tinker-atropos submodule not found โ€” run:") - print_info(" git submodule update --init --recursive") - print_info(' uv pip install -e "./tinker-atropos"') +# Tool categories and provider config are now in tools_config.py (shared +# between `hermes tools` and `hermes setup tools`). # ============================================================================= @@ -1805,187 +1623,17 @@ def setup_gateway(config: dict): # ============================================================================= -# Section 5: Tool Configuration (Category-First UX) +# Section 5: Tool Configuration (delegates to unified tools_config.py) # ============================================================================= def setup_tools(config: dict): - """Configure tools with a category-first UX. + """Configure tools โ€” delegates to the unified tools_command() in tools_config.py. - Instead of showing flat list of API keys, this shows tool categories - (TTS, Web Search, Image Gen, etc.) and lets users pick a provider - within each category. + Both `hermes setup tools` and `hermes tools` use the same flow: + platform selection โ†’ toolset toggles โ†’ provider/API key configuration. """ - print_header("Tool Configuration") - print_info("Select which tools you'd like to enable.") - print_info("For tools with multiple providers, you'll choose one next.") - print_info("You can always reconfigure later with 'hermes setup tools'.") - print() - - # Build checklist from TOOL_CATEGORIES - # NOTE: Do NOT use color() / ANSI codes in menu labels โ€” - # simple_term_menu miscalculates widths and causes garbled redraws. - checklist_labels = [] - for cat in TOOL_CATEGORIES: - icon = cat.get("icon", "") - name = cat["name"] - desc = cat.get("description", "") - - # Check if already configured โ€” plain text only (no ANSI codes) - configured = _is_tool_configured(cat) - status = " [configured]" if configured else "" - - checklist_labels.append(f"{icon} {name} - {desc}{status}") - - # Pre-select tools that are already configured - pre_selected = [i for i, cat in enumerate(TOOL_CATEGORIES) if _is_tool_configured(cat)] - - selected_indices = prompt_checklist( - "Which tools would you like to enable?", - checklist_labels, - pre_selected=pre_selected, - ) - - # For each selected tool, configure its provider - for idx in selected_indices: - cat = TOOL_CATEGORIES[idx] - _configure_tool_category(cat, config) - - save_config(config) - print() - print_success("Tool configuration complete!") - - -def _is_tool_configured(cat: dict) -> bool: - """Check if a tool category has at least one provider configured.""" - for provider in cat["providers"]: - env_vars = provider.get("env_vars", []) - if not env_vars: - # No env vars needed (e.g., Edge TTS) โ€” check if it's the active provider - if provider.get("tts_provider"): - from hermes_cli.config import load_config as _lc - cfg = _lc() - if cfg.get("tts", {}).get("provider") == provider["tts_provider"]: - return True - else: - return True - elif all(get_env_value(v["key"]) for v in env_vars): - return True - return False - - -def _configure_tool_category(cat: dict, config: dict): - """Configure a single tool category โ€” pick provider and enter API keys.""" - icon = cat.get("icon", "") - name = cat["name"] - providers = cat["providers"] - - # Check Python version requirement - if cat.get("requires_python"): - req = cat["requires_python"] - if sys.version_info < req: - print() - print(color(f" โ”€โ”€โ”€ {icon} {name} โ”€โ”€โ”€", Colors.CYAN)) - print_error(f" Requires Python {req[0]}.{req[1]}+ (current: {sys.version_info.major}.{sys.version_info.minor})") - print_info(" Upgrade Python and reinstall to enable this tool.") - return - - if len(providers) == 1: - # Single provider โ€” just configure it directly - provider = providers[0] - print() - print(color(f" โ”€โ”€โ”€ {icon} {name} ({provider['name']}) โ”€โ”€โ”€", Colors.CYAN)) - if provider.get("tag"): - print_info(f" {provider['tag']}") - _configure_provider(provider, config, cat) - else: - # Multiple providers โ€” let user choose - print() - print(color(f" โ”€โ”€โ”€ {icon} {name} โ€” Choose a provider โ”€โ”€โ”€", Colors.CYAN)) - print() - - # NOTE: Do NOT use color() / ANSI codes in menu labels โ€” - # simple_term_menu miscalculates widths and causes garbled redraws. - provider_choices = [] - for p in providers: - tag = f" ({p['tag']})" if p.get("tag") else "" - configured = "" - env_vars = p.get("env_vars", []) - if not env_vars or all(get_env_value(v["key"]) for v in env_vars): - # Check TTS provider match for edge - if p.get("tts_provider"): - if config.get("tts", {}).get("provider") == p["tts_provider"]: - configured = " [active]" - elif not env_vars: - configured = " [active]" - else: - configured = " [configured]" - provider_choices.append(f"{p['name']}{tag}{configured}") - - # Detect current provider as default - default_provider_idx = 0 - for i, p in enumerate(providers): - if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]: - default_provider_idx = i - break - env_vars = p.get("env_vars", []) - if env_vars and all(get_env_value(v["key"]) for v in env_vars): - default_provider_idx = i - break - - provider_idx = prompt_choice("Select provider:", provider_choices, default_provider_idx) - provider = providers[provider_idx] - - _configure_provider(provider, config, cat) - - -def _configure_provider(provider: dict, config: dict, cat: dict): - """Configure a single provider โ€” prompt for API keys and set config values.""" - env_vars = provider.get("env_vars", []) - - # Set TTS provider in config if applicable - if provider.get("tts_provider"): - config.setdefault("tts", {})["provider"] = provider["tts_provider"] - - if not env_vars: - # No API keys needed (e.g., Edge TTS) - print_success(f" {provider['name']} โ€” no configuration needed!") - return - - # Prompt for each required env var - all_configured = True - for var in env_vars: - existing = get_env_value(var["key"]) - if existing: - print_success(f" {var['key']}: already configured") - if prompt_yes_no(f" Update {var.get('prompt', var['key'])}?", False): - value = prompt(f" {var.get('prompt', var['key'])}", password=True) - if value: - save_env_value(var["key"], value) - print_success(" Updated") - else: - url = var.get("url", "") - if url: - print_info(f" Get yours at: {url}") - - default_val = var.get("default", "") - if default_val: - value = prompt(f" {var.get('prompt', var['key'])}", default_val) - else: - value = prompt(f" {var.get('prompt', var['key'])}", password=True) - - if value: - save_env_value(var["key"], value) - print_success(f" โœ“ Saved") - else: - print_warning(f" Skipped") - all_configured = False - - # Run post-setup hooks if needed - if provider.get("post_setup") and all_configured: - _run_post_setup(provider["post_setup"]) - - if all_configured: - print_success(f" {provider['name']} configured!") + from hermes_cli.tools_config import tools_command + tools_command() # ============================================================================= diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 6cfe34923..fd054e1ed 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1,7 +1,10 @@ """ -Interactive tool configuration for Hermes Agent. +Unified tool configuration for Hermes Agent. + +`hermes tools` and `hermes setup tools` both enter this module. +Select a platform โ†’ toggle toolsets on/off โ†’ for newly enabled tools +that need API keys, run through provider-aware configuration. -`hermes tools` โ€” select a platform, then toggle toolsets on/off via checklist. Saves per-platform tool configuration to ~/.hermes/config.yaml under the `platform_toolsets` key. """ @@ -12,9 +15,63 @@ from typing import Dict, List, Set import os -from hermes_cli.config import load_config, save_config, get_env_value, save_env_value +from hermes_cli.config import ( + load_config, save_config, get_env_value, save_env_value, + get_hermes_home, +) from hermes_cli.colors import Colors, color +PROJECT_ROOT = Path(__file__).parent.parent.resolve() + + +# โ”€โ”€โ”€ UI Helpers (shared with setup.py) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def _print_info(text: str): + print(color(f" {text}", Colors.DIM)) + +def _print_success(text: str): + print(color(f"โœ“ {text}", Colors.GREEN)) + +def _print_warning(text: str): + print(color(f"โš  {text}", Colors.YELLOW)) + +def _print_error(text: str): + print(color(f"โœ— {text}", Colors.RED)) + +def _prompt(question: str, default: str = None, password: bool = False) -> str: + if default: + display = f"{question} [{default}]: " + else: + display = f"{question}: " + try: + if password: + import getpass + value = getpass.getpass(color(display, Colors.YELLOW)) + else: + value = input(color(display, Colors.YELLOW)) + return value.strip() or default or "" + except (KeyboardInterrupt, EOFError): + print() + return default or "" + +def _prompt_yes_no(question: str, default: bool = True) -> bool: + default_str = "Y/n" if default else "y/N" + while True: + try: + value = input(color(f"{question} [{default_str}]: ", Colors.YELLOW)).strip().lower() + except (KeyboardInterrupt, EOFError): + print() + return default + if not value: + return default + if value in ('y', 'yes'): + return True + if value in ('n', 'no'): + return False + + +# โ”€โ”€โ”€ Toolset Registry โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Toolsets shown in the configurator, grouped for display. # Each entry: (toolset_name, label, description) # These map to keys in toolsets.py TOOLSETS dict. @@ -49,6 +106,181 @@ PLATFORMS = { } +# โ”€โ”€โ”€ Tool Categories (provider-aware configuration) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Maps toolset keys to their provider options. When a toolset is newly enabled, +# we use this to show provider selection and prompt for the right API keys. +# Toolsets not in this map either need no config or use the simple fallback. + +TOOL_CATEGORIES = { + "tts": { + "name": "Text-to-Speech", + "icon": "๐Ÿ”Š", + "providers": [ + { + "name": "Microsoft Edge TTS", + "tag": "Free - no API key needed", + "env_vars": [], + "tts_provider": "edge", + }, + { + "name": "OpenAI TTS", + "tag": "Premium - high quality voices", + "env_vars": [ + {"key": "VOICE_TOOLS_OPENAI_KEY", "prompt": "OpenAI API key", "url": "https://platform.openai.com/api-keys"}, + ], + "tts_provider": "openai", + }, + { + "name": "ElevenLabs", + "tag": "Premium - most natural voices", + "env_vars": [ + {"key": "ELEVENLABS_API_KEY", "prompt": "ElevenLabs API key", "url": "https://elevenlabs.io/app/settings/api-keys"}, + ], + "tts_provider": "elevenlabs", + }, + ], + }, + "web": { + "name": "Web Search & Extract", + "icon": "๐Ÿ”", + "providers": [ + { + "name": "Firecrawl Cloud", + "tag": "Recommended - hosted service", + "env_vars": [ + {"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"}, + ], + }, + { + "name": "Firecrawl Self-Hosted", + "tag": "Free - run your own instance", + "env_vars": [ + {"key": "FIRECRAWL_API_URL", "prompt": "Your Firecrawl instance URL (e.g., http://localhost:3002)"}, + ], + }, + ], + }, + "image_gen": { + "name": "Image Generation", + "icon": "๐ŸŽจ", + "providers": [ + { + "name": "FAL.ai", + "tag": "FLUX 2 Pro with auto-upscaling", + "env_vars": [ + {"key": "FAL_KEY", "prompt": "FAL API key", "url": "https://fal.ai/dashboard/keys"}, + ], + }, + ], + }, + "browser": { + "name": "Browser Automation", + "icon": "๐ŸŒ", + "providers": [ + { + "name": "Browserbase", + "tag": "Cloud browser with stealth mode", + "env_vars": [ + {"key": "BROWSERBASE_API_KEY", "prompt": "Browserbase API key", "url": "https://browserbase.com"}, + {"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"}, + ], + "post_setup": "browserbase", + }, + ], + }, + "homeassistant": { + "name": "Smart Home", + "icon": "๐Ÿ ", + "providers": [ + { + "name": "Home Assistant", + "tag": "REST API integration", + "env_vars": [ + {"key": "HASS_TOKEN", "prompt": "Home Assistant Long-Lived Access Token"}, + {"key": "HASS_URL", "prompt": "Home Assistant URL", "default": "http://homeassistant.local:8123"}, + ], + }, + ], + }, + "rl": { + "name": "RL Training", + "icon": "๐Ÿงช", + "requires_python": (3, 11), + "providers": [ + { + "name": "Tinker / Atropos", + "tag": "RL training platform", + "env_vars": [ + {"key": "TINKER_API_KEY", "prompt": "Tinker API key", "url": "https://tinker-console.thinkingmachines.ai/keys"}, + {"key": "WANDB_API_KEY", "prompt": "WandB API key", "url": "https://wandb.ai/authorize"}, + ], + "post_setup": "rl_training", + }, + ], + }, +} + +# Simple env-var requirements for toolsets NOT in TOOL_CATEGORIES. +# Used as a fallback for tools like vision/moa that just need an API key. +TOOLSET_ENV_REQUIREMENTS = { + "vision": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")], + "moa": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")], +} + + +# โ”€โ”€โ”€ Post-Setup Hooks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def _run_post_setup(post_setup_key: str): + """Run post-setup hooks for tools that need extra installation steps.""" + import shutil + if post_setup_key == "browserbase": + node_modules = PROJECT_ROOT / "node_modules" / "agent-browser" + if not node_modules.exists() and shutil.which("npm"): + _print_info(" Installing Node.js dependencies for browser tools...") + import subprocess + result = subprocess.run( + ["npm", "install", "--silent"], + capture_output=True, text=True, cwd=str(PROJECT_ROOT) + ) + if result.returncode == 0: + _print_success(" Node.js dependencies installed") + else: + _print_warning(" npm install failed - run manually: cd ~/.hermes/hermes-agent && npm install") + elif not node_modules.exists(): + _print_warning(" Node.js not found - browser tools require: npm install (in hermes-agent directory)") + + elif post_setup_key == "rl_training": + try: + __import__("tinker_atropos") + except ImportError: + tinker_dir = PROJECT_ROOT / "tinker-atropos" + if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists(): + _print_info(" Installing tinker-atropos submodule...") + import subprocess + uv_bin = shutil.which("uv") + if uv_bin: + result = subprocess.run( + [uv_bin, "pip", "install", "-e", str(tinker_dir)], + capture_output=True, text=True + ) + else: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "-e", str(tinker_dir)], + capture_output=True, text=True + ) + if result.returncode == 0: + _print_success(" tinker-atropos installed") + else: + _print_warning(" tinker-atropos install failed - run manually:") + _print_info(' uv pip install -e "./tinker-atropos"') + else: + _print_warning(" tinker-atropos submodule not found - run:") + _print_info(" git submodule update --init --recursive") + _print_info(' uv pip install -e "./tinker-atropos"') + + +# โ”€โ”€โ”€ Platform / Toolset Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _get_enabled_platforms() -> List[str]: """Return platform keys that are configured (have tokens or are CLI).""" enabled = ["cli"] @@ -97,6 +329,28 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[ save_config(config) +def _toolset_has_keys(ts_key: str) -> bool: + """Check if a toolset's required API keys are configured.""" + # Check TOOL_CATEGORIES first (provider-aware) + cat = TOOL_CATEGORIES.get(ts_key) + if cat: + for provider in cat["providers"]: + env_vars = provider.get("env_vars", []) + if not env_vars: + return True # Free provider (e.g., Edge TTS) + if all(get_env_value(v["key"]) for v in env_vars): + return True + return False + + # Fallback to simple requirements + requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, []) + if not requirements: + return True + return all(get_env_value(var) for var, _ in requirements) + + +# โ”€โ”€โ”€ Menu Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _prompt_choice(question: str, choices: list, default: int = 0) -> int: """Single-select menu (arrow keys).""" print(color(question, Colors.YELLOW)) @@ -114,7 +368,7 @@ def _prompt_choice(question: str, choices: list, default: int = 0) -> int: ) idx = menu.show() if idx is None: - sys.exit(0) + return default print() return idx except (ImportError, NotImplementedError): @@ -132,15 +386,7 @@ def _prompt_choice(question: str, choices: list, default: int = 0) -> int: return idx except (ValueError, KeyboardInterrupt, EOFError): print() - sys.exit(0) - - -def _toolset_has_keys(ts_key: str) -> bool: - """Check if a toolset's required API keys are configured.""" - requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, []) - if not requirements: - return True - return all(get_env_value(var) for var, _ in requirements) + return default def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str]: @@ -150,8 +396,8 @@ def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str labels = [] for ts_key, ts_label, ts_desc in CONFIGURABLE_TOOLSETS: suffix = "" - if not _toolset_has_keys(ts_key) and TOOLSET_ENV_REQUIREMENTS.get(ts_key): - suffix = " โš  no API key" + if not _toolset_has_keys(ts_key) and (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)): + suffix = " [no API key]" labels.append(f"{ts_label} ({ts_desc}){suffix}") pre_selected_indices = [ @@ -302,77 +548,294 @@ def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str return {CONFIGURABLE_TOOLSETS[i][0] for i in selected} -# Map toolset keys to the env vars they require and where to get them -TOOLSET_ENV_REQUIREMENTS = { - "web": [("FIRECRAWL_API_KEY", "https://firecrawl.dev/")], - "browser": [("BROWSERBASE_API_KEY", "https://browserbase.com/"), - ("BROWSERBASE_PROJECT_ID", None)], - "vision": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")], - "image_gen": [("FAL_KEY", "https://fal.ai/")], - "moa": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")], - "tts": [], # Edge TTS is free, no key needed - "rl": [("TINKER_API_KEY", "https://tinker-console.thinkingmachines.ai/keys"), - ("WANDB_API_KEY", "https://wandb.ai/authorize")], - "homeassistant": [("HASS_TOKEN", "Home Assistant > Profile > Long-Lived Access Tokens"), - ("HASS_URL", None)], -} +# โ”€โ”€โ”€ Provider-Aware Configuration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def _configure_toolset(ts_key: str, config: dict): + """Configure a toolset - provider selection + API keys. + + Uses TOOL_CATEGORIES for provider-aware config, falls back to simple + env var prompts for toolsets not in TOOL_CATEGORIES. + """ + cat = TOOL_CATEGORIES.get(ts_key) + + if cat: + _configure_tool_category(ts_key, cat, config) + else: + # Simple fallback for vision, moa, etc. + _configure_simple_requirements(ts_key) -def _check_and_prompt_requirements(newly_enabled: Set[str]): - """Check if newly enabled toolsets have missing API keys and offer to set them up.""" - for ts_key in sorted(newly_enabled): - requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, []) - if not requirements: - continue +def _configure_tool_category(ts_key: str, cat: dict, config: dict): + """Configure a tool category with provider selection.""" + icon = cat.get("icon", "") + name = cat["name"] + providers = cat["providers"] - missing = [(var, url) for var, url in requirements if not get_env_value(var)] - if not missing: - continue - - ts_label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) - print() - print(color(f" โš  {ts_label} requires configuration:", Colors.YELLOW)) - - for var, url in missing: - if url: - print(color(f" {var}", Colors.CYAN) + color(f" ({url})", Colors.DIM)) - else: - print(color(f" {var}", Colors.CYAN)) - - print() - try: - response = input(color(" Set up now? [Y/n] ", Colors.YELLOW)).strip().lower() - except (KeyboardInterrupt, EOFError): + # Check Python version requirement + if cat.get("requires_python"): + req = cat["requires_python"] + if sys.version_info < req: print() - continue + _print_error(f" {name} requires Python {req[0]}.{req[1]}+ (current: {sys.version_info.major}.{sys.version_info.minor})") + _print_info(" Upgrade Python and reinstall to enable this tool.") + return - if response in ("", "y", "yes"): - for var, url in missing: - if url: - print(color(f" Get key at: {url}", Colors.DIM)) - try: - import getpass - value = getpass.getpass(color(f" {var}: ", Colors.YELLOW)) - except (KeyboardInterrupt, EOFError): - print() - break - if value.strip(): - save_env_value(var, value.strip()) - print(color(f" โœ“ Saved", Colors.GREEN)) + if len(providers) == 1: + # Single provider - configure directly + provider = providers[0] + print() + print(color(f" --- {icon} {name} ({provider['name']}) ---", Colors.CYAN)) + if provider.get("tag"): + _print_info(f" {provider['tag']}") + _configure_provider(provider, config) + else: + # Multiple providers - let user choose + print() + print(color(f" --- {icon} {name} - Choose a provider ---", Colors.CYAN)) + print() + + # Plain text labels only (no ANSI codes in menu items) + provider_choices = [] + for p in providers: + tag = f" ({p['tag']})" if p.get("tag") else "" + configured = "" + env_vars = p.get("env_vars", []) + if not env_vars or all(get_env_value(v["key"]) for v in env_vars): + if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]: + configured = " [active]" + elif not env_vars: + configured = " [active]" if config.get("tts", {}).get("provider", "edge") == p.get("tts_provider", "") else "" else: - print(color(f" Skipped", Colors.DIM)) + configured = " [configured]" + provider_choices.append(f"{p['name']}{tag}{configured}") + + # Detect current provider as default + default_idx = 0 + for i, p in enumerate(providers): + if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]: + default_idx = i + break + env_vars = p.get("env_vars", []) + if env_vars and all(get_env_value(v["key"]) for v in env_vars): + default_idx = i + break + + provider_idx = _prompt_choice(" Select provider:", provider_choices, default_idx) + _configure_provider(providers[provider_idx], config) + + +def _configure_provider(provider: dict, config: dict): + """Configure a single provider - prompt for API keys and set config.""" + env_vars = provider.get("env_vars", []) + + # Set TTS provider in config if applicable + if provider.get("tts_provider"): + config.setdefault("tts", {})["provider"] = provider["tts_provider"] + + if not env_vars: + _print_success(f" {provider['name']} - no configuration needed!") + return + + # Prompt for each required env var + all_configured = True + for var in env_vars: + existing = get_env_value(var["key"]) + if existing: + _print_success(f" {var['key']}: already configured") + # Don't ask to update - this is a new enable flow. + # Reconfigure is handled separately. else: - print(color(" Skipped โ€” configure later with 'hermes setup'", Colors.DIM)) + url = var.get("url", "") + if url: + _print_info(f" Get yours at: {url}") + + default_val = var.get("default", "") + if default_val: + value = _prompt(f" {var.get('prompt', var['key'])}", default_val) + else: + value = _prompt(f" {var.get('prompt', var['key'])}", password=True) + + if value: + save_env_value(var["key"], value) + _print_success(f" Saved") + else: + _print_warning(f" Skipped") + all_configured = False + + # Run post-setup hooks if needed + if provider.get("post_setup") and all_configured: + _run_post_setup(provider["post_setup"]) + + if all_configured: + _print_success(f" {provider['name']} configured!") -def tools_command(args): - """Entry point for `hermes tools`.""" +def _configure_simple_requirements(ts_key: str): + """Simple fallback for toolsets that just need env vars (no provider selection).""" + requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, []) + if not requirements: + return + + missing = [(var, url) for var, url in requirements if not get_env_value(var)] + if not missing: + return + + ts_label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) + print() + print(color(f" {ts_label} requires configuration:", Colors.YELLOW)) + + for var, url in missing: + if url: + _print_info(f" Get key at: {url}") + value = _prompt(f" {var}", password=True) + if value and value.strip(): + save_env_value(var, value.strip()) + _print_success(f" Saved") + else: + _print_warning(f" Skipped") + + +def _reconfigure_tool(config: dict): + """Let user reconfigure an existing tool's provider or API key.""" + # Build list of configurable tools that are currently set up + configurable = [] + for ts_key, ts_label, _ in CONFIGURABLE_TOOLSETS: + cat = TOOL_CATEGORIES.get(ts_key) + reqs = TOOLSET_ENV_REQUIREMENTS.get(ts_key) + if cat or reqs: + if _toolset_has_keys(ts_key): + configurable.append((ts_key, ts_label)) + + if not configurable: + _print_info("No configured tools to reconfigure.") + return + + choices = [label for _, label in configurable] + choices.append("Cancel") + + idx = _prompt_choice(" Which tool would you like to reconfigure?", choices, len(choices) - 1) + + if idx >= len(configurable): + return # Cancel + + ts_key, ts_label = configurable[idx] + cat = TOOL_CATEGORIES.get(ts_key) + + if cat: + _configure_tool_category_for_reconfig(ts_key, cat, config) + else: + _reconfigure_simple_requirements(ts_key) + + save_config(config) + + +def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict): + """Reconfigure a tool category - provider selection + API key update.""" + icon = cat.get("icon", "") + name = cat["name"] + providers = cat["providers"] + + if len(providers) == 1: + provider = providers[0] + print() + print(color(f" --- {icon} {name} ({provider['name']}) ---", Colors.CYAN)) + _reconfigure_provider(provider, config) + else: + print() + print(color(f" --- {icon} {name} - Choose a provider ---", Colors.CYAN)) + print() + + provider_choices = [] + for p in providers: + tag = f" ({p['tag']})" if p.get("tag") else "" + configured = "" + env_vars = p.get("env_vars", []) + if not env_vars or all(get_env_value(v["key"]) for v in env_vars): + if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]: + configured = " [active]" + elif not env_vars: + configured = "" + else: + configured = " [configured]" + provider_choices.append(f"{p['name']}{tag}{configured}") + + default_idx = 0 + for i, p in enumerate(providers): + if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]: + default_idx = i + break + env_vars = p.get("env_vars", []) + if env_vars and all(get_env_value(v["key"]) for v in env_vars): + default_idx = i + break + + provider_idx = _prompt_choice(" Select provider:", provider_choices, default_idx) + _reconfigure_provider(providers[provider_idx], config) + + +def _reconfigure_provider(provider: dict, config: dict): + """Reconfigure a provider - update API keys.""" + env_vars = provider.get("env_vars", []) + + if provider.get("tts_provider"): + config.setdefault("tts", {})["provider"] = provider["tts_provider"] + _print_success(f" TTS provider set to: {provider['tts_provider']}") + + if not env_vars: + _print_success(f" {provider['name']} - no configuration needed!") + return + + for var in env_vars: + existing = get_env_value(var["key"]) + if existing: + _print_info(f" {var['key']}: configured ({existing[:8]}...)") + url = var.get("url", "") + if url: + _print_info(f" Get yours at: {url}") + default_val = var.get("default", "") + value = _prompt(f" {var.get('prompt', var['key'])} (Enter to keep current)", password=not default_val) + if value and value.strip(): + save_env_value(var["key"], value.strip()) + _print_success(f" Updated") + else: + _print_info(f" Kept current") + + +def _reconfigure_simple_requirements(ts_key: str): + """Reconfigure simple env var requirements.""" + requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, []) + if not requirements: + return + + ts_label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) + print() + print(color(f" {ts_label}:", Colors.CYAN)) + + for var, url in requirements: + existing = get_env_value(var) + if existing: + _print_info(f" {var}: configured ({existing[:8]}...)") + if url: + _print_info(f" Get key at: {url}") + value = _prompt(f" {var} (Enter to keep current)", password=True) + if value and value.strip(): + save_env_value(var, value.strip()) + _print_success(f" Updated") + else: + _print_info(f" Kept current") + + +# โ”€โ”€โ”€ Main Entry Point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def tools_command(args=None): + """Entry point for `hermes tools` and `hermes setup tools`.""" config = load_config() enabled_platforms = _get_enabled_platforms() print() print(color("โš• Hermes Tool Configuration", Colors.CYAN, Colors.BOLD)) print(color(" Enable or disable tools per platform.", Colors.DIM)) + print(color(" Tools that need API keys will be configured when enabled.", Colors.DIM)) print() # Build platform choices @@ -380,22 +843,28 @@ def tools_command(args): platform_keys = [] for pkey in enabled_platforms: pinfo = PLATFORMS[pkey] - # Count currently enabled toolsets current = _get_platform_tools(config, pkey) count = len(current) total = len(CONFIGURABLE_TOOLSETS) platform_choices.append(f"Configure {pinfo['label']} ({count}/{total} enabled)") platform_keys.append(pkey) - platform_choices.append("Done โ€” save and exit") + platform_choices.append("Reconfigure an existing tool's provider or API key") + platform_choices.append("Done") while True: - idx = _prompt_choice("Select a platform to configure:", platform_choices, default=0) + idx = _prompt_choice("Select an option:", platform_choices, default=0) # "Done" selected - if idx == len(platform_keys): + if idx == len(platform_keys) + 1: break + # "Reconfigure" selected + if idx == len(platform_keys): + _reconfigure_tool(config) + print() + continue + pkey = platform_keys[idx] pinfo = PLATFORMS[pkey] @@ -418,11 +887,15 @@ def tools_command(args): label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts) print(color(f" - {label}", Colors.RED)) - # Prompt for missing API keys on newly enabled toolsets + # Configure newly enabled toolsets that need API keys if added: - _check_and_prompt_requirements(added) + for ts_key in sorted(added): + if TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key): + if not _toolset_has_keys(ts_key): + _configure_toolset(ts_key, config) _save_platform_tools(config, pkey, new_enabled) + save_config(config) print(color(f" โœ“ Saved {pinfo['label']} configuration", Colors.GREEN)) else: print(color(f" No changes to {pinfo['label']}", Colors.DIM))