""" Interactive tool configuration for Hermes Agent. `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. """ import sys from pathlib import Path 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.colors import Colors, color # Toolsets shown in the configurator, grouped for display. # Each entry: (toolset_name, label, description) # These map to keys in toolsets.py TOOLSETS dict. CONFIGURABLE_TOOLSETS = [ ("web", "๐Ÿ” Web Search & Scraping", "web_search, web_extract"), ("browser", "๐ŸŒ Browser Automation", "navigate, click, type, scroll"), ("terminal", "๐Ÿ’ป Terminal & Processes", "terminal, process"), ("file", "๐Ÿ“ File Operations", "read, write, patch, search"), ("code_execution", "โšก Code Execution", "execute_code"), ("vision", "๐Ÿ‘๏ธ Vision / Image Analysis", "vision_analyze"), ("image_gen", "๐ŸŽจ Image Generation", "image_generate"), ("moa", "๐Ÿง  Mixture of Agents", "mixture_of_agents"), ("tts", "๐Ÿ”Š Text-to-Speech", "text_to_speech"), ("skills", "๐Ÿ“š Skills", "list, view, manage"), ("todo", "๐Ÿ“‹ Task Planning", "todo"), ("memory", "๐Ÿ’พ Memory", "persistent memory across sessions"), ("session_search", "๐Ÿ”Ž Session Search", "search past conversations"), ("clarify", "โ“ Clarifying Questions", "clarify"), ("delegation", "๐Ÿ‘ฅ Task Delegation", "delegate_task"), ("cronjob", "โฐ Cron Jobs", "schedule, list, remove"), ("rl", "๐Ÿงช RL Training", "Tinker-Atropos training tools"), ("homeassistant", "๐Ÿ  Home Assistant", "smart home device control"), ] # Platform display config PLATFORMS = { "cli": {"label": "๐Ÿ–ฅ๏ธ CLI", "default_toolset": "hermes-cli"}, "telegram": {"label": "๐Ÿ“ฑ Telegram", "default_toolset": "hermes-telegram"}, "discord": {"label": "๐Ÿ’ฌ Discord", "default_toolset": "hermes-discord"}, "slack": {"label": "๐Ÿ’ผ Slack", "default_toolset": "hermes-slack"}, "whatsapp": {"label": "๐Ÿ“ฑ WhatsApp", "default_toolset": "hermes-whatsapp"}, } def _get_enabled_platforms() -> List[str]: """Return platform keys that are configured (have tokens or are CLI).""" enabled = ["cli"] if get_env_value("TELEGRAM_BOT_TOKEN"): enabled.append("telegram") if get_env_value("DISCORD_BOT_TOKEN"): enabled.append("discord") if get_env_value("SLACK_BOT_TOKEN"): enabled.append("slack") if get_env_value("WHATSAPP_ENABLED"): enabled.append("whatsapp") return enabled def _get_platform_tools(config: dict, platform: str) -> Set[str]: """Resolve which individual toolset names are enabled for a platform.""" from toolsets import resolve_toolset, TOOLSETS platform_toolsets = config.get("platform_toolsets", {}) toolset_names = platform_toolsets.get(platform) if not toolset_names or not isinstance(toolset_names, list): default_ts = PLATFORMS[platform]["default_toolset"] toolset_names = [default_ts] # Resolve to individual tool names, then map back to which # configurable toolsets are covered all_tool_names = set() for ts_name in toolset_names: all_tool_names.update(resolve_toolset(ts_name)) # Map individual tool names back to configurable toolset keys enabled_toolsets = set() for ts_key, _, _ in CONFIGURABLE_TOOLSETS: ts_tools = set(resolve_toolset(ts_key)) if ts_tools and ts_tools.issubset(all_tool_names): enabled_toolsets.add(ts_key) return enabled_toolsets def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[str]): """Save the selected toolset keys for a platform to config.""" config.setdefault("platform_toolsets", {}) config["platform_toolsets"][platform] = sorted(enabled_toolset_keys) save_config(config) def _prompt_choice(question: str, choices: list, default: int = 0) -> int: """Single-select menu (arrow keys).""" print(color(question, Colors.YELLOW)) try: from simple_term_menu import TerminalMenu menu = TerminalMenu( [f" {c}" for c in choices], cursor_index=default, menu_cursor="โ†’ ", menu_cursor_style=("fg_green", "bold"), menu_highlight_style=("fg_green",), cycle_cursor=True, clear_screen=False, ) idx = menu.show() if idx is None: sys.exit(0) print() return idx except (ImportError, NotImplementedError): for i, c in enumerate(choices): marker = "โ—" if i == default else "โ—‹" style = Colors.GREEN if i == default else "" print(color(f" {marker} {c}", style) if style else f" {marker} {c}") while True: try: val = input(color(f" Select [1-{len(choices)}] ({default + 1}): ", Colors.DIM)) if not val: return default idx = int(val) - 1 if 0 <= idx < len(choices): 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) def _prompt_toolset_checklist(platform_label: str, enabled: Set[str]) -> Set[str]: """Multi-select checklist of toolsets. Returns set of selected toolset keys.""" import platform as _platform 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" labels.append(f"{ts_label} ({ts_desc}){suffix}") pre_selected_indices = [ i for i, (ts_key, _, _) in enumerate(CONFIGURABLE_TOOLSETS) if ts_key in enabled ] # simple_term_menu multi-select has rendering bugs on macOS terminals, # so we use a curses-based fallback there. use_term_menu = _platform.system() != "Darwin" if use_term_menu: try: from simple_term_menu import TerminalMenu print(color(f"Tools for {platform_label}", Colors.YELLOW)) print(color(" SPACE to toggle, ENTER to confirm.", Colors.DIM)) print() menu_items = [f" {label}" for label in labels] menu = TerminalMenu( menu_items, multi_select=True, show_multi_select_hint=False, multi_select_cursor="[โœ“] ", multi_select_select_on_accept=False, multi_select_empty_ok=True, preselected_entries=pre_selected_indices if pre_selected_indices else None, menu_cursor="โ†’ ", menu_cursor_style=("fg_green", "bold"), menu_highlight_style=("fg_green",), cycle_cursor=True, clear_screen=False, clear_menu_on_exit=False, ) menu.show() if menu.chosen_menu_entries is None: return enabled selected_indices = list(menu.chosen_menu_indices or []) return {CONFIGURABLE_TOOLSETS[i][0] for i in selected_indices} except (ImportError, NotImplementedError): pass # fall through to curses/numbered fallback # Curses-based multi-select โ€” arrow keys + space to toggle + enter to confirm. # Used on macOS (where simple_term_menu ghosts) and as a fallback. try: import curses selected = set(pre_selected_indices) result_holder = [None] def _curses_checklist(stdscr): curses.curs_set(0) if curses.has_colors(): curses.start_color() curses.use_default_colors() curses.init_pair(1, curses.COLOR_GREEN, -1) curses.init_pair(2, curses.COLOR_YELLOW, -1) curses.init_pair(3, 8, -1) # dim gray cursor = 0 scroll_offset = 0 while True: stdscr.clear() max_y, max_x = stdscr.getmaxyx() header = f"Tools for {platform_label} โ€” โ†‘โ†“ navigate, SPACE toggle, ENTER confirm" try: stdscr.addnstr(0, 0, header, max_x - 1, curses.A_BOLD | curses.color_pair(2) if curses.has_colors() else curses.A_BOLD) except curses.error: pass visible_rows = max_y - 3 if cursor < scroll_offset: scroll_offset = cursor elif cursor >= scroll_offset + visible_rows: scroll_offset = cursor - visible_rows + 1 for draw_i, i in enumerate(range(scroll_offset, min(len(labels), scroll_offset + visible_rows))): y = draw_i + 2 if y >= max_y - 1: break check = "โœ“" if i in selected else " " arrow = "โ†’" if i == cursor else " " line = f" {arrow} [{check}] {labels[i]}" attr = curses.A_NORMAL if i == cursor: attr = curses.A_BOLD if curses.has_colors(): attr |= curses.color_pair(1) try: stdscr.addnstr(y, 0, line, max_x - 1, attr) except curses.error: pass stdscr.refresh() key = stdscr.getch() if key in (curses.KEY_UP, ord('k')): cursor = (cursor - 1) % len(labels) elif key in (curses.KEY_DOWN, ord('j')): cursor = (cursor + 1) % len(labels) elif key == ord(' '): if cursor in selected: selected.discard(cursor) else: selected.add(cursor) elif key in (curses.KEY_ENTER, 10, 13): result_holder[0] = {CONFIGURABLE_TOOLSETS[i][0] for i in selected} return elif key in (27, ord('q')): # ESC or q result_holder[0] = enabled return curses.wrapper(_curses_checklist) return result_holder[0] if result_holder[0] is not None else enabled except Exception: pass # fall through to numbered toggle # Final fallback: numbered toggle (Windows without curses, etc.) selected = set(pre_selected_indices) print(color(f"\n Tools for {platform_label}", Colors.YELLOW)) print(color(" Toggle by number, Enter to confirm.\n", Colors.DIM)) while True: for i, label in enumerate(labels): marker = color("[โœ“]", Colors.GREEN) if i in selected else "[ ]" print(f" {marker} {i + 1:>2}. {label}") print() try: val = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip() if not val: break idx = int(val) - 1 if 0 <= idx < len(labels): if idx in selected: selected.discard(idx) else: selected.add(idx) except (ValueError, KeyboardInterrupt, EOFError): return enabled print() 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)], } 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 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): print() continue 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)) else: print(color(f" Skipped", Colors.DIM)) else: print(color(" Skipped โ€” configure later with 'hermes setup'", Colors.DIM)) def tools_command(args): """Entry point for `hermes 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() # Build platform choices platform_choices = [] 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") while True: idx = _prompt_choice("Select a platform to configure:", platform_choices, default=0) # "Done" selected if idx == len(platform_keys): break pkey = platform_keys[idx] pinfo = PLATFORMS[pkey] # Get current enabled toolsets for this platform current_enabled = _get_platform_tools(config, pkey) # Show checklist new_enabled = _prompt_toolset_checklist(pinfo["label"], current_enabled) if new_enabled != current_enabled: added = new_enabled - current_enabled removed = current_enabled - new_enabled if added: for ts in sorted(added): label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts) print(color(f" + {label}", Colors.GREEN)) if removed: for ts in sorted(removed): 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 if added: _check_and_prompt_requirements(added) _save_platform_tools(config, pkey, new_enabled) print(color(f" โœ“ Saved {pinfo['label']} configuration", Colors.GREEN)) else: print(color(f" No changes to {pinfo['label']}", Colors.DIM)) print() # Update the choice label with new count new_count = len(_get_platform_tools(config, pkey)) total = len(CONFIGURABLE_TOOLSETS) platform_choices[idx] = f"Configure {pinfo['label']} ({new_count}/{total} enabled)" print() print(color(" Tool configuration saved to ~/.hermes/config.yaml", Colors.DIM)) print(color(" Changes take effect on next 'hermes' or gateway restart.", Colors.DIM)) print()