feat: unify hermes tools and hermes setup tools into single flow

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.
This commit is contained in:
teknium1 2026-03-06 18:11:35 -08:00
parent 0111c9848d
commit 82b18e8ac2
2 changed files with 558 additions and 437 deletions

View file

@ -460,191 +460,9 @@ def _prompt_container_resources(config: dict):
pass 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 = [ # Tool categories and provider config are now in tools_config.py (shared
{ # between `hermes tools` and `hermes setup tools`).
"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"')
# ============================================================================= # =============================================================================
@ -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): 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 Both `hermes setup tools` and `hermes tools` use the same flow:
(TTS, Web Search, Image Gen, etc.) and lets users pick a provider platform selection toolset toggles provider/API key configuration.
within each category.
""" """
print_header("Tool Configuration") from hermes_cli.tools_config import tools_command
print_info("Select which tools you'd like to enable.") tools_command()
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!")
# ============================================================================= # =============================================================================

View file

@ -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 Saves per-platform tool configuration to ~/.hermes/config.yaml under
the `platform_toolsets` key. the `platform_toolsets` key.
""" """
@ -12,9 +15,63 @@ from typing import Dict, List, Set
import os 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 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. # Toolsets shown in the configurator, grouped for display.
# Each entry: (toolset_name, label, description) # Each entry: (toolset_name, label, description)
# These map to keys in toolsets.py TOOLSETS dict. # 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]: def _get_enabled_platforms() -> List[str]:
"""Return platform keys that are configured (have tokens or are CLI).""" """Return platform keys that are configured (have tokens or are CLI)."""
enabled = ["cli"] enabled = ["cli"]
@ -97,6 +329,28 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[
save_config(config) 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: def _prompt_choice(question: str, choices: list, default: int = 0) -> int:
"""Single-select menu (arrow keys).""" """Single-select menu (arrow keys)."""
print(color(question, Colors.YELLOW)) print(color(question, Colors.YELLOW))
@ -114,7 +368,7 @@ def _prompt_choice(question: str, choices: list, default: int = 0) -> int:
) )
idx = menu.show() idx = menu.show()
if idx is None: if idx is None:
sys.exit(0) return default
print() print()
return idx return idx
except (ImportError, NotImplementedError): except (ImportError, NotImplementedError):
@ -132,15 +386,7 @@ def _prompt_choice(question: str, choices: list, default: int = 0) -> int:
return idx return idx
except (ValueError, KeyboardInterrupt, EOFError): except (ValueError, KeyboardInterrupt, EOFError):
print() print()
sys.exit(0) return default
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]: 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 = [] labels = []
for ts_key, ts_label, ts_desc in CONFIGURABLE_TOOLSETS: for ts_key, ts_label, ts_desc in CONFIGURABLE_TOOLSETS:
suffix = "" suffix = ""
if not _toolset_has_keys(ts_key) and TOOLSET_ENV_REQUIREMENTS.get(ts_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" suffix = " [no API key]"
labels.append(f"{ts_label} ({ts_desc}){suffix}") labels.append(f"{ts_label} ({ts_desc}){suffix}")
pre_selected_indices = [ 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} return {CONFIGURABLE_TOOLSETS[i][0] for i in selected}
# Map toolset keys to the env vars they require and where to get them # ─── Provider-Aware Configuration ────────────────────────────────────────────
TOOLSET_ENV_REQUIREMENTS = {
"web": [("FIRECRAWL_API_KEY", "https://firecrawl.dev/")], def _configure_toolset(ts_key: str, config: dict):
"browser": [("BROWSERBASE_API_KEY", "https://browserbase.com/"), """Configure a toolset - provider selection + API keys.
("BROWSERBASE_PROJECT_ID", None)],
"vision": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")], Uses TOOL_CATEGORIES for provider-aware config, falls back to simple
"image_gen": [("FAL_KEY", "https://fal.ai/")], env var prompts for toolsets not in TOOL_CATEGORIES.
"moa": [("OPENROUTER_API_KEY", "https://openrouter.ai/keys")], """
"tts": [], # Edge TTS is free, no key needed cat = TOOL_CATEGORIES.get(ts_key)
"rl": [("TINKER_API_KEY", "https://tinker-console.thinkingmachines.ai/keys"),
("WANDB_API_KEY", "https://wandb.ai/authorize")], if cat:
"homeassistant": [("HASS_TOKEN", "Home Assistant > Profile > Long-Lived Access Tokens"), _configure_tool_category(ts_key, cat, config)
("HASS_URL", None)], else:
} # Simple fallback for vision, moa, etc.
_configure_simple_requirements(ts_key)
def _check_and_prompt_requirements(newly_enabled: Set[str]): def _configure_tool_category(ts_key: str, cat: dict, config: dict):
"""Check if newly enabled toolsets have missing API keys and offer to set them up.""" """Configure a tool category with provider selection."""
for ts_key in sorted(newly_enabled): icon = cat.get("icon", "")
requirements = TOOLSET_ENV_REQUIREMENTS.get(ts_key, []) name = cat["name"]
if not requirements: providers = cat["providers"]
continue
missing = [(var, url) for var, url in requirements if not get_env_value(var)] # Check Python version requirement
if not missing: if cat.get("requires_python"):
continue req = cat["requires_python"]
if sys.version_info < req:
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() 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"): if len(providers) == 1:
for var, url in missing: # Single provider - configure directly
if url: provider = providers[0]
print(color(f" Get key at: {url}", Colors.DIM)) print()
try: print(color(f" --- {icon} {name} ({provider['name']}) ---", Colors.CYAN))
import getpass if provider.get("tag"):
value = getpass.getpass(color(f" {var}: ", Colors.YELLOW)) _print_info(f" {provider['tag']}")
except (KeyboardInterrupt, EOFError): _configure_provider(provider, config)
print() else:
break # Multiple providers - let user choose
if value.strip(): print()
save_env_value(var, value.strip()) print(color(f" --- {icon} {name} - Choose a provider ---", Colors.CYAN))
print(color(f" ✓ Saved", Colors.GREEN)) 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: 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: 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): def _configure_simple_requirements(ts_key: str):
"""Entry point for `hermes tools`.""" """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() config = load_config()
enabled_platforms = _get_enabled_platforms() enabled_platforms = _get_enabled_platforms()
print() print()
print(color("⚕ Hermes Tool Configuration", Colors.CYAN, Colors.BOLD)) print(color("⚕ Hermes Tool Configuration", Colors.CYAN, Colors.BOLD))
print(color(" Enable or disable tools per platform.", Colors.DIM)) 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() print()
# Build platform choices # Build platform choices
@ -380,22 +843,28 @@ def tools_command(args):
platform_keys = [] platform_keys = []
for pkey in enabled_platforms: for pkey in enabled_platforms:
pinfo = PLATFORMS[pkey] pinfo = PLATFORMS[pkey]
# Count currently enabled toolsets
current = _get_platform_tools(config, pkey) current = _get_platform_tools(config, pkey)
count = len(current) count = len(current)
total = len(CONFIGURABLE_TOOLSETS) total = len(CONFIGURABLE_TOOLSETS)
platform_choices.append(f"Configure {pinfo['label']} ({count}/{total} enabled)") platform_choices.append(f"Configure {pinfo['label']} ({count}/{total} enabled)")
platform_keys.append(pkey) 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: 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 # "Done" selected
if idx == len(platform_keys): if idx == len(platform_keys) + 1:
break break
# "Reconfigure" selected
if idx == len(platform_keys):
_reconfigure_tool(config)
print()
continue
pkey = platform_keys[idx] pkey = platform_keys[idx]
pinfo = PLATFORMS[pkey] pinfo = PLATFORMS[pkey]
@ -418,11 +887,15 @@ def tools_command(args):
label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts) label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts), ts)
print(color(f" - {label}", Colors.RED)) 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: 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_platform_tools(config, pkey, new_enabled)
save_config(config)
print(color(f" ✓ Saved {pinfo['label']} configuration", Colors.GREEN)) print(color(f" ✓ Saved {pinfo['label']} configuration", Colors.GREEN))
else: else:
print(color(f" No changes to {pinfo['label']}", Colors.DIM)) print(color(f" No changes to {pinfo['label']}", Colors.DIM))