diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index c471b1b9d8..f533a93844 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -21,78 +21,133 @@ logger = logging.getLogger(__name__) PROJECT_ROOT = Path(__file__).parent.parent.resolve() + +def _model_config_dict(config: Dict[str, Any]) -> Dict[str, Any]: + current_model = config.get("model") + if isinstance(current_model, dict): + return dict(current_model) + if isinstance(current_model, str) and current_model.strip(): + return {"default": current_model.strip()} + return {} + + +def _set_model_provider( + config: Dict[str, Any], provider_id: str, base_url: str = "" +) -> None: + model_cfg = _model_config_dict(config) + model_cfg["provider"] = provider_id + if base_url: + model_cfg["base_url"] = base_url.rstrip("/") + else: + model_cfg.pop("base_url", None) + config["model"] = model_cfg + + +def _set_default_model(config: Dict[str, Any], model_name: str) -> None: + if not model_name: + return + model_cfg = _model_config_dict(config) + model_cfg["default"] = model_name + config["model"] = model_cfg + + +def _sync_model_from_disk(config: Dict[str, Any]) -> None: + disk_model = load_config().get("model") + if isinstance(disk_model, dict): + model_cfg = _model_config_dict(config) + model_cfg.update(disk_model) + config["model"] = model_cfg + elif isinstance(disk_model, str) and disk_model.strip(): + _set_default_model(config, disk_model.strip()) + + # Import config helpers from hermes_cli.config import ( - get_hermes_home, get_config_path, get_env_path, - load_config, save_config, save_env_value, get_env_value, - ensure_hermes_home, DEFAULT_CONFIG + get_hermes_home, + get_config_path, + get_env_path, + load_config, + save_config, + save_env_value, + get_env_value, + ensure_hermes_home, + DEFAULT_CONFIG, ) from hermes_cli.colors import Colors, color + def print_header(title: str): """Print a section header.""" print() print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD)) + def print_info(text: str): """Print info text.""" print(color(f" {text}", Colors.DIM)) + def print_success(text: str): """Print success message.""" print(color(f"✓ {text}", Colors.GREEN)) + def print_warning(text: str): """Print warning message.""" print(color(f"⚠ {text}", Colors.YELLOW)) + def print_error(text: str): """Print error message.""" print(color(f"✗ {text}", Colors.RED)) + def prompt(question: str, default: str = None, password: bool = False) -> str: """Prompt for input with optional default.""" 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() sys.exit(1) + def prompt_choice(question: str, choices: list, default: int = 0) -> int: """Prompt for a choice from a list with arrow key navigation. - + Escape keeps the current default (skips the question). Ctrl+C exits the wizard. """ print(color(question, Colors.YELLOW)) - + # Try to use interactive menu if available try: from simple_term_menu import TerminalMenu import re - + # Strip emoji characters — simple_term_menu miscalculates visual # width of emojis, causing duplicated/garbled lines on redraw. _emoji_re = re.compile( "[\U0001f300-\U0001f9ff\U00002600-\U000027bf\U0000fe00-\U0000fe0f" - "\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", flags=re.UNICODE + "\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", + flags=re.UNICODE, ) menu_choices = [f" {_emoji_re.sub('', choice).strip()}" for choice in choices] - + print_info(" ↑/↓ Navigate Enter Select Esc Skip Ctrl+C Exit") - + terminal_menu = TerminalMenu( menu_choices, cursor_index=default, @@ -102,7 +157,7 @@ def prompt_choice(question: str, choices: list, default: int = 0) -> int: cycle_cursor=True, clear_screen=False, ) - + idx = terminal_menu.show() if idx is None: # User pressed Escape — keep current value print_info(f" Skipped (keeping current)") @@ -110,7 +165,7 @@ def prompt_choice(question: str, choices: list, default: int = 0) -> int: return default print() # Add newline after selection return idx - + except (ImportError, NotImplementedError): pass except Exception as e: @@ -128,7 +183,9 @@ def prompt_choice(question: str, choices: list, default: int = 0) -> int: while True: try: - value = input(color(f" Select [1-{len(choices)}] ({default + 1}): ", Colors.DIM)) + value = input( + color(f" Select [1-{len(choices)}] ({default + 1}): ", Colors.DIM) + ) if not value: return default idx = int(value) - 1 @@ -141,22 +198,27 @@ def prompt_choice(question: str, choices: list, default: int = 0) -> int: print() sys.exit(1) + def prompt_yes_no(question: str, default: bool = True) -> bool: """Prompt for yes/no. Ctrl+C exits, empty input returns default.""" default_str = "Y/n" if default else "y/N" - + while True: try: - value = input(color(f"{question} [{default_str}]: ", Colors.YELLOW)).strip().lower() + value = ( + input(color(f"{question} [{default_str}]: ", Colors.YELLOW)) + .strip() + .lower() + ) except (KeyboardInterrupt, EOFError): print() sys.exit(1) - + if not value: return default - if value in ('y', 'yes'): + if value in ("y", "yes"): return True - if value in ('n', 'no'): + if value in ("n", "no"): return False print_error("Please enter 'y' or 'n'") @@ -164,40 +226,41 @@ def prompt_yes_no(question: str, default: bool = True) -> bool: def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list: """ Display a multi-select checklist and return the indices of selected items. - + Each item in `items` is a display string. `pre_selected` is a list of indices that should be checked by default. A "Continue →" option is appended at the end — the user toggles items with Space and confirms with Enter on "Continue →". - + Falls back to a numbered toggle interface when simple_term_menu is unavailable. - + Returns: List of selected indices (not including the Continue option). """ if pre_selected is None: pre_selected = [] - + print(color(title, Colors.YELLOW)) print_info(" SPACE Toggle ENTER Confirm ESC Skip Ctrl+C Exit") print() - + try: from simple_term_menu import TerminalMenu import re - + # Strip emoji characters from menu labels — simple_term_menu miscalculates # visual width of emojis on macOS, causing duplicated/garbled lines. _emoji_re = re.compile( "[\U0001f300-\U0001f9ff\U00002600-\U000027bf\U0000fe00-\U0000fe0f" - "\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", flags=re.UNICODE + "\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", + flags=re.UNICODE, ) menu_items = [f" {_emoji_re.sub('', item).strip()}" for item in items] - + # Map pre-selected indices to the actual menu entry strings preselected = [menu_items[i] for i in pre_selected if i < len(menu_items)] - + terminal_menu = TerminalMenu( menu_items, multi_select=True, @@ -212,28 +275,30 @@ def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list cycle_cursor=True, clear_screen=False, ) - + terminal_menu.show() - + if terminal_menu.chosen_menu_entries is None: print_info(" Skipped (keeping current)") return list(pre_selected) - + selected = list(terminal_menu.chosen_menu_indices or []) return selected - + except (ImportError, NotImplementedError): # Fallback: numbered toggle interface (simple_term_menu doesn't support Windows) selected = set(pre_selected) - + while True: for i, item in enumerate(items): marker = color("[✓]", Colors.GREEN) if i in selected else "[ ]" print(f" {marker} {i + 1}. {item}") print() - + try: - value = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip() + value = input( + color(" Toggle # (or Enter to confirm): ", Colors.DIM) + ).strip() if not value: break idx = int(value) - 1 @@ -249,10 +314,10 @@ def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list except (KeyboardInterrupt, EOFError): print() return [] - + # Clear and redraw (simple approach) print() - + return sorted(selected) @@ -289,111 +354,137 @@ def _print_setup_summary(config: dict, hermes_home): # Tool availability summary print() print_header("Tool Availability Summary") - + tool_status = [] - + # OpenRouter (required for vision, moa) - if get_env_value('OPENROUTER_API_KEY'): + 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') or get_env_value('FIRECRAWL_API_URL'): + if get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"): tool_status.append(("Web Search & Extract", True, None)) else: tool_status.append(("Web Search & Extract", False, "FIRECRAWL_API_KEY")) - + # Browser tools (local Chromium or Browserbase cloud) import shutil - _ab_found = shutil.which("agent-browser") or (Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser").exists() - if get_env_value('BROWSERBASE_API_KEY'): + + _ab_found = ( + shutil.which("agent-browser") + or ( + Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser" + ).exists() + ) + if get_env_value("BROWSERBASE_API_KEY"): tool_status.append(("Browser Automation (Browserbase)", True, None)) elif _ab_found: tool_status.append(("Browser Automation (local)", True, None)) else: - tool_status.append(("Browser Automation", False, "npm install -g agent-browser")) - + tool_status.append( + ("Browser Automation", False, "npm install -g agent-browser") + ) + # FAL (image generation) - if get_env_value('FAL_KEY'): + if get_env_value("FAL_KEY"): tool_status.append(("Image Generation", True, None)) else: tool_status.append(("Image Generation", False, "FAL_KEY")) - + # TTS — show configured provider - tts_provider = config.get('tts', {}).get('provider', 'edge') - if tts_provider == 'elevenlabs' and get_env_value('ELEVENLABS_API_KEY'): + tts_provider = config.get("tts", {}).get("provider", "edge") + if tts_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"): tool_status.append(("Text-to-Speech (ElevenLabs)", True, None)) - elif tts_provider == 'openai' and get_env_value('VOICE_TOOLS_OPENAI_KEY'): + elif tts_provider == "openai" and get_env_value("VOICE_TOOLS_OPENAI_KEY"): tool_status.append(("Text-to-Speech (OpenAI)", True, None)) else: tool_status.append(("Text-to-Speech (Edge TTS)", True, None)) - + # Tinker + WandB (RL training) - if get_env_value('TINKER_API_KEY') and get_env_value('WANDB_API_KEY'): + if get_env_value("TINKER_API_KEY") and get_env_value("WANDB_API_KEY"): tool_status.append(("RL Training (Tinker)", True, None)) - elif get_env_value('TINKER_API_KEY'): + elif get_env_value("TINKER_API_KEY"): tool_status.append(("RL Training (Tinker)", False, "WANDB_API_KEY")) else: tool_status.append(("RL Training (Tinker)", False, "TINKER_API_KEY")) - + # Home Assistant - if get_env_value('HASS_TOKEN'): + if get_env_value("HASS_TOKEN"): tool_status.append(("Smart Home (Home Assistant)", True, None)) - + # Skills Hub - if get_env_value('GITHUB_TOKEN'): + if get_env_value("GITHUB_TOKEN"): tool_status.append(("Skills Hub (GitHub)", True, None)) else: tool_status.append(("Skills Hub (GitHub)", False, "GITHUB_TOKEN")) - + # Terminal (always available if system deps met) tool_status.append(("Terminal/Commands", True, None)) - + # Task planning (always available, in-memory) tool_status.append(("Task Planning (todo)", True, None)) - + # Skills (always available -- bundled skills + user-created skills) tool_status.append(("Skills (view, create, edit)", 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( + 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 tools' to configure them,") + print_warning( + "Some tools are disabled. Run 'hermes setup tools' 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( + 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( + 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)) @@ -405,7 +496,9 @@ def _print_setup_summary(config: dict, hermes_home): print(f" {color('hermes setup tools', Colors.GREEN)} Configure tool providers") 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 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() @@ -413,7 +506,7 @@ def _print_setup_summary(config: dict, hermes_home): 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)) @@ -426,45 +519,46 @@ def _print_setup_summary(config: dict, hermes_home): def _prompt_container_resources(config: dict): """Prompt for container resource settings (Docker, Singularity, Modal, Daytona).""" - terminal = config.setdefault('terminal', {}) + terminal = config.setdefault("terminal", {}) print() print_info("Container Resource Settings:") # Persistence - current_persist = terminal.get('container_persistent', True) + current_persist = terminal.get("container_persistent", True) persist_label = "yes" if current_persist else "no" print_info(" Persistent filesystem keeps files between sessions.") print_info(" Set to 'no' for ephemeral sandboxes that reset each time.") - persist_str = prompt(f" Persist filesystem across sessions? (yes/no)", persist_label) - terminal['container_persistent'] = persist_str.lower() in ('yes', 'true', 'y', '1') + persist_str = prompt( + f" Persist filesystem across sessions? (yes/no)", persist_label + ) + terminal["container_persistent"] = persist_str.lower() in ("yes", "true", "y", "1") # CPU - current_cpu = terminal.get('container_cpu', 1) + current_cpu = terminal.get("container_cpu", 1) cpu_str = prompt(f" CPU cores", str(current_cpu)) try: - terminal['container_cpu'] = float(cpu_str) + terminal["container_cpu"] = float(cpu_str) except ValueError: pass # Memory - current_mem = terminal.get('container_memory', 5120) + current_mem = terminal.get("container_memory", 5120) mem_str = prompt(f" Memory in MB (5120 = 5GB)", str(current_mem)) try: - terminal['container_memory'] = int(mem_str) + terminal["container_memory"] = int(mem_str) except ValueError: pass # Disk - current_disk = terminal.get('container_disk', 51200) + current_disk = terminal.get("container_disk", 51200) disk_str = prompt(f" Disk in MB (51200 = 50GB)", str(current_disk)) try: - terminal['container_disk'] = int(disk_str) + terminal["container_disk"] = int(disk_str) except ValueError: pass - # Tool categories and provider config are now in tools_config.py (shared # between `hermes tools` and `hermes setup tools`). @@ -473,13 +567,21 @@ def _prompt_container_resources(config: dict): # Section 1: Model & Provider Configuration # ============================================================================= + def setup_model_provider(config: dict): """Configure the inference provider and default model.""" from hermes_cli.auth import ( - get_active_provider, get_provider_auth_state, PROVIDER_REGISTRY, - format_auth_error, AuthError, fetch_nous_models, - resolve_nous_runtime_credentials, _update_config_for_provider, - _login_openai_codex, get_codex_auth_status, DEFAULT_CODEX_BASE_URL, + get_active_provider, + get_provider_auth_state, + PROVIDER_REGISTRY, + format_auth_error, + AuthError, + fetch_nous_models, + resolve_nous_runtime_credentials, + _update_config_for_provider, + _login_openai_codex, + get_codex_auth_status, + DEFAULT_CODEX_BASE_URL, detect_external_credentials, ) @@ -497,14 +599,14 @@ def setup_model_provider(config: dict): print_info("Detected existing credentials:") for cred in detected_creds: if cred["provider"] == "openai-codex": - print_success(f" * {cred['label']} -- select \"OpenAI Codex\" to use it") + print_success(f' * {cred["label"]} -- select "OpenAI Codex" to use it') else: print_info(f" * {cred['label']}") print() # Detect if any provider is already configured has_any_provider = bool(active_oauth or existing_custom or existing_or) - + # Build "keep current" label if active_oauth and active_oauth in PROVIDER_REGISTRY: keep_label = f"Keep current ({PROVIDER_REGISTRY[active_oauth].name})" @@ -528,18 +630,22 @@ def setup_model_provider(config: dict): ] if keep_label: provider_choices.append(keep_label) - + # Default to "Keep current" if a provider exists, otherwise OpenRouter (most common) default_provider = len(provider_choices) - 1 if has_any_provider else 3 - + if not has_any_provider: print_warning("An inference provider is required for Hermes to work.") print() - - provider_idx = prompt_choice("Select your inference provider:", provider_choices, default_provider) + + provider_idx = prompt_choice( + "Select your inference provider:", provider_choices, default_provider + ) # Track which provider was selected for model step - selected_provider = None # "nous", "openai-codex", "openrouter", "custom", or None (keep) + selected_provider = ( + None # "nous", "openai-codex", "openrouter", "custom", or None (keep) + ) nous_models = [] # populated if Nous login succeeds if provider_idx == 0: # Nous Portal API Key (direct) @@ -570,7 +676,12 @@ def setup_model_provider(config: dict): if existing_custom: save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") - _update_config_for_provider("nous-api", "https://inference-api.nousresearch.com/v1") + _update_config_for_provider( + "nous-api", "https://inference-api.nousresearch.com/v1" + ) + _set_model_provider( + config, "nous-api", "https://inference-api.nousresearch.com/v1" + ) elif provider_idx == 1: # Nous Portal selected_provider = "nous" @@ -583,18 +694,26 @@ def setup_model_provider(config: dict): try: from hermes_cli.auth import _login_nous, ProviderConfig import argparse + mock_args = argparse.Namespace( - portal_url=None, inference_url=None, client_id=None, - scope=None, no_browser=False, timeout=15.0, - ca_bundle=None, insecure=False, + portal_url=None, + inference_url=None, + client_id=None, + scope=None, + no_browser=False, + timeout=15.0, + ca_bundle=None, + insecure=False, ) pconfig = PROVIDER_REGISTRY["nous"] _login_nous(mock_args, pconfig) + _sync_model_from_disk(config) # Fetch models for the selection step try: creds = resolve_nous_runtime_credentials( - min_key_ttl_seconds=5 * 60, timeout_seconds=15.0, + min_key_ttl_seconds=5 * 60, + timeout_seconds=15.0, ) nous_models = fetch_nous_models( inference_base_url=creds.get("base_url", ""), @@ -620,6 +739,7 @@ def setup_model_provider(config: dict): try: import argparse + mock_args = argparse.Namespace() _login_openai_codex(mock_args, PROVIDER_REGISTRY["openai-codex"]) # Clear custom endpoint vars that would override provider routing. @@ -627,6 +747,7 @@ def setup_model_provider(config: dict): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) + _set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL) except SystemExit: print_warning("OpenAI Codex login was cancelled or failed.") print_info("You can try again later with: hermes model") @@ -667,11 +788,15 @@ def setup_model_provider(config: dict): # resolver doesn't keep returning the old provider (e.g. Codex). try: from hermes_cli.auth import deactivate_provider + deactivate_provider() except Exception: pass import yaml - config_path = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "config.yaml" + + config_path = ( + Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "config.yaml" + ) try: disk_cfg = {} if config_path.exists(): @@ -683,6 +808,7 @@ def setup_model_provider(config: dict): model_section.pop("base_url", None) # OpenRouter uses default URL disk_cfg["model"] = model_section config_path.write_text(yaml.safe_dump(disk_cfg, sort_keys=False)) + _set_model_provider(config, "openrouter") except Exception as e: logger.debug("Could not save provider to config.yaml: %s", e) @@ -694,15 +820,21 @@ def setup_model_provider(config: dict): current_url = get_env_value("OPENAI_BASE_URL") or "" current_key = get_env_value("OPENAI_API_KEY") - _raw_model = config.get('model', '') - current_model = _raw_model.get('default', '') if isinstance(_raw_model, dict) else (_raw_model or '') + _raw_model = config.get("model", "") + current_model = ( + _raw_model.get("default", "") + if isinstance(_raw_model, dict) + else (_raw_model or "") + ) if current_url: print_info(f" Current URL: {current_url}") if current_key: print_info(f" Current key: {current_key[:8]}... (configured)") - base_url = prompt(" API base URL (e.g., https://api.example.com/v1)", current_url) + base_url = prompt( + " API base URL (e.g., https://api.example.com/v1)", current_url + ) api_key = prompt(" API key", password=True) model_name = prompt(" Model name (e.g., gpt-4, claude-3-opus)", current_model) @@ -711,14 +843,25 @@ def setup_model_provider(config: dict): if api_key: save_env_value("OPENAI_API_KEY", api_key) if model_name: - config['model'] = model_name + _set_default_model(config, model_name) save_env_value("LLM_MODEL", model_name) + try: + from hermes_cli.auth import deactivate_provider + + deactivate_provider() + except Exception: + pass + # Save provider and base_url to config.yaml so the gateway and CLI # both resolve the correct provider without relying on env-var heuristics. if base_url: import yaml - config_path = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "config.yaml" + + config_path = ( + Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) + / "config.yaml" + ) try: disk_cfg = {} if config_path.exists(): @@ -735,6 +878,8 @@ def setup_model_provider(config: dict): except Exception as e: logger.debug("Could not save provider to config.yaml: %s", e) + _set_model_provider(config, "custom", base_url) + print_success("Custom endpoint configured") elif provider_idx == 5: # Z.AI / GLM @@ -772,24 +917,30 @@ def setup_model_provider(config: dict): print() print_info("Detecting your z.ai endpoint...") from hermes_cli.auth import detect_zai_endpoint + detected = detect_zai_endpoint(api_key) if detected: zai_base_url = detected["base_url"] print_success(f"Detected: {detected['label']} endpoint") print_info(f" URL: {detected['base_url']}") if detected["id"].startswith("coding"): - print_info(f" Note: Coding Plan detected — GLM-5 is not available, using {detected['model']}") + print_info( + f" Note: Coding Plan detected — GLM-5 is not available, using {detected['model']}" + ) save_env_value("GLM_BASE_URL", zai_base_url) else: print_warning("Could not verify any z.ai endpoint with this key.") print_info(f" Using default: {zai_base_url}") - print_info(" If you get billing errors, check your plan at https://open.bigmodel.cn/") + print_info( + " If you get billing errors, check your plan at https://open.bigmodel.cn/" + ) # Clear custom endpoint vars if switching if existing_custom: save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("zai", zai_base_url) + _set_model_provider(config, "zai", zai_base_url) elif provider_idx == 6: # Kimi / Moonshot selected_provider = "kimi-coding" @@ -822,6 +973,7 @@ def setup_model_provider(config: dict): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("kimi-coding", pconfig.inference_base_url) + _set_model_provider(config, "kimi-coding", pconfig.inference_base_url) elif provider_idx == 7: # MiniMax selected_provider = "minimax" @@ -854,6 +1006,7 @@ def setup_model_provider(config: dict): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("minimax", pconfig.inference_base_url) + _set_model_provider(config, "minimax", pconfig.inference_base_url) elif provider_idx == 8: # MiniMax China selected_provider = "minimax-cn" @@ -886,32 +1039,50 @@ def setup_model_provider(config: dict): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("minimax-cn", pconfig.inference_base_url) + _set_model_provider(config, "minimax-cn", pconfig.inference_base_url) # else: provider_idx == 9 (Keep current) — only shown when a provider already exists # ── OpenRouter API Key for tools (if not already set) ── # Tools (vision, web, MoA) use OpenRouter independently of the main provider. # Prompt for OpenRouter key if not set and a non-OpenRouter provider was chosen. - if selected_provider in ("nous", "nous-api", "openai-codex", "custom", "zai", "kimi-coding", "minimax", "minimax-cn") and not get_env_value("OPENROUTER_API_KEY"): + if selected_provider in ( + "nous", + "nous-api", + "openai-codex", + "custom", + "zai", + "kimi-coding", + "minimax", + "minimax-cn", + ) and not get_env_value("OPENROUTER_API_KEY"): print() print_header("OpenRouter API Key (for tools)") print_info("Tools like vision analysis, web search, and MoA use OpenRouter") print_info("independently of your main inference provider.") print_info("Get your API key at: https://openrouter.ai/keys") - api_key = prompt(" OpenRouter API key (optional, press Enter to skip)", password=True) + api_key = prompt( + " OpenRouter API key (optional, press Enter to skip)", password=True + ) if api_key: save_env_value("OPENROUTER_API_KEY", api_key) print_success("OpenRouter API key saved (for tools)") else: - print_info("Skipped - some tools (vision, web scraping) won't work without this") + print_info( + "Skipped - some tools (vision, web scraping) won't work without this" + ) # ── Model Selection (adapts based on provider) ── if selected_provider != "custom": # Custom already prompted for model name print_header("Default Model") - _raw_model = config.get('model', 'anthropic/claude-opus-4.6') - current_model = _raw_model.get('default', 'anthropic/claude-opus-4.6') if isinstance(_raw_model, dict) else (_raw_model or 'anthropic/claude-opus-4.6') + _raw_model = config.get("model", "anthropic/claude-opus-4.6") + current_model = ( + _raw_model.get("default", "anthropic/claude-opus-4.6") + if isinstance(_raw_model, dict) + else (_raw_model or "anthropic/claude-opus-4.6") + ) print_info(f"Current: {current_model}") if selected_provider == "nous" and nous_models: @@ -922,18 +1093,24 @@ def setup_model_provider(config: dict): # Post-login validation: warn if current model might not be available if current_model and current_model not in nous_models: - print_warning(f"Your current model ({current_model}) may not be available via Nous Portal.") - print_info("Select a model from the list, or keep current to use it anyway.") + print_warning( + f"Your current model ({current_model}) may not be available via Nous Portal." + ) + print_info( + "Select a model from the list, or keep current to use it anyway." + ) print() - model_idx = prompt_choice("Select default model:", model_choices, len(model_choices) - 1) + model_idx = prompt_choice( + "Select default model:", model_choices, len(model_choices) - 1 + ) if model_idx < len(nous_models): - config['model'] = nous_models[model_idx] + _set_default_model(config, nous_models[model_idx]) elif model_idx == len(model_choices) - 2: # Custom model_name = prompt(" Model name") if model_name: - config['model'] = model_name + _set_default_model(config, model_name) # else: keep current elif selected_provider == "nous": @@ -943,7 +1120,7 @@ def setup_model_provider(config: dict): print_info("Enter a Nous model name manually (e.g., claude-opus-4-6).") custom = prompt(f" Model name (Enter to keep '{current_model}')") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) elif selected_provider == "nous-api": # Nous API key provider — prompt for model manually @@ -951,10 +1128,11 @@ def setup_model_provider(config: dict): print_info("Examples: anthropic/claude-opus-4.6, deepseek/deepseek-r1") custom = prompt(f" Model name (Enter to keep '{current_model}')") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) elif selected_provider == "openai-codex": from hermes_cli.codex_models import get_codex_model_ids + codex_models = get_codex_model_ids() model_choices = codex_models + [f"Keep current ({current_model})"] default_codex = 0 @@ -963,19 +1141,24 @@ def setup_model_provider(config: dict): elif current_model: default_codex = len(model_choices) - 1 - model_idx = prompt_choice("Select default model:", model_choices, default_codex) + model_idx = prompt_choice( + "Select default model:", model_choices, default_codex + ) if model_idx < len(codex_models): - config['model'] = codex_models[model_idx] + _set_default_model(config, codex_models[model_idx]) save_env_value("LLM_MODEL", codex_models[model_idx]) elif model_idx == len(codex_models): custom = prompt("Enter model name") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) + _set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL) elif selected_provider == "zai": # Coding Plan endpoints don't have GLM-5 - is_coding_plan = get_env_value("GLM_BASE_URL") and "coding" in (get_env_value("GLM_BASE_URL") or "") + is_coding_plan = get_env_value("GLM_BASE_URL") and "coding" in ( + get_env_value("GLM_BASE_URL") or "" + ) if is_coding_plan: zai_models = ["glm-4.7", "glm-4.5", "glm-4.5-flash"] else: @@ -988,12 +1171,12 @@ def setup_model_provider(config: dict): model_idx = prompt_choice("Select default model:", model_choices, keep_idx) if model_idx < len(zai_models): - config['model'] = zai_models[model_idx] + _set_default_model(config, zai_models[model_idx]) save_env_value("LLM_MODEL", zai_models[model_idx]) elif model_idx == len(zai_models): custom = prompt("Enter model name") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) # else: keep current elif selected_provider == "kimi-coding": @@ -1006,12 +1189,12 @@ def setup_model_provider(config: dict): model_idx = prompt_choice("Select default model:", model_choices, keep_idx) if model_idx < len(kimi_models): - config['model'] = kimi_models[model_idx] + _set_default_model(config, kimi_models[model_idx]) save_env_value("LLM_MODEL", kimi_models[model_idx]) elif model_idx == len(kimi_models): custom = prompt("Enter model name") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) # else: keep current elif selected_provider in ("minimax", "minimax-cn"): @@ -1024,12 +1207,12 @@ def setup_model_provider(config: dict): model_idx = prompt_choice("Select default model:", model_choices, keep_idx) if model_idx < len(minimax_models): - config['model'] = minimax_models[model_idx] + _set_default_model(config, minimax_models[model_idx]) save_env_value("LLM_MODEL", minimax_models[model_idx]) elif model_idx == len(minimax_models): custom = prompt("Enter model name") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) # else: keep current else: @@ -1046,18 +1229,22 @@ def setup_model_provider(config: dict): model_idx = prompt_choice("Select default model:", model_choices, keep_idx) if model_idx < len(ids): - config['model'] = ids[model_idx] + _set_default_model(config, ids[model_idx]) save_env_value("LLM_MODEL", ids[model_idx]) elif model_idx == len(ids): # Custom custom = prompt("Enter model name (e.g., anthropic/claude-opus-4.6)") if custom: - config['model'] = custom + _set_default_model(config, custom) save_env_value("LLM_MODEL", custom) # else: Keep current - _final_model = config.get('model', '') + _final_model = config.get("model", "") if _final_model: - _display = _final_model.get('default', _final_model) if isinstance(_final_model, dict) else _final_model + _display = ( + _final_model.get("default", _final_model) + if isinstance(_final_model, dict) + else _final_model + ) print_success(f"Model set to: {_display}") save_config(config) @@ -1067,6 +1254,7 @@ def setup_model_provider(config: dict): # Section 2: Terminal Backend Configuration # ============================================================================= + def setup_terminal_backend(config: dict): """Configure the terminal execution backend.""" import platform as _platform @@ -1077,7 +1265,7 @@ def setup_terminal_backend(config: dict): print_info("This affects tool execution, file access, and isolation.") print() - current_backend = config.get('terminal', {}).get('backend', 'local') + current_backend = config.get("terminal", {}).get("backend", "local") is_linux = _platform.system() == "Linux" # Build backend choices with descriptions @@ -1105,7 +1293,9 @@ def setup_terminal_backend(config: dict): default_terminal = backend_to_idx.get(current_backend, 0) - terminal_idx = prompt_choice("Select terminal backend:", terminal_choices, keep_current_idx) + terminal_idx = prompt_choice( + "Select terminal backend:", terminal_choices, keep_current_idx + ) selected_backend = idx_to_backend.get(terminal_idx) @@ -1113,21 +1303,23 @@ def setup_terminal_backend(config: dict): print_info(f"Keeping current backend: {current_backend}") return - config.setdefault('terminal', {})['backend'] = selected_backend + config.setdefault("terminal", {})["backend"] = selected_backend if selected_backend == "local": print_success("Terminal backend: Local") print_info("Commands run directly on this machine.") - + # CWD for messaging print() print_info("Working directory for messaging sessions:") print_info(" When using Hermes via Telegram/Discord, this is where") - print_info(" the agent starts. CLI mode always starts in the current directory.") - current_cwd = config.get('terminal', {}).get('cwd', '') + print_info( + " the agent starts. CLI mode always starts in the current directory." + ) + current_cwd = config.get("terminal", {}).get("cwd", "") cwd = prompt(" Messaging working directory", current_cwd or str(Path.home())) if cwd: - config['terminal']['cwd'] = cwd + config["terminal"]["cwd"] = cwd # Sudo support print() @@ -1135,7 +1327,9 @@ def setup_terminal_backend(config: dict): if existing_sudo: print_info("Sudo password: configured") else: - if prompt_yes_no("Enable sudo support? (stores password for apt install, etc.)", False): + if prompt_yes_no( + "Enable sudo support? (stores password for apt install, etc.)", False + ): sudo_pass = prompt(" Sudo password", password=True) if sudo_pass: save_env_value("SUDO_PASSWORD", sudo_pass) @@ -1153,9 +1347,11 @@ def setup_terminal_backend(config: dict): print_info(f"Docker found: {docker_bin}") # Docker image - current_image = config.get('terminal', {}).get('docker_image', 'python:3.11-slim') + current_image = config.get("terminal", {}).get( + "docker_image", "python:3.11-slim" + ) image = prompt(" Docker image", current_image) - config['terminal']['docker_image'] = image + config["terminal"]["docker_image"] = image save_env_value("TERMINAL_DOCKER_IMAGE", image) _prompt_container_resources(config) @@ -1167,13 +1363,17 @@ def setup_terminal_backend(config: dict): sing_bin = shutil.which("apptainer") or shutil.which("singularity") if not sing_bin: print_warning("Singularity/Apptainer not found in PATH!") - print_info("Install: https://apptainer.org/docs/admin/main/installation.html") + print_info( + "Install: https://apptainer.org/docs/admin/main/installation.html" + ) else: print_info(f"Found: {sing_bin}") - current_image = config.get('terminal', {}).get('singularity_image', 'docker://python:3.11-slim') + current_image = config.get("terminal", {}).get( + "singularity_image", "docker://python:3.11-slim" + ) image = prompt(" Container image", current_image) - config['terminal']['singularity_image'] = image + config["terminal"]["singularity_image"] = image save_env_value("TERMINAL_SINGULARITY_IMAGE", image) _prompt_container_resources(config) @@ -1189,21 +1389,33 @@ def setup_terminal_backend(config: dict): except ImportError: print_info("Installing swe-rex[modal]...") import subprocess + uv_bin = shutil.which("uv") if uv_bin: result = subprocess.run( - [uv_bin, "pip", "install", "--python", sys.executable, "swe-rex[modal]"], - capture_output=True, text=True + [ + uv_bin, + "pip", + "install", + "--python", + sys.executable, + "swe-rex[modal]", + ], + capture_output=True, + text=True, ) else: result = subprocess.run( [sys.executable, "-m", "pip", "install", "swe-rex[modal]"], - capture_output=True, text=True + capture_output=True, + text=True, ) if result.returncode == 0: print_success("swe-rex[modal] installed") else: - print_warning("Install failed — run manually: pip install 'swe-rex[modal]'") + print_warning( + "Install failed — run manually: pip install 'swe-rex[modal]'" + ) # Modal token print() @@ -1241,16 +1453,19 @@ def setup_terminal_backend(config: dict): except ImportError: print_info("Installing daytona SDK...") import subprocess + uv_bin = shutil.which("uv") if uv_bin: result = subprocess.run( [uv_bin, "pip", "install", "--python", sys.executable, "daytona"], - capture_output=True, text=True + capture_output=True, + text=True, ) else: result = subprocess.run( [sys.executable, "-m", "pip", "install", "daytona"], - capture_output=True, text=True + capture_output=True, + text=True, ) if result.returncode == 0: print_success("daytona SDK installed") @@ -1276,9 +1491,11 @@ def setup_terminal_backend(config: dict): print_success(" Configured") # Daytona image - current_image = config.get('terminal', {}).get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20') + current_image = config.get("terminal", {}).get( + "daytona_image", "nikolaik/python-nodejs:python3.11-nodejs20" + ) image = prompt(" Sandbox image", current_image) - config['terminal']['daytona_image'] = image + config["terminal"]["daytona_image"] = image save_env_value("TERMINAL_DAYTONA_IMAGE", image) _prompt_container_resources(config) @@ -1316,6 +1533,7 @@ def setup_terminal_backend(config: dict): if host and prompt_yes_no(" Test SSH connection?", True): print_info(" Testing connection...") import subprocess + ssh_cmd = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5"] if ssh_key: ssh_cmd.extend(["-i", ssh_key]) @@ -1342,28 +1560,31 @@ def setup_terminal_backend(config: dict): # Section 3: Agent Settings # ============================================================================= + def setup_agent_settings(config: dict): """Configure agent behavior: iterations, progress display, compression, session reset.""" # ── Max Iterations ── print_header("Agent Settings") - current_max = get_env_value('HERMES_MAX_ITERATIONS') or str(config.get('agent', {}).get('max_turns', 90)) + current_max = get_env_value("HERMES_MAX_ITERATIONS") or str( + config.get("agent", {}).get("max_turns", 90) + ) print_info("Maximum tool-calling iterations per conversation.") print_info("Higher = more complex tasks, but costs more tokens.") print_info("Recommended: 30-60 for most tasks, 100+ for open exploration.") - + max_iter_str = prompt("Max iterations", current_max) try: max_iter = int(max_iter_str) if max_iter > 0: save_env_value("HERMES_MAX_ITERATIONS", str(max_iter)) - config.setdefault('agent', {})['max_turns'] = max_iter - config.pop('max_turns', None) + config.setdefault("agent", {})["max_turns"] = max_iter + config.pop("max_turns", None) print_success(f"Max iterations set to {max_iter}") except ValueError: print_warning("Invalid number, keeping current value") - + # ── Tool Progress Display ── print_info("") print_info("Tool Progress Display") @@ -1372,7 +1593,7 @@ def setup_agent_settings(config: dict): print_info(" new — Show tool name only when it changes (less noise)") print_info(" all — Show every tool call with a short preview") print_info(" verbose — Full args, results, and debug logs") - + current_mode = config.get("display", {}).get("tool_progress", "all") mode = prompt("Tool progress mode", current_mode) if mode.lower() in ("off", "new", "all", "verbose"): @@ -1387,33 +1608,47 @@ def setup_agent_settings(config: dict): # ── Context Compression ── print_header("Context Compression") print_info("Automatically summarizes old messages when context gets too long.") - print_info("Higher threshold = compress later (use more context). Lower = compress sooner.") - - config.setdefault('compression', {})['enabled'] = True - - current_threshold = config.get('compression', {}).get('threshold', 0.85) + print_info( + "Higher threshold = compress later (use more context). Lower = compress sooner." + ) + + config.setdefault("compression", {})["enabled"] = True + + current_threshold = config.get("compression", {}).get("threshold", 0.85) threshold_str = prompt("Compression threshold (0.5-0.95)", str(current_threshold)) try: threshold = float(threshold_str) if 0.5 <= threshold <= 0.95: - config['compression']['threshold'] = threshold + config["compression"]["threshold"] = threshold except ValueError: pass - - print_success(f"Context compression threshold set to {config['compression'].get('threshold', 0.85)}") + + print_success( + f"Context compression threshold set to {config['compression'].get('threshold', 0.85)}" + ) # ── Session Reset Policy ── print_header("Session Reset Policy") - print_info("Messaging sessions (Telegram, Discord, etc.) accumulate context over time.") - print_info("Each message adds to the conversation history, which means growing API costs.") + print_info( + "Messaging sessions (Telegram, Discord, etc.) accumulate context over time." + ) + print_info( + "Each message adds to the conversation history, which means growing API costs." + ) print_info("") - print_info("To manage this, sessions can automatically reset after a period of inactivity") - print_info("or at a fixed time each day. When a reset happens, the agent saves important") - print_info("things to its persistent memory first — but the conversation context is cleared.") + print_info( + "To manage this, sessions can automatically reset after a period of inactivity" + ) + print_info( + "or at a fixed time each day. When a reset happens, the agent saves important" + ) + print_info( + "things to its persistent memory first — but the conversation context is cleared." + ) print_info("") print_info("You can also manually reset anytime by typing /reset in chat.") print_info("") - + reset_choices = [ "Inactivity + daily reset (recommended - reset whichever comes first)", "Inactivity only (reset after N minutes of no messages)", @@ -1421,61 +1656,71 @@ def setup_agent_settings(config: dict): "Never auto-reset (context lives until /reset or context compression)", "Keep current settings", ] - - current_policy = config.get('session_reset', {}) - current_mode = current_policy.get('mode', 'both') - current_idle = current_policy.get('idle_minutes', 1440) - current_hour = current_policy.get('at_hour', 4) - + + current_policy = config.get("session_reset", {}) + current_mode = current_policy.get("mode", "both") + current_idle = current_policy.get("idle_minutes", 1440) + current_hour = current_policy.get("at_hour", 4) + default_reset = {"both": 0, "idle": 1, "daily": 2, "none": 3}.get(current_mode, 0) - + reset_idx = prompt_choice("Session reset mode:", reset_choices, default_reset) - - config.setdefault('session_reset', {}) - + + config.setdefault("session_reset", {}) + if reset_idx == 0: # Both - config['session_reset']['mode'] = 'both' + config["session_reset"]["mode"] = "both" idle_str = prompt(" Inactivity timeout (minutes)", str(current_idle)) try: idle_val = int(idle_str) if idle_val > 0: - config['session_reset']['idle_minutes'] = idle_val + config["session_reset"]["idle_minutes"] = idle_val except ValueError: pass hour_str = prompt(" Daily reset hour (0-23, local time)", str(current_hour)) try: hour_val = int(hour_str) if 0 <= hour_val <= 23: - config['session_reset']['at_hour'] = hour_val + config["session_reset"]["at_hour"] = hour_val except ValueError: pass - print_success(f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min idle or daily at {config['session_reset'].get('at_hour', 4)}:00") + print_success( + f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min idle or daily at {config['session_reset'].get('at_hour', 4)}:00" + ) elif reset_idx == 1: # Idle only - config['session_reset']['mode'] = 'idle' + config["session_reset"]["mode"] = "idle" idle_str = prompt(" Inactivity timeout (minutes)", str(current_idle)) try: idle_val = int(idle_str) if idle_val > 0: - config['session_reset']['idle_minutes'] = idle_val + config["session_reset"]["idle_minutes"] = idle_val except ValueError: pass - print_success(f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min of inactivity") + print_success( + f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min of inactivity" + ) elif reset_idx == 2: # Daily only - config['session_reset']['mode'] = 'daily' + config["session_reset"]["mode"] = "daily" hour_str = prompt(" Daily reset hour (0-23, local time)", str(current_hour)) try: hour_val = int(hour_str) if 0 <= hour_val <= 23: - config['session_reset']['at_hour'] = hour_val + config["session_reset"]["at_hour"] = hour_val except ValueError: pass - print_success(f"Sessions reset daily at {config['session_reset'].get('at_hour', 4)}:00") + print_success( + f"Sessions reset daily at {config['session_reset'].get('at_hour', 4)}:00" + ) elif reset_idx == 3: # None - config['session_reset']['mode'] = 'none' - print_info("Sessions will never auto-reset. Context is managed only by compression.") - print_warning("Long conversations will grow in cost. Use /reset manually when needed.") + config["session_reset"]["mode"] = "none" + print_info( + "Sessions will never auto-reset. Context is managed only by compression." + ) + print_warning( + "Long conversations will grow in cost. Use /reset manually when needed." + ) # else: keep current (idx == 4) - + save_config(config) @@ -1483,6 +1728,7 @@ def setup_agent_settings(config: dict): # Section 4: Messaging Platforms (Gateway) # ============================================================================= + def setup_gateway(config: dict): """Configure messaging platform integrations.""" print_header("Messaging Platforms") @@ -1490,19 +1736,19 @@ def setup_gateway(config: dict): print() # ── Telegram ── - existing_telegram = get_env_value('TELEGRAM_BOT_TOKEN') + existing_telegram = get_env_value("TELEGRAM_BOT_TOKEN") if existing_telegram: print_info("Telegram: already configured") if prompt_yes_no("Reconfigure Telegram?", False): existing_telegram = None - + if not existing_telegram and prompt_yes_no("Set up Telegram bot?", False): print_info("Create a bot via @BotFather on Telegram") token = prompt("Telegram bot token", password=True) if token: save_env_value("TELEGRAM_BOT_TOKEN", token) print_success("Telegram token saved") - + # Allowed users (security) print() print_info("🔒 Security: Restrict who can use your bot") @@ -1510,60 +1756,74 @@ def setup_gateway(config: dict): print_info(" 1. Message @userinfobot on Telegram") print_info(" 2. It will reply with your numeric ID (e.g., 123456789)") print() - allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)") + allowed_users = prompt( + "Allowed user IDs (comma-separated, leave empty for open access)" + ) if allowed_users: save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", "")) - print_success("Telegram allowlist configured - only listed users can use the bot") + print_success( + "Telegram allowlist configured - only listed users can use the bot" + ) else: - print_info("⚠️ No allowlist set - anyone who finds your bot can use it!") - + print_info( + "⚠️ No allowlist set - anyone who finds your bot can use it!" + ) + # Home channel setup with better guidance print() print_info("📬 Home Channel: where Hermes delivers cron job results,") print_info(" cross-platform messages, and notifications.") print_info(" For Telegram DMs, this is your user ID (same as above).") - + first_user_id = allowed_users.split(",")[0].strip() if allowed_users else "" if first_user_id: - if prompt_yes_no(f"Use your user ID ({first_user_id}) as the home channel?", True): + if prompt_yes_no( + f"Use your user ID ({first_user_id}) as the home channel?", True + ): save_env_value("TELEGRAM_HOME_CHANNEL", first_user_id) print_success(f"Telegram home channel set to {first_user_id}") else: - home_channel = prompt("Home channel ID (or leave empty to set later with /set-home in Telegram)") + home_channel = prompt( + "Home channel ID (or leave empty to set later with /set-home in Telegram)" + ) if home_channel: save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) else: - print_info(" You can also set this later by typing /set-home in your Telegram chat.") + print_info( + " You can also set this later by typing /set-home in your Telegram chat." + ) home_channel = prompt("Home channel ID (leave empty to set later)") if home_channel: save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) - + # Check/update existing Telegram allowlist elif existing_telegram: - existing_allowlist = get_env_value('TELEGRAM_ALLOWED_USERS') + existing_allowlist = get_env_value("TELEGRAM_ALLOWED_USERS") if not existing_allowlist: print_info("⚠️ Telegram has no user allowlist - anyone can use your bot!") if prompt_yes_no("Add allowed users now?", True): print_info(" To find your Telegram user ID: message @userinfobot") allowed_users = prompt("Allowed user IDs (comma-separated)") if allowed_users: - save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", "")) + save_env_value( + "TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", "") + ) print_success("Telegram allowlist configured") - + # ── Discord ── - existing_discord = get_env_value('DISCORD_BOT_TOKEN') + existing_discord = get_env_value("DISCORD_BOT_TOKEN") if existing_discord: print_info("Discord: already configured") if prompt_yes_no("Reconfigure Discord?", False): existing_discord = None - + if not existing_discord and prompt_yes_no("Set up Discord bot?", False): print_info("Create a bot at https://discord.com/developers/applications") token = prompt("Discord bot token", password=True) if token: save_env_value("DISCORD_BOT_TOKEN", token) print_success("Discord token saved") - + # Allowed users (security) print() print_info("🔒 Security: Restrict who can use your bot") @@ -1571,48 +1831,66 @@ def setup_gateway(config: dict): print_info(" 1. Enable Developer Mode in Discord settings") print_info(" 2. Right-click your name → Copy ID") print() - print_info(" You can also use Discord usernames (resolved on gateway start).") + print_info( + " You can also use Discord usernames (resolved on gateway start)." + ) print() - allowed_users = prompt("Allowed user IDs or usernames (comma-separated, leave empty for open access)") + allowed_users = prompt( + "Allowed user IDs or usernames (comma-separated, leave empty for open access)" + ) if allowed_users: save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "")) print_success("Discord allowlist configured") else: - print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!") - + print_info( + "⚠️ No allowlist set - anyone in servers with your bot can use it!" + ) + # Home channel setup with better guidance print() print_info("📬 Home Channel: where Hermes delivers cron job results,") print_info(" cross-platform messages, and notifications.") - print_info(" To get a channel ID: right-click a channel → Copy Channel ID") + print_info( + " To get a channel ID: right-click a channel → Copy Channel ID" + ) print_info(" (requires Developer Mode in Discord settings)") - print_info(" You can also set this later by typing /set-home in a Discord channel.") - home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") + print_info( + " You can also set this later by typing /set-home in a Discord channel." + ) + home_channel = prompt( + "Home channel ID (leave empty to set later with /set-home)" + ) if home_channel: save_env_value("DISCORD_HOME_CHANNEL", home_channel) - + # Check/update existing Discord allowlist elif existing_discord: - existing_allowlist = get_env_value('DISCORD_ALLOWED_USERS') + existing_allowlist = get_env_value("DISCORD_ALLOWED_USERS") if not existing_allowlist: print_info("⚠️ Discord has no user allowlist - anyone can use your bot!") if prompt_yes_no("Add allowed users now?", True): - print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID") + print_info( + " To find Discord ID: Enable Developer Mode, right-click name → Copy ID" + ) allowed_users = prompt("Allowed user IDs (comma-separated)") if allowed_users: - save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "")) + save_env_value( + "DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "") + ) print_success("Discord allowlist configured") - + # ── Slack ── - existing_slack = get_env_value('SLACK_BOT_TOKEN') + existing_slack = get_env_value("SLACK_BOT_TOKEN") if existing_slack: print_info("Slack: already configured") if prompt_yes_no("Reconfigure Slack?", False): existing_slack = None - + if not existing_slack and prompt_yes_no("Set up Slack bot?", False): print_info("Steps to create a Slack app:") - print_info(" 1. Go to https://api.slack.com/apps → Create New App (from scratch)") + print_info( + " 1. Go to https://api.slack.com/apps → Create New App (from scratch)" + ) print_info(" 2. Enable Socket Mode: Settings → Socket Mode → Enable") print_info(" • Create an App-Level Token with 'connections:write' scope") print_info(" 3. Add Bot Token Scopes: Features → OAuth & Permissions") @@ -1625,9 +1903,13 @@ def setup_gateway(config: dict): print_warning(" ⚠ Without message.channels/message.groups events,") print_warning(" the bot will ONLY work in DMs, not channels!") print_info(" 5. Install to Workspace: Settings → Install App") - print_info(" 6. After installing, invite the bot to channels: /invite @YourBot") + print_info( + " 6. After installing, invite the bot to channels: /invite @YourBot" + ) print() - print_info(" Full guide: https://hermes-agent.ai/docs/user-guide/messaging/slack") + print_info( + " Full guide: https://hermes-agent.ai/docs/user-guide/messaging/slack" + ) print() bot_token = prompt("Slack Bot Token (xoxb-...)", password=True) if bot_token: @@ -1636,20 +1918,26 @@ def setup_gateway(config: dict): if app_token: save_env_value("SLACK_APP_TOKEN", app_token) print_success("Slack tokens saved") - + print() print_info("🔒 Security: Restrict who can use your bot") - print_info(" To find a Member ID: click a user's name → View full profile → ⋮ → Copy member ID") + print_info( + " To find a Member ID: click a user's name → View full profile → ⋮ → Copy member ID" + ) print() - allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)") + allowed_users = prompt( + "Allowed user IDs (comma-separated, leave empty for open access)" + ) if allowed_users: save_env_value("SLACK_ALLOWED_USERS", allowed_users.replace(" ", "")) print_success("Slack allowlist configured") else: - print_info("⚠️ No allowlist set - anyone in your workspace can use the bot!") - + print_info( + "⚠️ No allowlist set - anyone in your workspace can use the bot!" + ) + # ── WhatsApp ── - existing_whatsapp = get_env_value('WHATSAPP_ENABLED') + existing_whatsapp = get_env_value("WHATSAPP_ENABLED") if not existing_whatsapp and prompt_yes_no("Set up WhatsApp?", False): print_info("WhatsApp connects via a built-in bridge (Baileys).") print_info("Requires Node.js. Run 'hermes whatsapp' for guided setup.") @@ -1659,13 +1947,13 @@ def setup_gateway(config: dict): print_success("WhatsApp enabled") print_info("Run 'hermes whatsapp' to choose your mode (separate bot number") print_info("or personal self-chat) and pair via QR code.") - + # ── Gateway Service Setup ── any_messaging = ( - get_env_value('TELEGRAM_BOT_TOKEN') - or get_env_value('DISCORD_BOT_TOKEN') - or get_env_value('SLACK_BOT_TOKEN') - or get_env_value('WHATSAPP_ENABLED') + get_env_value("TELEGRAM_BOT_TOKEN") + or get_env_value("DISCORD_BOT_TOKEN") + or get_env_value("SLACK_BOT_TOKEN") + or get_env_value("WHATSAPP_ENABLED") ) if any_messaging: print() @@ -1674,11 +1962,15 @@ def setup_gateway(config: dict): # Check if any home channels are missing missing_home = [] - if get_env_value('TELEGRAM_BOT_TOKEN') and not get_env_value('TELEGRAM_HOME_CHANNEL'): + if get_env_value("TELEGRAM_BOT_TOKEN") and not get_env_value( + "TELEGRAM_HOME_CHANNEL" + ): missing_home.append("Telegram") - if get_env_value('DISCORD_BOT_TOKEN') and not get_env_value('DISCORD_HOME_CHANNEL'): + if get_env_value("DISCORD_BOT_TOKEN") and not get_env_value( + "DISCORD_HOME_CHANNEL" + ): missing_home.append("Discord") - if get_env_value('SLACK_BOT_TOKEN') and not get_env_value('SLACK_HOME_CHANNEL'): + if get_env_value("SLACK_BOT_TOKEN") and not get_env_value("SLACK_HOME_CHANNEL"): missing_home.append("Slack") if missing_home: @@ -1688,17 +1980,25 @@ def setup_gateway(config: dict): print_info(" messages can't be delivered to those platforms.") print_info(" Set one later with /set-home in your chat, or:") for plat in missing_home: - print_info(f" hermes config set {plat.upper()}_HOME_CHANNEL ") + print_info( + f" hermes config set {plat.upper()}_HOME_CHANNEL " + ) # Offer to install the gateway as a system service import platform as _platform + _is_linux = _platform.system() == "Linux" _is_macos = _platform.system() == "Darwin" from hermes_cli.gateway import ( - _is_service_installed, _is_service_running, - systemd_install, systemd_start, systemd_restart, - launchd_install, launchd_start, launchd_restart, + _is_service_installed, + _is_service_running, + systemd_install, + systemd_start, + systemd_restart, + launchd_install, + launchd_start, + launchd_restart, ) service_installed = _is_service_installed() @@ -1725,7 +2025,10 @@ def setup_gateway(config: dict): print_error(f" Start failed: {e}") elif _is_linux or _is_macos: svc_name = "systemd" if _is_linux else "launchd" - if prompt_yes_no(f" Install the gateway as a {svc_name} service? (runs in background, starts on boot)", True): + if prompt_yes_no( + f" Install the gateway as a {svc_name} service? (runs in background, starts on boot)", + True, + ): try: if _is_linux: systemd_install(force=False) @@ -1757,17 +2060,19 @@ def setup_gateway(config: dict): # Section 5: Tool Configuration (delegates to unified tools_config.py) # ============================================================================= + def setup_tools(config: dict, first_install: bool = False): """Configure tools — delegates to the unified tools_command() in tools_config.py. - + Both `hermes setup tools` and `hermes tools` use the same flow: platform selection → toolset toggles → provider/API key configuration. - + Args: first_install: When True, uses the simplified first-install flow (no platform menu, prompts for all unconfigured API keys). """ from hermes_cli.tools_config import tools_command + tools_command(first_install=first_install, config=config) @@ -1786,7 +2091,7 @@ SETUP_SECTIONS = [ def run_setup_wizard(args): """Run the interactive setup wizard. - + Supports full, quick, and section-specific setup: hermes setup — full or quick (auto-detected) hermes setup model — just model/provider @@ -1796,46 +2101,84 @@ def run_setup_wizard(args): hermes setup agent — just agent settings """ ensure_hermes_home() - + config = load_config() hermes_home = get_hermes_home() - + # Check if a specific section was requested - section = getattr(args, 'section', None) + section = getattr(args, "section", None) if section: for key, label, func in SETUP_SECTIONS: if key == section: print() - print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA)) + print( + color( + "┌─────────────────────────────────────────────────────────┐", + Colors.MAGENTA, + ) + ) print(color(f"│ ⚕ Hermes Setup — {label:<34s} │", Colors.MAGENTA)) - print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA)) + print( + color( + "└─────────────────────────────────────────────────────────┘", + Colors.MAGENTA, + ) + ) func(config) save_config(config) print() print_success(f"{label} configuration complete!") return - + print_error(f"Unknown setup section: {section}") print_info(f"Available sections: {', '.join(k for k, _, _ in SETUP_SECTIONS)}") return - + # Check if this is an existing installation with a provider configured from hermes_cli.auth import get_active_provider + active_provider = get_active_provider() is_existing = ( bool(get_env_value("OPENROUTER_API_KEY")) or bool(get_env_value("OPENAI_BASE_URL")) or active_provider is not None ) - + print() - print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA)) - print(color("│ ⚕ Hermes Agent Setup Wizard │", Colors.MAGENTA)) - print(color("├─────────────────────────────────────────────────────────┤", Colors.MAGENTA)) - print(color("│ Let's configure your Hermes Agent installation. │", Colors.MAGENTA)) - print(color("│ Press Ctrl+C at any time to exit. │", Colors.MAGENTA)) - print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA)) - + print( + color( + "┌─────────────────────────────────────────────────────────┐", + Colors.MAGENTA, + ) + ) + print( + color( + "│ ⚕ Hermes Agent Setup Wizard │", Colors.MAGENTA + ) + ) + print( + color( + "├─────────────────────────────────────────────────────────┤", + Colors.MAGENTA, + ) + ) + print( + color( + "│ Let's configure your Hermes Agent installation. │", Colors.MAGENTA + ) + ) + print( + color( + "│ Press Ctrl+C at any time to exit. │", Colors.MAGENTA + ) + ) + print( + color( + "└─────────────────────────────────────────────────────────┘", + Colors.MAGENTA, + ) + ) + if is_existing: # ── Returning User Menu ── print() @@ -1931,20 +2274,31 @@ def run_setup_wizard(args): def _run_quick_setup(config: dict, hermes_home): """Quick setup — only configure items that are missing.""" from hermes_cli.config import ( - get_missing_env_vars, get_missing_config_fields, - check_config_version, migrate_config, + get_missing_env_vars, + get_missing_config_fields, + check_config_version, + migrate_config, ) print() print_header("Quick Setup — Missing Items Only") # 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_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_anything_missing = missing_required or missing_optional or missing_config or current_ver < latest_ver + has_anything_missing = ( + missing_required + or missing_optional + or missing_config + or current_ver < latest_ver + ) if not has_anything_missing: print_success("Everything is configured! Nothing to do.") @@ -1967,12 +2321,12 @@ def _run_quick_setup(config: dict, hermes_home): 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']}") @@ -1981,7 +2335,11 @@ def _run_quick_setup(config: dict, hermes_home): # Split missing optional vars by category missing_tools = [v for v in missing_optional if v.get("category") == "tool"] - missing_messaging = [v for v in missing_optional if v.get("category") == "messaging" and not v.get("advanced")] + missing_messaging = [ + v + for v in missing_optional + if v.get("category") == "messaging" and not v.get("advanced") + ] # ── Tool API keys (checklist) ── if missing_tools: @@ -2028,7 +2386,11 @@ def _run_quick_setup(config: dict, hermes_home): platforms.setdefault(plat, []).append(var) platform_labels = [ - {"Telegram": "📱 Telegram", "Discord": "💬 Discord", "Slack": "💼 Slack"}.get(p, p) + { + "Telegram": "📱 Telegram", + "Discord": "💬 Discord", + "Slack": "💼 Slack", + }.get(p, p) for p in platform_order ] @@ -2062,10 +2424,12 @@ def _run_quick_setup(config: dict, hermes_home): # Handle missing config fields if missing_config: print() - print_info(f"Adding {len(missing_config)} new config option(s) with defaults...") + 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) diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py new file mode 100644 index 0000000000..3c3de32086 --- /dev/null +++ b/tests/hermes_cli/test_setup.py @@ -0,0 +1,130 @@ +import json + +from hermes_cli.auth import _update_config_for_provider, get_active_provider +from hermes_cli.config import load_config, save_config +from hermes_cli.setup import setup_model_provider + + +def _clear_provider_env(monkeypatch): + for key in ( + "NOUS_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_API_KEY", + "LLM_MODEL", + ): + monkeypatch.delenv(key, raising=False) + + +def test_nous_api_setup_preserves_model_provider_metadata(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + config = load_config() + + monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda *args, **kwargs: 0) + + prompt_values = iter( + [ + "nous-api-key", + "", + "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", + ] + ) + monkeypatch.setattr( + "hermes_cli.setup.prompt", + lambda *args, **kwargs: next(prompt_values), + ) + + setup_model_provider(config) + save_config(config) + + reloaded = load_config() + + assert isinstance(reloaded["model"], dict) + assert reloaded["model"]["provider"] == "nous-api" + assert reloaded["model"]["base_url"] == "https://inference-api.nousresearch.com/v1" + assert ( + reloaded["model"]["default"] + == "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8" + ) + + +def test_nous_oauth_setup_keeps_current_model_when_syncing_disk_provider( + tmp_path, monkeypatch +): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + config = load_config() + + prompt_choices = iter([1, 2]) + monkeypatch.setattr( + "hermes_cli.setup.prompt_choice", + lambda *args, **kwargs: next(prompt_choices), + ) + monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") + + def _fake_login_nous(*args, **kwargs): + auth_path = tmp_path / "auth.json" + auth_path.write_text(json.dumps({"active_provider": "nous", "providers": {}})) + _update_config_for_provider("nous", "https://inference.example.com/v1") + + monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login_nous) + monkeypatch.setattr( + "hermes_cli.auth.resolve_nous_runtime_credentials", + lambda *args, **kwargs: { + "base_url": "https://inference.example.com/v1", + "api_key": "nous-key", + }, + ) + monkeypatch.setattr( + "hermes_cli.auth.fetch_nous_models", + lambda *args, **kwargs: ["gemini-3-flash"], + ) + + setup_model_provider(config) + save_config(config) + + reloaded = load_config() + + assert isinstance(reloaded["model"], dict) + assert reloaded["model"]["provider"] == "nous" + assert reloaded["model"]["base_url"] == "https://inference.example.com/v1" + assert reloaded["model"]["default"] == "anthropic/claude-opus-4.6" + + +def test_custom_setup_clears_active_oauth_provider(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + auth_path = tmp_path / "auth.json" + auth_path.write_text(json.dumps({"active_provider": "nous", "providers": {}})) + + config = load_config() + + monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda *args, **kwargs: 4) + + prompt_values = iter( + [ + "https://custom.example/v1", + "custom-api-key", + "custom/model", + "", + ] + ) + monkeypatch.setattr( + "hermes_cli.setup.prompt", + lambda *args, **kwargs: next(prompt_values), + ) + + setup_model_provider(config) + save_config(config) + + reloaded = load_config() + + assert get_active_provider() is None + assert isinstance(reloaded["model"], dict) + assert reloaded["model"]["provider"] == "custom" + assert reloaded["model"]["base_url"] == "https://custom.example/v1" + assert reloaded["model"]["default"] == "custom/model"