Implement configuration migration system and enhance CLI setup

- Introduced a configuration migration system to check for missing required environment variables and outdated config fields, prompting users for necessary inputs during updates.
- Enhanced the CLI with new commands for checking and migrating configuration, improving user experience by providing clear guidance on required settings.
- Updated the setup wizard to detect existing installations and offer quick setup options for missing configurations, streamlining the user onboarding process.
- Improved messaging throughout the CLI to inform users about the status of their configuration and any required actions.
This commit is contained in:
teknium1
2026-02-02 19:39:23 -08:00
parent fef504f038
commit 3ee788dacc
3 changed files with 598 additions and 105 deletions

View File

@@ -16,7 +16,7 @@ import os
import sys
import subprocess
from pathlib import Path
from typing import Dict, Any, Optional
from typing import Dict, Any, Optional, List, Tuple
import yaml
@@ -98,8 +98,219 @@ DEFAULT_CONFIG = {
"compact": False,
"personality": "kawaii",
},
# Config schema version - bump this when adding new required fields
"_config_version": 1,
}
# =============================================================================
# Config Migration System
# =============================================================================
# Required environment variables with metadata for migration prompts
REQUIRED_ENV_VARS = {
"OPENROUTER_API_KEY": {
"description": "OpenRouter API key (required for vision, web scraping, and tools)",
"prompt": "OpenRouter API key",
"url": "https://openrouter.ai/keys",
"required": True,
"password": True,
},
}
# Optional environment variables that enhance functionality
OPTIONAL_ENV_VARS = {
"FIRECRAWL_API_KEY": {
"description": "Firecrawl API key for web search and scraping",
"prompt": "Firecrawl API key",
"url": "https://firecrawl.dev/",
"tools": ["web_search", "web_extract"],
"password": True,
},
"BROWSERBASE_API_KEY": {
"description": "Browserbase API key for browser automation",
"prompt": "Browserbase API key",
"url": "https://browserbase.com/",
"tools": ["browser_navigate", "browser_click", "etc."],
"password": True,
},
"BROWSERBASE_PROJECT_ID": {
"description": "Browserbase project ID",
"prompt": "Browserbase project ID",
"url": "https://browserbase.com/",
"tools": ["browser_navigate", "browser_click", "etc."],
"password": False,
},
"FAL_KEY": {
"description": "FAL API key for image generation",
"prompt": "FAL API key",
"url": "https://fal.ai/",
"tools": ["image_generate"],
"password": True,
},
"OPENAI_BASE_URL": {
"description": "Custom OpenAI-compatible API endpoint URL",
"prompt": "API base URL (e.g., https://api.example.com/v1)",
"url": None,
"password": False,
},
"OPENAI_API_KEY": {
"description": "API key for custom OpenAI-compatible endpoint",
"prompt": "API key for custom endpoint",
"url": None,
"password": True,
},
}
def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
"""
Check which environment variables are missing.
Returns list of dicts with var info for missing variables.
"""
missing = []
# Check required vars
for var_name, info in REQUIRED_ENV_VARS.items():
if not get_env_value(var_name):
missing.append({"name": var_name, **info, "is_required": True})
# Check optional vars (if not required_only)
if not required_only:
for var_name, info in OPTIONAL_ENV_VARS.items():
if not get_env_value(var_name):
missing.append({"name": var_name, **info, "is_required": False})
return missing
def get_missing_config_fields() -> List[Dict[str, Any]]:
"""
Check which config fields are missing or outdated.
Returns list of missing/outdated fields.
"""
config = load_config()
missing = []
# Check for new top-level keys in DEFAULT_CONFIG
for key, default_value in DEFAULT_CONFIG.items():
if key.startswith('_'):
continue # Skip internal keys
if key not in config:
missing.append({
"key": key,
"default": default_value,
"description": f"New config section: {key}",
})
elif isinstance(default_value, dict):
# Check nested keys
for subkey, subvalue in default_value.items():
if subkey not in config.get(key, {}):
missing.append({
"key": f"{key}.{subkey}",
"default": subvalue,
"description": f"New config option: {key}.{subkey}",
})
return missing
def check_config_version() -> Tuple[int, int]:
"""
Check config version.
Returns (current_version, latest_version).
"""
config = load_config()
current = config.get("_config_version", 0)
latest = DEFAULT_CONFIG.get("_config_version", 1)
return current, latest
def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:
"""
Migrate config to latest version, prompting for new required fields.
Args:
interactive: If True, prompt user for missing values
quiet: If True, suppress output
Returns:
Dict with migration results: {"env_added": [...], "config_added": [...], "warnings": [...]}
"""
results = {"env_added": [], "config_added": [], "warnings": []}
# Check config version
current_ver, latest_ver = check_config_version()
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")
# Check for missing required env vars
missing_env = get_missing_env_vars(required_only=True)
if missing_env and not quiet:
print("\n⚠️ Missing required environment variables:")
for var in missing_env:
print(f"{var['name']}: {var['description']}")
if interactive and missing_env:
print("\nLet's configure them now:\n")
for var in missing_env:
if var.get("url"):
print(f" Get your key at: {var['url']}")
if var.get("password"):
import getpass
value = getpass.getpass(f" {var['prompt']}: ")
else:
value = input(f" {var['prompt']}: ").strip()
if value:
save_env_value(var["name"], value)
results["env_added"].append(var["name"])
print(f" ✓ Saved {var['name']}")
else:
results["warnings"].append(f"Skipped {var['name']} - some features may not work")
print()
# Check for missing config fields
missing_config = get_missing_config_fields()
if missing_config:
config = load_config()
for field in missing_config:
key = field["key"]
default = field["default"]
# Add with default value
if "." in key:
# Nested key
parent, child = key.split(".", 1)
if parent not in config:
config[parent] = {}
config[parent][child] = default
else:
config[key] = default
results["config_added"].append(key)
if not quiet:
print(f" ✓ Added {key} = {default}")
# Update version and save
config["_config_version"] = latest_ver
save_config(config)
elif current_ver < latest_ver:
# Just update version
config = load_config()
config["_config_version"] = latest_ver
save_config(config)
return results
def load_config() -> Dict[str, Any]:
"""Load configuration from ~/.hermes/config.yaml."""
@@ -395,6 +606,106 @@ def config_command(args):
elif subcmd == "env-path":
print(get_env_path())
elif subcmd == "migrate":
print()
print(color("🔄 Checking configuration for updates...", Colors.CYAN, Colors.BOLD))
print()
# Check what's missing
missing_env = get_missing_env_vars(required_only=False)
missing_config = get_missing_config_fields()
current_ver, latest_ver = check_config_version()
if not missing_env and not missing_config and current_ver >= latest_ver:
print(color("✓ Configuration is up to date!", Colors.GREEN))
print()
return
# Show what needs to be updated
if current_ver < latest_ver:
print(f" Config version: {current_ver}{latest_ver}")
if missing_config:
print(f"\n {len(missing_config)} new config option(s) will be added with defaults")
required_missing = [v for v in missing_env if v.get("is_required")]
optional_missing = [v for v in missing_env if not v.get("is_required")]
if required_missing:
print(f"\n ⚠️ {len(required_missing)} required API key(s) missing:")
for var in required_missing:
print(f"{var['name']}")
if optional_missing:
print(f"\n {len(optional_missing)} optional API key(s) not configured:")
for var in optional_missing:
tools = var.get("tools", [])
tools_str = f" (enables: {', '.join(tools[:2])})" if tools else ""
print(f"{var['name']}{tools_str}")
print()
# Run migration
results = migrate_config(interactive=True, quiet=False)
print()
if results["env_added"] or results["config_added"]:
print(color("✓ Configuration updated!", Colors.GREEN))
if results["warnings"]:
print()
for warning in results["warnings"]:
print(color(f" ⚠️ {warning}", Colors.YELLOW))
print()
elif subcmd == "check":
# Non-interactive check for what's missing
print()
print(color("📋 Configuration Status", Colors.CYAN, Colors.BOLD))
print()
current_ver, latest_ver = check_config_version()
if current_ver >= latest_ver:
print(f" Config version: {current_ver}")
else:
print(color(f" Config version: {current_ver}{latest_ver} (update available)", Colors.YELLOW))
print()
print(color(" Required:", Colors.BOLD))
for var_name in REQUIRED_ENV_VARS:
if get_env_value(var_name):
print(f"{var_name}")
else:
print(color(f"{var_name} (missing)", Colors.RED))
print()
print(color(" Optional:", Colors.BOLD))
for var_name, info in OPTIONAL_ENV_VARS.items():
if get_env_value(var_name):
print(f"{var_name}")
else:
tools = info.get("tools", [])
tools_str = f"{', '.join(tools[:2])}" if tools else ""
print(color(f"{var_name}{tools_str}", Colors.DIM))
missing_config = get_missing_config_fields()
if missing_config:
print()
print(color(f" {len(missing_config)} new config option(s) available", Colors.YELLOW))
print(f" Run 'hermes config migrate' to add them")
print()
else:
print(f"Unknown config command: {subcmd}")
print()
print("Available commands:")
print(" hermes config Show current configuration")
print(" hermes config edit Open config in editor")
print(" hermes config set K V Set a config value")
print(" hermes config check Check for missing/outdated config")
print(" hermes config migrate Update config with new options")
print(" hermes config path Show config file path")
print(" hermes config env-path Show .env file path")
sys.exit(1)

View File

@@ -170,6 +170,47 @@ def cmd_update(args):
print("→ Updating Node.js dependencies...")
subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False)
print()
print("✓ Code updated!")
# Check for config migrations
print()
print("→ Checking configuration for new options...")
from hermes_cli.config import (
get_missing_env_vars, get_missing_config_fields,
check_config_version, migrate_config
)
missing_env = get_missing_env_vars(required_only=True)
missing_config = get_missing_config_fields()
current_ver, latest_ver = check_config_version()
needs_migration = missing_env or missing_config or current_ver < latest_ver
if needs_migration:
print()
if missing_env:
print(f" ⚠️ {len(missing_env)} new required setting(s) need configuration")
if missing_config:
print(f" {len(missing_config)} new config option(s) available")
print()
response = input("Would you like to configure them now? [Y/n]: ").strip().lower()
if response in ('', 'y', 'yes'):
print()
results = migrate_config(interactive=True, quiet=False)
if results["env_added"] or results["config_added"]:
print()
print("✓ Configuration updated!")
else:
print()
print("Skipped. Run 'hermes config migrate' later to configure.")
else:
print(" ✓ Configuration is up to date")
print()
print("✓ Update complete!")
print()
@@ -380,6 +421,12 @@ For more help on a command:
# config env-path
config_env = config_subparsers.add_parser("env-path", help="Print .env file path")
# config check
config_check = config_subparsers.add_parser("check", help="Check for missing/outdated config")
# config migrate
config_migrate = config_subparsers.add_parser("migrate", help="Update config with new options")
config_parser.set_defaults(func=cmd_config)
# =========================================================================

View File

@@ -152,6 +152,106 @@ def prompt_yes_no(question: str, default: bool = True) -> bool:
print_error("Please enter 'y' or 'n'")
def _print_setup_summary(config: dict, hermes_home):
"""Print the setup completion summary."""
# Tool availability summary
print()
print_header("Tool Availability Summary")
tool_status = []
# OpenRouter (required for vision, moa)
if get_env_value('OPENROUTER_API_KEY'):
tool_status.append(("Vision (image analysis)", True, None))
tool_status.append(("Mixture of Agents", True, None))
else:
tool_status.append(("Vision (image analysis)", False, "OPENROUTER_API_KEY"))
tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY"))
# Firecrawl (web tools)
if get_env_value('FIRECRAWL_API_KEY'):
tool_status.append(("Web Search & Extract", True, None))
else:
tool_status.append(("Web Search & Extract", False, "FIRECRAWL_API_KEY"))
# Browserbase (browser tools)
if get_env_value('BROWSERBASE_API_KEY'):
tool_status.append(("Browser Automation", True, None))
else:
tool_status.append(("Browser Automation", False, "BROWSERBASE_API_KEY"))
# FAL (image generation)
if get_env_value('FAL_KEY'):
tool_status.append(("Image Generation", True, None))
else:
tool_status.append(("Image Generation", False, "FAL_KEY"))
# Terminal (always available if system deps met)
tool_status.append(("Terminal/Commands", True, None))
# Skills (always available if skills dir exists)
tool_status.append(("Skills Knowledge Base", True, None))
# Print status
available_count = sum(1 for _, avail, _ in tool_status if avail)
total_count = len(tool_status)
print_info(f"{available_count}/{total_count} tool categories available:")
print()
for name, available, missing_var in tool_status:
if available:
print(f" {color('', Colors.GREEN)} {name}")
else:
print(f" {color('', Colors.RED)} {name} {color(f'(missing {missing_var})', Colors.DIM)}")
print()
disabled_tools = [(name, var) for name, avail, var in tool_status if not avail]
if disabled_tools:
print_warning("Some tools are disabled. Run 'hermes setup' again to configure them,")
print_warning("or edit ~/.hermes/.env directly to add the missing API keys.")
print()
# Done banner
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.GREEN))
print(color("│ ✓ Setup Complete! │", Colors.GREEN))
print(color("└─────────────────────────────────────────────────────────┘", Colors.GREEN))
print()
# Show file locations prominently
print(color("📁 All your files are in ~/.hermes/:", Colors.CYAN, Colors.BOLD))
print()
print(f" {color('Settings:', Colors.YELLOW)} {get_config_path()}")
print(f" {color('API Keys:', Colors.YELLOW)} {get_env_path()}")
print(f" {color('Data:', Colors.YELLOW)} {hermes_home}/cron/, sessions/, logs/")
print()
print(color("" * 60, Colors.DIM))
print()
print(color("📝 To edit your configuration:", Colors.CYAN, Colors.BOLD))
print()
print(f" {color('hermes config', Colors.GREEN)} View current settings")
print(f" {color('hermes config edit', Colors.GREEN)} Open config in your editor")
print(f" {color('hermes config set KEY VALUE', Colors.GREEN)}")
print(f" Set a specific value")
print()
print(f" Or edit the files directly:")
print(f" {color(f'nano {get_config_path()}', Colors.DIM)}")
print(f" {color(f'nano {get_env_path()}', Colors.DIM)}")
print()
print(color("" * 60, Colors.DIM))
print()
print(color("🚀 Ready to go!", Colors.CYAN, Colors.BOLD))
print()
print(f" {color('hermes', Colors.GREEN)} Start chatting")
print(f" {color('hermes gateway', Colors.GREEN)} Start messaging gateway")
print(f" {color('hermes doctor', Colors.GREEN)} Check for issues")
print()
def run_setup_wizard(args):
"""Run the interactive setup wizard."""
ensure_hermes_home()
@@ -159,6 +259,24 @@ def run_setup_wizard(args):
config = load_config()
hermes_home = get_hermes_home()
# Check if this is an existing installation with config
is_existing = get_env_value("OPENROUTER_API_KEY") is not None or get_config_path().exists()
# Import migration helpers
from hermes_cli.config import (
get_missing_env_vars, get_missing_config_fields,
check_config_version, migrate_config,
REQUIRED_ENV_VARS, OPTIONAL_ENV_VARS
)
# Check what's missing
missing_required = [v for v in get_missing_env_vars(required_only=False) if v.get("is_required")]
missing_optional = [v for v in get_missing_env_vars(required_only=False) if not v.get("is_required")]
missing_config = get_missing_config_fields()
current_ver, latest_ver = check_config_version()
has_missing = missing_required or missing_optional or missing_config or current_ver < latest_ver
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA))
print(color("│ 🦋 Hermes Agent Setup Wizard │", Colors.MAGENTA))
@@ -167,8 +285,126 @@ def run_setup_wizard(args):
print(color("│ Press Ctrl+C at any time to exit. │", Colors.MAGENTA))
print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA))
# If existing installation, show what's missing and offer quick mode
quick_mode = False
if is_existing and has_missing:
print()
print_header("Existing Installation Detected")
print_success("You already have Hermes configured!")
print()
if missing_required:
print_warning(f" {len(missing_required)} required setting(s) missing:")
for var in missing_required:
print(f"{var['name']}")
if missing_optional:
print_info(f" {len(missing_optional)} optional tool(s) not configured:")
for var in missing_optional[:3]: # Show first 3
tools = var.get("tools", [])
tools_str = f"{', '.join(tools[:2])}" if tools else ""
print(f"{var['name']}{tools_str}")
if len(missing_optional) > 3:
print(f" • ...and {len(missing_optional) - 3} more")
if missing_config:
print_info(f" {len(missing_config)} new config option(s) available")
print()
setup_choices = [
"Quick setup - just configure missing items",
"Full setup - reconfigure everything",
"Skip - exit setup"
]
choice = prompt_choice("What would you like to do?", setup_choices, 0)
if choice == 0:
quick_mode = True
elif choice == 2:
print()
print_info("Exiting. Run 'hermes setup' again when ready.")
return
# choice == 1 continues with full setup
elif is_existing and not has_missing:
print()
print_header("Configuration Status")
print_success("Your configuration is complete!")
print()
if not prompt_yes_no("Would you like to reconfigure anyway?", False):
print()
print_info("Exiting. Your configuration is already set up.")
print_info(f"Config: {get_config_path()}")
print_info(f"Secrets: {get_env_path()}")
return
# Quick mode: only configure missing items
if quick_mode:
print()
print_header("Quick Setup - Missing Items Only")
# Handle missing required env vars
if missing_required:
for var in missing_required:
print()
print(color(f" {var['name']}", Colors.CYAN))
print_info(f" {var.get('description', '')}")
if var.get("url"):
print_info(f" Get key at: {var['url']}")
if var.get("password"):
value = prompt(f" {var.get('prompt', var['name'])}", password=True)
else:
value = prompt(f" {var.get('prompt', var['name'])}")
if value:
save_env_value(var["name"], value)
print_success(f" Saved {var['name']}")
else:
print_warning(f" Skipped {var['name']}")
# Handle missing optional env vars
if missing_optional:
print()
print_header("Optional Tools (Quick Setup)")
for var in missing_optional:
tools = var.get("tools", [])
tools_str = f" (enables: {', '.join(tools[:2])})" if tools else ""
if prompt_yes_no(f"Configure {var['name']}{tools_str}?", False):
if var.get("url"):
print_info(f" Get key at: {var['url']}")
if var.get("password"):
value = prompt(f" {var.get('prompt', var['name'])}", password=True)
else:
value = prompt(f" {var.get('prompt', var['name'])}")
if value:
save_env_value(var["name"], value)
print_success(f" Saved")
# Handle missing config fields
if missing_config:
print()
print_info(f"Adding {len(missing_config)} new config option(s) with defaults...")
for field in missing_config:
print_success(f" Added {field['key']} = {field['default']}")
# Update config version
config["_config_version"] = latest_ver
save_config(config)
# Jump to summary
_print_setup_summary(config, hermes_home)
return
# =========================================================================
# Step 0: Show paths
# Step 0: Show paths (full setup)
# =========================================================================
print_header("Configuration Location")
print_info(f"Config file: {get_config_path()}")
@@ -586,108 +822,7 @@ def run_setup_wizard(args):
print_success(" Configured ✓")
# =========================================================================
# Save config
# Save config and show summary
# =========================================================================
save_config(config)
# =========================================================================
# Tool Availability Summary
# =========================================================================
print()
print_header("Tool Availability Summary")
# Check which tools are available
tool_status = []
# OpenRouter (required for vision, moa)
if get_env_value('OPENROUTER_API_KEY'):
tool_status.append(("Vision (image analysis)", True, None))
tool_status.append(("Mixture of Agents", True, None))
else:
tool_status.append(("Vision (image analysis)", False, "OPENROUTER_API_KEY"))
tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY"))
# Firecrawl (web tools)
if get_env_value('FIRECRAWL_API_KEY'):
tool_status.append(("Web Search & Extract", True, None))
else:
tool_status.append(("Web Search & Extract", False, "FIRECRAWL_API_KEY"))
# Browserbase (browser tools)
if get_env_value('BROWSERBASE_API_KEY'):
tool_status.append(("Browser Automation", True, None))
else:
tool_status.append(("Browser Automation", False, "BROWSERBASE_API_KEY"))
# FAL (image generation)
if get_env_value('FAL_KEY'):
tool_status.append(("Image Generation", True, None))
else:
tool_status.append(("Image Generation", False, "FAL_KEY"))
# Terminal (always available if system deps met)
tool_status.append(("Terminal/Commands", True, None))
# Skills (always available if skills dir exists)
tool_status.append(("Skills Knowledge Base", True, None))
# Print status
available_count = sum(1 for _, avail, _ in tool_status if avail)
total_count = len(tool_status)
print_info(f"{available_count}/{total_count} tool categories available:")
print()
for name, available, missing_var in tool_status:
if available:
print(f" {color('', Colors.GREEN)} {name}")
else:
print(f" {color('', Colors.RED)} {name} {color(f'(missing {missing_var})', Colors.DIM)}")
print()
disabled_tools = [(name, var) for name, avail, var in tool_status if not avail]
if disabled_tools:
print_warning("Some tools are disabled. Run 'hermes setup' again to configure them,")
print_warning("or edit ~/.hermes/.env directly to add the missing API keys.")
print()
# =========================================================================
# Done!
# =========================================================================
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.GREEN))
print(color("│ ✓ Setup Complete! │", Colors.GREEN))
print(color("└─────────────────────────────────────────────────────────┘", Colors.GREEN))
print()
# Show file locations prominently
print(color("📁 All your files are in ~/.hermes/:", Colors.CYAN, Colors.BOLD))
print()
print(f" {color('Settings:', Colors.YELLOW)} {get_config_path()}")
print(f" {color('API Keys:', Colors.YELLOW)} {get_env_path()}")
print(f" {color('Data:', Colors.YELLOW)} {hermes_home}/cron/, sessions/, logs/")
print()
print(color("" * 60, Colors.DIM))
print()
print(color("📝 To edit your configuration:", Colors.CYAN, Colors.BOLD))
print()
print(f" {color('hermes config', Colors.GREEN)} View current settings")
print(f" {color('hermes config edit', Colors.GREEN)} Open config in your editor")
print(f" {color('hermes config set KEY VALUE', Colors.GREEN)}")
print(f" Set a specific value")
print()
print(f" Or edit the files directly:")
print(f" {color(f'nano {get_config_path()}', Colors.DIM)}")
print(f" {color(f'nano {get_env_path()}', Colors.DIM)}")
print()
print(color("" * 60, Colors.DIM))
print()
print(color("🚀 Ready to go!", Colors.CYAN, Colors.BOLD))
print()
print(f" {color('hermes', Colors.GREEN)} Start chatting")
print(f" {color('hermes gateway', Colors.GREEN)} Start messaging gateway")
print(f" {color('hermes doctor', Colors.GREEN)} Check for issues")
print()
_print_setup_summary(config, hermes_home)