#!/usr/bin/env python3 """ Hermes CLI - Main entry point. Usage: hermes # Interactive chat (default) hermes chat # Interactive chat hermes gateway # Run gateway in foreground hermes gateway start # Start gateway as service hermes gateway stop # Stop gateway service hermes gateway status # Show gateway status hermes gateway install # Install gateway service hermes gateway uninstall # Uninstall gateway service hermes setup # Interactive setup wizard hermes logout # Clear stored authentication hermes status # Show status of all components hermes cron # Manage cron jobs hermes cron list # List cron jobs hermes cron status # Check if cron scheduler is running hermes doctor # Check configuration and dependencies hermes version # Show version hermes update # Update to latest version hermes uninstall # Uninstall Hermes Agent """ import argparse import os import sys from pathlib import Path from typing import Optional # Add project root to path PROJECT_ROOT = Path(__file__).parent.parent.resolve() sys.path.insert(0, str(PROJECT_ROOT)) # Load .env from ~/.hermes/.env first, then project root as dev fallback from dotenv import load_dotenv from hermes_cli.config import get_env_path, get_hermes_home _user_env = get_env_path() if _user_env.exists(): try: load_dotenv(dotenv_path=_user_env, encoding="utf-8") except UnicodeDecodeError: load_dotenv(dotenv_path=_user_env, encoding="latin-1") load_dotenv(dotenv_path=PROJECT_ROOT / '.env', override=False) # Point mini-swe-agent at ~/.hermes/ so it shares our config os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(get_hermes_home())) os.environ.setdefault("MSWEA_SILENT_STARTUP", "1") import logging from hermes_cli import __version__ from hermes_constants import OPENROUTER_BASE_URL logger = logging.getLogger(__name__) def _has_any_provider_configured() -> bool: """Check if at least one inference provider is usable.""" from hermes_cli.config import get_env_path, get_hermes_home from hermes_cli.auth import get_auth_status # Check env vars (may be set by .env or shell). # OPENAI_BASE_URL alone counts — local models (vLLM, llama.cpp, etc.) # often don't require an API key. provider_env_vars = ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_BASE_URL") if any(os.getenv(v) for v in provider_env_vars): return True # Check .env file for keys env_file = get_env_path() if env_file.exists(): try: for line in env_file.read_text().splitlines(): line = line.strip() if line.startswith("#") or "=" not in line: continue key, _, val = line.partition("=") val = val.strip().strip("'\"") if key.strip() in provider_env_vars and val: return True except Exception: pass # Check for Nous Portal OAuth credentials auth_file = get_hermes_home() / "auth.json" if auth_file.exists(): try: import json auth = json.loads(auth_file.read_text()) active = auth.get("active_provider") if active: status = get_auth_status(active) if status.get("logged_in"): return True except Exception: pass return False def _resolve_last_cli_session() -> Optional[str]: """Look up the most recent CLI session ID from SQLite. Returns None if unavailable.""" try: from hermes_state import SessionDB db = SessionDB() sessions = db.search_sessions(source="cli", limit=1) db.close() if sessions: return sessions[0]["id"] except Exception: pass return None def cmd_chat(args): """Run interactive chat CLI.""" # Resolve --continue into --resume with the latest CLI session if getattr(args, "continue_last", False) and not getattr(args, "resume", None): last_id = _resolve_last_cli_session() if last_id: args.resume = last_id else: print("No previous CLI session found to continue.") sys.exit(1) # First-run guard: check if any provider is configured before launching if not _has_any_provider_configured(): print() print("It looks like Hermes isn't configured yet -- no API keys or providers found.") print() print(" Run: hermes setup") print() try: reply = input("Run setup now? [Y/n] ").strip().lower() except (EOFError, KeyboardInterrupt): reply = "n" if reply in ("", "y", "yes"): cmd_setup(args) return print() print("You can run 'hermes setup' at any time to configure.") sys.exit(1) # Import and run the CLI from cli import main as cli_main # Build kwargs from args kwargs = { "model": args.model, "provider": getattr(args, "provider", None), "toolsets": args.toolsets, "verbose": args.verbose, "query": args.query, "resume": getattr(args, "resume", None), } # Filter out None values kwargs = {k: v for k, v in kwargs.items() if v is not None} cli_main(**kwargs) def cmd_gateway(args): """Gateway management commands.""" from hermes_cli.gateway import gateway_command gateway_command(args) def cmd_whatsapp(args): """Set up WhatsApp: choose mode, configure, install bridge, pair via QR.""" import os import subprocess from pathlib import Path from hermes_cli.config import get_env_value, save_env_value print() print("⚕ WhatsApp Setup") print("=" * 50) # ── Step 1: Choose mode ────────────────────────────────────────────── current_mode = get_env_value("WHATSAPP_MODE") or "" if not current_mode: print() print("How will you use WhatsApp with Hermes?") print() print(" 1. Separate bot number (recommended)") print(" People message the bot's number directly — cleanest experience.") print(" Requires a second phone number with WhatsApp installed on a device.") print() print(" 2. Personal number (self-chat)") print(" You message yourself to talk to the agent.") print(" Quick to set up, but the UX is less intuitive.") print() try: choice = input(" Choose [1/2]: ").strip() except (EOFError, KeyboardInterrupt): print("\nSetup cancelled.") return if choice == "1": save_env_value("WHATSAPP_MODE", "bot") wa_mode = "bot" print(" ✓ Mode: separate bot number") print() print(" ┌─────────────────────────────────────────────────┐") print(" │ Getting a second number for the bot: │") print(" │ │") print(" │ Easiest: Install WhatsApp Business (free app) │") print(" │ on your phone with a second number: │") print(" │ • Dual-SIM: use your 2nd SIM slot │") print(" │ • Google Voice: free US number (voice.google) │") print(" │ • Prepaid SIM: $3-10, verify once │") print(" │ │") print(" │ WhatsApp Business runs alongside your personal │") print(" │ WhatsApp — no second phone needed. │") print(" └─────────────────────────────────────────────────┘") else: save_env_value("WHATSAPP_MODE", "self-chat") wa_mode = "self-chat" print(" ✓ Mode: personal number (self-chat)") else: wa_mode = current_mode mode_label = "separate bot number" if wa_mode == "bot" else "personal number (self-chat)" print(f"\n✓ Mode: {mode_label}") # ── Step 2: Enable WhatsApp ────────────────────────────────────────── print() current = get_env_value("WHATSAPP_ENABLED") if current and current.lower() == "true": print("✓ WhatsApp is already enabled") else: save_env_value("WHATSAPP_ENABLED", "true") print("✓ WhatsApp enabled") # ── Step 3: Allowed users ──────────────────────────────────────────── current_users = get_env_value("WHATSAPP_ALLOWED_USERS") or "" if current_users: print(f"✓ Allowed users: {current_users}") try: response = input("\n Update allowed users? [y/N] ").strip() except (EOFError, KeyboardInterrupt): response = "n" if response.lower() in ("y", "yes"): if wa_mode == "bot": phone = input(" Phone numbers that can message the bot (comma-separated): ").strip() else: phone = input(" Your phone number (e.g. 15551234567): ").strip() if phone: save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", "")) print(f" ✓ Updated to: {phone}") else: print() if wa_mode == "bot": print(" Who should be allowed to message the bot?") phone = input(" Phone numbers (comma-separated, or * for anyone): ").strip() else: phone = input(" Your phone number (e.g. 15551234567): ").strip() if phone: save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", "")) print(f" ✓ Allowed users set: {phone}") else: print(" ⚠ No allowlist — the agent will respond to ALL incoming messages") # ── Step 4: Install bridge dependencies ────────────────────────────── project_root = Path(__file__).resolve().parents[1] bridge_dir = project_root / "scripts" / "whatsapp-bridge" bridge_script = bridge_dir / "bridge.js" if not bridge_script.exists(): print(f"\n✗ Bridge script not found at {bridge_script}") return if not (bridge_dir / "node_modules").exists(): print("\n→ Installing WhatsApp bridge dependencies...") result = subprocess.run( ["npm", "install"], cwd=str(bridge_dir), capture_output=True, text=True, timeout=120, ) if result.returncode != 0: print(f" ✗ npm install failed: {result.stderr}") return print(" ✓ Dependencies installed") else: print("✓ Bridge dependencies already installed") # ── Step 5: Check for existing session ─────────────────────────────── session_dir = Path.home() / ".hermes" / "whatsapp" / "session" session_dir.mkdir(parents=True, exist_ok=True) if (session_dir / "creds.json").exists(): print("✓ Existing WhatsApp session found") try: response = input("\n Re-pair? This will clear the existing session. [y/N] ").strip() except (EOFError, KeyboardInterrupt): response = "n" if response.lower() in ("y", "yes"): import shutil shutil.rmtree(session_dir, ignore_errors=True) session_dir.mkdir(parents=True, exist_ok=True) print(" ✓ Session cleared") else: print("\n✓ WhatsApp is configured and paired!") print(" Start the gateway with: hermes gateway") return # ── Step 6: QR code pairing ────────────────────────────────────────── print() print("─" * 50) if wa_mode == "bot": print("📱 Open WhatsApp (or WhatsApp Business) on the") print(" phone with the BOT's number, then scan:") else: print("📱 Open WhatsApp on your phone, then scan:") print() print(" Settings → Linked Devices → Link a Device") print("─" * 50) print() try: subprocess.run( ["node", str(bridge_script), "--pair-only", "--session", str(session_dir)], cwd=str(bridge_dir), ) except KeyboardInterrupt: pass # ── Step 7: Post-pairing ───────────────────────────────────────────── print() if (session_dir / "creds.json").exists(): print("✓ WhatsApp paired successfully!") print() if wa_mode == "bot": print(" Next steps:") print(" 1. Start the gateway: hermes gateway") print(" 2. Send a message to the bot's WhatsApp number") print(" 3. The agent will reply automatically") print() print(" Tip: Agent responses are prefixed with '⚕ Hermes Agent'") else: print(" Next steps:") print(" 1. Start the gateway: hermes gateway") print(" 2. Open WhatsApp → Message Yourself") print(" 3. Type a message — the agent will reply") print() print(" Tip: Agent responses are prefixed with '⚕ Hermes Agent'") print(" so you can tell them apart from your own messages.") print() print(" Or install as a service: hermes gateway install") else: print("⚠ Pairing may not have completed. Run 'hermes whatsapp' to try again.") def cmd_setup(args): """Interactive setup wizard.""" from hermes_cli.setup import run_setup_wizard run_setup_wizard(args) def cmd_model(args): """Select default model — starts with provider selection, then model picker.""" from hermes_cli.auth import ( resolve_provider, get_provider_auth_state, PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice, _update_config_for_provider, resolve_nous_runtime_credentials, fetch_nous_models, AuthError, format_auth_error, _login_nous, ) from hermes_cli.config import load_config, save_config, get_env_value, save_env_value config = load_config() current_model = config.get("model") if isinstance(current_model, dict): current_model = current_model.get("default", "") current_model = current_model or "(not set)" # Read effective provider the same way the CLI does at startup: # config.yaml model.provider > env var > auto-detect import os config_provider = None model_cfg = config.get("model") if isinstance(model_cfg, dict): config_provider = model_cfg.get("provider") effective_provider = ( os.getenv("HERMES_INFERENCE_PROVIDER") or config_provider or "auto" ) try: active = resolve_provider(effective_provider) except AuthError as exc: warning = format_auth_error(exc) print(f"Warning: {warning} Falling back to auto provider detection.") active = resolve_provider("auto") # Detect custom endpoint if active == "openrouter" and get_env_value("OPENAI_BASE_URL"): active = "custom" provider_labels = { "openrouter": "OpenRouter", "nous": "Nous Portal", "openai-codex": "OpenAI Codex", "custom": "Custom endpoint", } active_label = provider_labels.get(active, active) print() print(f" Current model: {current_model}") print(f" Active provider: {active_label}") print() # Step 1: Provider selection — put active provider first with marker providers = [ ("openrouter", "OpenRouter (100+ models, pay-per-use)"), ("nous", "Nous Portal (Nous Research subscription)"), ("openai-codex", "OpenAI Codex"), ("custom", "Custom endpoint (self-hosted / VLLM / etc.)"), ] # Reorder so the active provider is at the top active_key = active if active in ("openrouter", "nous", "openai-codex") else "custom" ordered = [] for key, label in providers: if key == active_key: ordered.insert(0, (key, f"{label} ← currently active")) else: ordered.append((key, label)) ordered.append(("cancel", "Cancel")) provider_idx = _prompt_provider_choice([label for _, label in ordered]) if provider_idx is None or ordered[provider_idx][0] == "cancel": print("No change.") return selected_provider = ordered[provider_idx][0] # Step 2: Provider-specific setup + model selection if selected_provider == "openrouter": _model_flow_openrouter(config, current_model) elif selected_provider == "nous": _model_flow_nous(config, current_model) elif selected_provider == "openai-codex": _model_flow_openai_codex(config, current_model) elif selected_provider == "custom": _model_flow_custom(config) def _prompt_provider_choice(choices): """Show provider selection menu. Returns index or None.""" try: from simple_term_menu import TerminalMenu menu_items = [f" {c}" for c in choices] menu = TerminalMenu( menu_items, cursor_index=0, menu_cursor="-> ", menu_cursor_style=("fg_green", "bold"), menu_highlight_style=("fg_green",), cycle_cursor=True, clear_screen=False, title="Select provider:", ) idx = menu.show() print() return idx except (ImportError, NotImplementedError): pass # Fallback: numbered list print("Select provider:") for i, c in enumerate(choices, 1): print(f" {i}. {c}") print() while True: try: val = input(f"Choice [1-{len(choices)}]: ").strip() if not val: return None idx = int(val) - 1 if 0 <= idx < len(choices): return idx print(f"Please enter 1-{len(choices)}") except ValueError: print("Please enter a number") except (KeyboardInterrupt, EOFError): print() return None def _model_flow_openrouter(config, current_model=""): """OpenRouter provider: ensure API key, then pick model.""" from hermes_cli.auth import _prompt_model_selection, _save_model_choice, deactivate_provider from hermes_cli.config import get_env_value, save_env_value api_key = get_env_value("OPENROUTER_API_KEY") if not api_key: print("No OpenRouter API key configured.") print("Get one at: https://openrouter.ai/keys") print() try: key = input("OpenRouter API key (or Enter to cancel): ").strip() except (KeyboardInterrupt, EOFError): print() return if not key: print("Cancelled.") return save_env_value("OPENROUTER_API_KEY", key) print("API key saved.") print() from hermes_cli.models import model_ids openrouter_models = model_ids() selected = _prompt_model_selection(openrouter_models, current_model=current_model) if selected: # Clear any custom endpoint and set provider to openrouter if get_env_value("OPENAI_BASE_URL"): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _save_model_choice(selected) # Update config provider and deactivate any OAuth provider from hermes_cli.config import load_config, save_config cfg = load_config() model = cfg.get("model") if isinstance(model, dict): model["provider"] = "openrouter" model["base_url"] = OPENROUTER_BASE_URL save_config(cfg) deactivate_provider() print(f"Default model set to: {selected} (via OpenRouter)") else: print("No change.") def _model_flow_nous(config, current_model=""): """Nous Portal provider: ensure logged in, then pick model.""" from hermes_cli.auth import ( get_provider_auth_state, _prompt_model_selection, _save_model_choice, _update_config_for_provider, resolve_nous_runtime_credentials, fetch_nous_models, AuthError, format_auth_error, _login_nous, PROVIDER_REGISTRY, ) from hermes_cli.config import get_env_value, save_env_value import argparse state = get_provider_auth_state("nous") if not state or not state.get("access_token"): print("Not logged into Nous Portal. Starting login...") print() try: 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, ) _login_nous(mock_args, PROVIDER_REGISTRY["nous"]) except SystemExit: print("Login cancelled or failed.") return except Exception as exc: print(f"Login failed: {exc}") return # login_nous already handles model selection + config update return # Already logged in — fetch models and select print("Fetching models from Nous Portal...") try: creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=5 * 60) model_ids = fetch_nous_models( inference_base_url=creds.get("base_url", ""), api_key=creds.get("api_key", ""), ) except Exception as exc: relogin = isinstance(exc, AuthError) and exc.relogin_required msg = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc) if relogin: print(f"Session expired: {msg}") print("Re-authenticating with Nous Portal...\n") try: 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, ) _login_nous(mock_args, PROVIDER_REGISTRY["nous"]) except Exception as login_exc: print(f"Re-login failed: {login_exc}") return print(f"Could not fetch models: {msg}") return if not model_ids: print("No models returned by the inference API.") return selected = _prompt_model_selection(model_ids, current_model=current_model) if selected: _save_model_choice(selected) # Reactivate Nous as the provider and update config inference_url = creds.get("base_url", "") _update_config_for_provider("nous", inference_url) # Clear any custom endpoint that might conflict if get_env_value("OPENAI_BASE_URL"): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") print(f"Default model set to: {selected} (via Nous Portal)") else: print("No change.") def _model_flow_openai_codex(config, current_model=""): """OpenAI Codex provider: ensure logged in, then pick model.""" from hermes_cli.auth import ( get_codex_auth_status, _prompt_model_selection, _save_model_choice, _update_config_for_provider, _login_openai_codex, PROVIDER_REGISTRY, DEFAULT_CODEX_BASE_URL, ) from hermes_cli.codex_models import get_codex_model_ids from hermes_cli.config import get_env_value, save_env_value import argparse status = get_codex_auth_status() if not status.get("logged_in"): print("Not logged into OpenAI Codex. Starting login...") print() try: mock_args = argparse.Namespace() _login_openai_codex(mock_args, PROVIDER_REGISTRY["openai-codex"]) except SystemExit: print("Login cancelled or failed.") return except Exception as exc: print(f"Login failed: {exc}") return _codex_token = None try: from hermes_cli.auth import resolve_codex_runtime_credentials _codex_creds = resolve_codex_runtime_credentials() _codex_token = _codex_creds.get("api_key") except Exception: pass codex_models = get_codex_model_ids(access_token=_codex_token) selected = _prompt_model_selection(codex_models, current_model=current_model) if selected: _save_model_choice(selected) _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) # Clear custom endpoint env vars that would otherwise override Codex. if get_env_value("OPENAI_BASE_URL"): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") print(f"Default model set to: {selected} (via OpenAI Codex)") else: print("No change.") def _model_flow_custom(config): """Custom endpoint: collect URL, API key, and model name.""" from hermes_cli.auth import _save_model_choice, deactivate_provider from hermes_cli.config import get_env_value, save_env_value, load_config, save_config current_url = get_env_value("OPENAI_BASE_URL") or "" current_key = get_env_value("OPENAI_API_KEY") or "" print("Custom OpenAI-compatible endpoint configuration:") if current_url: print(f" Current URL: {current_url}") if current_key: print(f" Current key: {current_key[:8]}...") print() try: base_url = input(f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: ").strip() api_key = input(f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: ").strip() model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip() except (KeyboardInterrupt, EOFError): print("\nCancelled.") return if not base_url and not current_url: print("No URL provided. Cancelled.") return # Validate URL format effective_url = base_url or current_url if not effective_url.startswith(("http://", "https://")): print(f"Invalid URL: {effective_url} (must start with http:// or https://)") return if base_url: save_env_value("OPENAI_BASE_URL", base_url) if api_key: save_env_value("OPENAI_API_KEY", api_key) if model_name: _save_model_choice(model_name) # Update config and deactivate any OAuth provider cfg = load_config() model = cfg.get("model") if isinstance(model, dict): model["provider"] = "auto" model["base_url"] = effective_url save_config(cfg) deactivate_provider() print(f"Default model set to: {model_name} (via {effective_url})") else: if base_url or api_key: deactivate_provider() print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.") def cmd_login(args): """Authenticate Hermes CLI with a provider.""" from hermes_cli.auth import login_command login_command(args) def cmd_logout(args): """Clear provider authentication.""" from hermes_cli.auth import logout_command logout_command(args) def cmd_status(args): """Show status of all components.""" from hermes_cli.status import show_status show_status(args) def cmd_cron(args): """Cron job management.""" from hermes_cli.cron import cron_command cron_command(args) def cmd_doctor(args): """Check configuration and dependencies.""" from hermes_cli.doctor import run_doctor run_doctor(args) def cmd_config(args): """Configuration management.""" from hermes_cli.config import config_command config_command(args) def cmd_version(args): """Show version.""" print(f"Hermes Agent v{__version__}") print(f"Project: {PROJECT_ROOT}") # Show Python version print(f"Python: {sys.version.split()[0]}") # Check for key dependencies try: import openai print(f"OpenAI SDK: {openai.__version__}") except ImportError: print("OpenAI SDK: Not installed") def cmd_uninstall(args): """Uninstall Hermes Agent.""" from hermes_cli.uninstall import run_uninstall run_uninstall(args) def _update_via_zip(args): """Update Hermes Agent by downloading a ZIP archive. Used on Windows when git file I/O is broken (antivirus, NTFS filter drivers causing 'Invalid argument' errors on file creation). """ import shutil import tempfile import zipfile from urllib.request import urlretrieve branch = "main" zip_url = f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip" print("→ Downloading latest version...") try: tmp_dir = tempfile.mkdtemp(prefix="hermes-update-") zip_path = os.path.join(tmp_dir, f"hermes-agent-{branch}.zip") urlretrieve(zip_url, zip_path) print("→ Extracting...") with zipfile.ZipFile(zip_path, 'r') as zf: zf.extractall(tmp_dir) # GitHub ZIPs extract to hermes-agent-/ extracted = os.path.join(tmp_dir, f"hermes-agent-{branch}") if not os.path.isdir(extracted): # Try to find it for d in os.listdir(tmp_dir): candidate = os.path.join(tmp_dir, d) if os.path.isdir(candidate) and d != "__MACOSX": extracted = candidate break # Copy updated files over existing installation, preserving venv/node_modules/.git preserve = {'venv', 'node_modules', '.git', '__pycache__', '.env'} update_count = 0 for item in os.listdir(extracted): if item in preserve: continue src = os.path.join(extracted, item) dst = os.path.join(str(PROJECT_ROOT), item) if os.path.isdir(src): if os.path.exists(dst): shutil.rmtree(dst) shutil.copytree(src, dst) else: shutil.copy2(src, dst) update_count += 1 print(f"✓ Updated {update_count} items from ZIP") # Cleanup shutil.rmtree(tmp_dir, ignore_errors=True) except Exception as e: print(f"✗ ZIP update failed: {e}") sys.exit(1) # Reinstall Python dependencies print("→ Updating Python dependencies...") import subprocess uv_bin = shutil.which("uv") if uv_bin: subprocess.run( [uv_bin, "pip", "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True, env={**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} ) else: venv_pip = PROJECT_ROOT / "venv" / ("Scripts" if sys.platform == "win32" else "bin") / "pip" if venv_pip.exists(): subprocess.run([str(venv_pip), "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True) # Sync skills try: from tools.skills_sync import sync_skills print("→ Checking for new bundled skills...") result = sync_skills(quiet=True) if result["copied"]: print(f" + {len(result['copied'])} new skill(s): {', '.join(result['copied'])}") else: print(" ✓ Skills are up to date") except Exception: pass print() print("✓ Update complete!") def cmd_update(args): """Update Hermes Agent to the latest version.""" import subprocess import shutil print("⚕ Updating Hermes Agent...") print() # Try git-based update first, fall back to ZIP download on Windows # when git file I/O is broken (antivirus, NTFS filter drivers, etc.) use_zip_update = False git_dir = PROJECT_ROOT / '.git' if not git_dir.exists(): if sys.platform == "win32": use_zip_update = True else: print("✗ Not a git repository. Please reinstall:") print(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash") sys.exit(1) # On Windows, git can fail with "unable to write loose object file: Invalid argument" # due to filesystem atomicity issues. Set the recommended workaround. if sys.platform == "win32" and git_dir.exists(): subprocess.run( ["git", "-c", "windows.appendAtomically=false", "config", "windows.appendAtomically", "false"], cwd=PROJECT_ROOT, check=False, capture_output=True ) if use_zip_update: # ZIP-based update for Windows when git is broken _update_via_zip(args) return # Fetch and pull try: print("→ Fetching updates...") git_cmd = ["git"] if sys.platform == "win32": git_cmd = ["git", "-c", "windows.appendAtomically=false"] subprocess.run(git_cmd + ["fetch", "origin"], cwd=PROJECT_ROOT, check=True) # Get current branch result = subprocess.run( git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=True ) branch = result.stdout.strip() # Check if there are updates result = subprocess.run( git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"], cwd=PROJECT_ROOT, capture_output=True, text=True, check=True ) commit_count = int(result.stdout.strip()) if commit_count == 0: print("✓ Already up to date!") return print(f"→ Found {commit_count} new commit(s)") print("→ Pulling updates...") subprocess.run(git_cmd + ["pull", "origin", branch], cwd=PROJECT_ROOT, check=True) # Reinstall Python dependencies (prefer uv for speed, fall back to pip) print("→ Updating Python dependencies...") uv_bin = shutil.which("uv") if uv_bin: subprocess.run( [uv_bin, "pip", "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True, env={**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} ) else: venv_pip = PROJECT_ROOT / "venv" / ("Scripts" if sys.platform == "win32" else "bin") / "pip" if venv_pip.exists(): subprocess.run([str(venv_pip), "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True) else: subprocess.run(["pip", "install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True) # Check for Node.js deps if (PROJECT_ROOT / "package.json").exists(): import shutil if shutil.which("npm"): print("→ Updating Node.js dependencies...") subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False) print() print("✓ Code updated!") # Sync any new bundled skills (manifest-based -- won't overwrite or re-add deleted skills) try: from tools.skills_sync import sync_skills print() print("→ Checking for new bundled skills...") result = sync_skills(quiet=True) if result["copied"]: print(f" + {len(result['copied'])} new skill(s): {', '.join(result['copied'])}") else: print(" ✓ Skills are up to date") except Exception as e: logger.debug("Skills sync during update failed: %s", e) # 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!") # Auto-restart gateway if it's running as a systemd service try: check = subprocess.run( ["systemctl", "--user", "is-active", "hermes-gateway"], capture_output=True, text=True, timeout=5, ) if check.stdout.strip() == "active": print() print("→ Gateway service is running — restarting to pick up changes...") restart = subprocess.run( ["systemctl", "--user", "restart", "hermes-gateway"], capture_output=True, text=True, timeout=15, ) if restart.returncode == 0: print("✓ Gateway restarted.") else: print(f"⚠ Gateway restart failed: {restart.stderr.strip()}") print(" Try manually: hermes gateway restart") except (FileNotFoundError, subprocess.TimeoutExpired): pass # No systemd (macOS, WSL1, etc.) — skip silently print() print("Tip: You can now select a provider and model:") print(" hermes model # Select provider and model") except subprocess.CalledProcessError as e: if sys.platform == "win32": print(f"⚠ Git update failed: {e}") print("→ Falling back to ZIP download...") print() _update_via_zip(args) else: print(f"✗ Update failed: {e}") sys.exit(1) def main(): """Main entry point for hermes CLI.""" parser = argparse.ArgumentParser( prog="hermes", description="Hermes Agent - AI assistant with tool-calling capabilities", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: hermes Start interactive chat hermes chat -q "Hello" Single query mode hermes --continue Resume the most recent session hermes --resume Resume a specific session hermes setup Run setup wizard hermes logout Clear stored authentication hermes model Select default model hermes config View configuration hermes config edit Edit config in $EDITOR hermes config set model gpt-4 Set a config value hermes gateway Run messaging gateway hermes gateway install Install as system service hermes sessions list List past sessions hermes update Update to latest version For more help on a command: hermes --help """ ) parser.add_argument( "--version", "-V", action="store_true", help="Show version and exit" ) parser.add_argument( "--resume", "-r", metavar="SESSION_ID", default=None, help="Resume a previous session by ID (shortcut for: hermes chat --resume ID)" ) parser.add_argument( "--continue", "-c", dest="continue_last", action="store_true", default=False, help="Resume the most recent CLI session" ) subparsers = parser.add_subparsers(dest="command", help="Command to run") # ========================================================================= # chat command # ========================================================================= chat_parser = subparsers.add_parser( "chat", help="Interactive chat with the agent", description="Start an interactive chat session with Hermes Agent" ) chat_parser.add_argument( "-q", "--query", help="Single query (non-interactive mode)" ) chat_parser.add_argument( "-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)" ) chat_parser.add_argument( "-t", "--toolsets", help="Comma-separated toolsets to enable" ) chat_parser.add_argument( "--provider", choices=["auto", "openrouter", "nous", "openai-codex"], default=None, help="Inference provider (default: auto)" ) chat_parser.add_argument( "-v", "--verbose", action="store_true", help="Verbose output" ) chat_parser.add_argument( "--resume", "-r", metavar="SESSION_ID", help="Resume a previous session by ID (shown on exit)" ) chat_parser.add_argument( "--continue", "-c", dest="continue_last", action="store_true", default=False, help="Resume the most recent CLI session" ) chat_parser.set_defaults(func=cmd_chat) # ========================================================================= # model command # ========================================================================= model_parser = subparsers.add_parser( "model", help="Select default model and provider", description="Interactively select your inference provider and default model" ) model_parser.set_defaults(func=cmd_model) # ========================================================================= # gateway command # ========================================================================= gateway_parser = subparsers.add_parser( "gateway", help="Messaging gateway management", description="Manage the messaging gateway (Telegram, Discord, WhatsApp)" ) gateway_subparsers = gateway_parser.add_subparsers(dest="gateway_command") # gateway run (default) gateway_run = gateway_subparsers.add_parser("run", help="Run gateway in foreground") gateway_run.add_argument("-v", "--verbose", action="store_true") # gateway start gateway_start = gateway_subparsers.add_parser("start", help="Start gateway service") # gateway stop gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service") # gateway restart gateway_restart = gateway_subparsers.add_parser("restart", help="Restart gateway service") # gateway status gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status") gateway_status.add_argument("--deep", action="store_true", help="Deep status check") # gateway install gateway_install = gateway_subparsers.add_parser("install", help="Install gateway as service") gateway_install.add_argument("--force", action="store_true", help="Force reinstall") # gateway uninstall gateway_uninstall = gateway_subparsers.add_parser("uninstall", help="Uninstall gateway service") # gateway setup gateway_setup = gateway_subparsers.add_parser("setup", help="Configure messaging platforms") gateway_parser.set_defaults(func=cmd_gateway) # ========================================================================= # setup command # ========================================================================= setup_parser = subparsers.add_parser( "setup", help="Interactive setup wizard", description="Configure Hermes Agent with an interactive wizard" ) setup_parser.add_argument( "--non-interactive", action="store_true", help="Non-interactive mode (use defaults/env vars)" ) setup_parser.add_argument( "--reset", action="store_true", help="Reset configuration to defaults" ) setup_parser.set_defaults(func=cmd_setup) # ========================================================================= # whatsapp command # ========================================================================= whatsapp_parser = subparsers.add_parser( "whatsapp", help="Set up WhatsApp integration", description="Configure WhatsApp and pair via QR code" ) whatsapp_parser.set_defaults(func=cmd_whatsapp) # ========================================================================= # login command # ========================================================================= login_parser = subparsers.add_parser( "login", help="Authenticate with an inference provider", description="Run OAuth device authorization flow for Hermes CLI" ) login_parser.add_argument( "--provider", choices=["nous", "openai-codex"], default=None, help="Provider to authenticate with (default: nous)" ) login_parser.add_argument( "--portal-url", help="Portal base URL (default: production portal)" ) login_parser.add_argument( "--inference-url", help="Inference API base URL (default: production inference API)" ) login_parser.add_argument( "--client-id", default=None, help="OAuth client id to use (default: hermes-cli)" ) login_parser.add_argument( "--scope", default=None, help="OAuth scope to request" ) login_parser.add_argument( "--no-browser", action="store_true", help="Do not attempt to open the browser automatically" ) login_parser.add_argument( "--timeout", type=float, default=15.0, help="HTTP request timeout in seconds (default: 15)" ) login_parser.add_argument( "--ca-bundle", help="Path to CA bundle PEM file for TLS verification" ) login_parser.add_argument( "--insecure", action="store_true", help="Disable TLS verification (testing only)" ) login_parser.set_defaults(func=cmd_login) # ========================================================================= # logout command # ========================================================================= logout_parser = subparsers.add_parser( "logout", help="Clear authentication for an inference provider", description="Remove stored credentials and reset provider config" ) logout_parser.add_argument( "--provider", choices=["nous", "openai-codex"], default=None, help="Provider to log out from (default: active provider)" ) logout_parser.set_defaults(func=cmd_logout) # ========================================================================= # status command # ========================================================================= status_parser = subparsers.add_parser( "status", help="Show status of all components", description="Display status of Hermes Agent components" ) status_parser.add_argument( "--all", action="store_true", help="Show all details (redacted for sharing)" ) status_parser.add_argument( "--deep", action="store_true", help="Run deep checks (may take longer)" ) status_parser.set_defaults(func=cmd_status) # ========================================================================= # cron command # ========================================================================= cron_parser = subparsers.add_parser( "cron", help="Cron job management", description="Manage scheduled tasks" ) cron_subparsers = cron_parser.add_subparsers(dest="cron_command") # cron list cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs") cron_list.add_argument("--all", action="store_true", help="Include disabled jobs") # cron status cron_subparsers.add_parser("status", help="Check if cron scheduler is running") # cron tick (mostly for debugging) cron_subparsers.add_parser("tick", help="Run due jobs once and exit") cron_parser.set_defaults(func=cmd_cron) # ========================================================================= # doctor command # ========================================================================= doctor_parser = subparsers.add_parser( "doctor", help="Check configuration and dependencies", description="Diagnose issues with Hermes Agent setup" ) doctor_parser.add_argument( "--fix", action="store_true", help="Attempt to fix issues automatically" ) doctor_parser.set_defaults(func=cmd_doctor) # ========================================================================= # config command # ========================================================================= config_parser = subparsers.add_parser( "config", help="View and edit configuration", description="Manage Hermes Agent configuration" ) config_subparsers = config_parser.add_subparsers(dest="config_command") # config show (default) config_show = config_subparsers.add_parser("show", help="Show current configuration") # config edit config_edit = config_subparsers.add_parser("edit", help="Open config file in editor") # config set config_set = config_subparsers.add_parser("set", help="Set a configuration value") config_set.add_argument("key", nargs="?", help="Configuration key (e.g., model, terminal.backend)") config_set.add_argument("value", nargs="?", help="Value to set") # config path config_path = config_subparsers.add_parser("path", help="Print config file path") # 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) # ========================================================================= # pairing command # ========================================================================= pairing_parser = subparsers.add_parser( "pairing", help="Manage DM pairing codes for user authorization", description="Approve or revoke user access via pairing codes" ) pairing_sub = pairing_parser.add_subparsers(dest="pairing_action") pairing_list_parser = pairing_sub.add_parser("list", help="Show pending + approved users") pairing_approve_parser = pairing_sub.add_parser("approve", help="Approve a pairing code") pairing_approve_parser.add_argument("platform", help="Platform name (telegram, discord, slack, whatsapp)") pairing_approve_parser.add_argument("code", help="Pairing code to approve") pairing_revoke_parser = pairing_sub.add_parser("revoke", help="Revoke user access") pairing_revoke_parser.add_argument("platform", help="Platform name") pairing_revoke_parser.add_argument("user_id", help="User ID to revoke") pairing_clear_parser = pairing_sub.add_parser("clear-pending", help="Clear all pending codes") def cmd_pairing(args): from hermes_cli.pairing import pairing_command pairing_command(args) pairing_parser.set_defaults(func=cmd_pairing) # ========================================================================= # skills command # ========================================================================= skills_parser = subparsers.add_parser( "skills", help="Skills Hub — search, install, and manage skills from online registries", description="Search, install, inspect, audit, and manage skills from GitHub, ClawHub, and other registries." ) skills_subparsers = skills_parser.add_subparsers(dest="skills_action") skills_browse = skills_subparsers.add_parser("browse", help="Browse all available skills (paginated)") skills_browse.add_argument("--page", type=int, default=1, help="Page number (default: 1)") skills_browse.add_argument("--size", type=int, default=20, help="Results per page (default: 20)") skills_browse.add_argument("--source", default="all", choices=["all", "official", "github", "clawhub", "lobehub"], help="Filter by source (default: all)") skills_search = skills_subparsers.add_parser("search", help="Search skill registries") skills_search.add_argument("query", help="Search query") skills_search.add_argument("--source", default="all", choices=["all", "official", "github", "clawhub", "lobehub"]) skills_search.add_argument("--limit", type=int, default=10, help="Max results") skills_install = skills_subparsers.add_parser("install", help="Install a skill") skills_install.add_argument("identifier", help="Skill identifier (e.g. openai/skills/skill-creator)") skills_install.add_argument("--category", default="", help="Category folder to install into") skills_install.add_argument("--force", action="store_true", help="Install despite caution verdict") skills_inspect = skills_subparsers.add_parser("inspect", help="Preview a skill without installing") skills_inspect.add_argument("identifier", help="Skill identifier") skills_list = skills_subparsers.add_parser("list", help="List installed skills") skills_list.add_argument("--source", default="all", choices=["all", "hub", "builtin"]) skills_audit = skills_subparsers.add_parser("audit", help="Re-scan installed hub skills") skills_audit.add_argument("name", nargs="?", help="Specific skill to audit (default: all)") skills_uninstall = skills_subparsers.add_parser("uninstall", help="Remove a hub-installed skill") skills_uninstall.add_argument("name", help="Skill name to remove") skills_publish = skills_subparsers.add_parser("publish", help="Publish a skill to a registry") skills_publish.add_argument("skill_path", help="Path to skill directory") skills_publish.add_argument("--to", default="github", choices=["github", "clawhub"], help="Target registry") skills_publish.add_argument("--repo", default="", help="Target GitHub repo (e.g. openai/skills)") skills_snapshot = skills_subparsers.add_parser("snapshot", help="Export/import skill configurations") snapshot_subparsers = skills_snapshot.add_subparsers(dest="snapshot_action") snap_export = snapshot_subparsers.add_parser("export", help="Export installed skills to a file") snap_export.add_argument("output", help="Output JSON file path") snap_import = snapshot_subparsers.add_parser("import", help="Import and install skills from a file") snap_import.add_argument("input", help="Input JSON file path") snap_import.add_argument("--force", action="store_true", help="Force install despite caution verdict") skills_tap = skills_subparsers.add_parser("tap", help="Manage skill sources") tap_subparsers = skills_tap.add_subparsers(dest="tap_action") tap_subparsers.add_parser("list", help="List configured taps") tap_add = tap_subparsers.add_parser("add", help="Add a GitHub repo as skill source") tap_add.add_argument("repo", help="GitHub repo (e.g. owner/repo)") tap_rm = tap_subparsers.add_parser("remove", help="Remove a tap") tap_rm.add_argument("name", help="Tap name to remove") def cmd_skills(args): from hermes_cli.skills_hub import skills_command skills_command(args) skills_parser.set_defaults(func=cmd_skills) # ========================================================================= # tools command # ========================================================================= tools_parser = subparsers.add_parser( "tools", help="Configure which tools are enabled per platform", description="Interactive tool configuration — enable/disable tools for CLI, Telegram, Discord, etc." ) def cmd_tools(args): from hermes_cli.tools_config import tools_command tools_command(args) tools_parser.set_defaults(func=cmd_tools) # ========================================================================= # sessions command # ========================================================================= sessions_parser = subparsers.add_parser( "sessions", help="Manage session history (list, export, prune, delete)", description="View and manage the SQLite session store" ) sessions_subparsers = sessions_parser.add_subparsers(dest="sessions_action") sessions_list = sessions_subparsers.add_parser("list", help="List recent sessions") sessions_list.add_argument("--source", help="Filter by source (cli, telegram, discord, etc.)") sessions_list.add_argument("--limit", type=int, default=20, help="Max sessions to show") sessions_export = sessions_subparsers.add_parser("export", help="Export sessions to a JSONL file") sessions_export.add_argument("output", help="Output JSONL file path") sessions_export.add_argument("--source", help="Filter by source") sessions_export.add_argument("--session-id", help="Export a specific session") sessions_delete = sessions_subparsers.add_parser("delete", help="Delete a specific session") sessions_delete.add_argument("session_id", help="Session ID to delete") sessions_delete.add_argument("--yes", "-y", action="store_true", help="Skip confirmation") sessions_prune = sessions_subparsers.add_parser("prune", help="Delete old sessions") sessions_prune.add_argument("--older-than", type=int, default=90, help="Delete sessions older than N days (default: 90)") sessions_prune.add_argument("--source", help="Only prune sessions from this source") sessions_prune.add_argument("--yes", "-y", action="store_true", help="Skip confirmation") sessions_stats = sessions_subparsers.add_parser("stats", help="Show session store statistics") def cmd_sessions(args): import json as _json try: from hermes_state import SessionDB db = SessionDB() except Exception as e: print(f"Error: Could not open session database: {e}") return action = args.sessions_action if action == "list": sessions = db.search_sessions(source=args.source, limit=args.limit) if not sessions: print("No sessions found.") return print(f"{'ID':<30} {'Source':<12} {'Model':<30} {'Messages':>8} {'Started'}") print("─" * 100) from datetime import datetime for s in sessions: started = datetime.fromtimestamp(s["started_at"]).strftime("%Y-%m-%d %H:%M") if s["started_at"] else "?" model = (s.get("model") or "?")[:28] ended = " (ended)" if s.get("ended_at") else "" print(f"{s['id']:<30} {s['source']:<12} {model:<30} {s['message_count']:>8} {started}{ended}") elif action == "export": if args.session_id: data = db.export_session(args.session_id) if not data: print(f"Session '{args.session_id}' not found.") return with open(args.output, "w") as f: f.write(_json.dumps(data, ensure_ascii=False) + "\n") print(f"Exported 1 session to {args.output}") else: sessions = db.export_all(source=args.source) with open(args.output, "w") as f: for s in sessions: f.write(_json.dumps(s, ensure_ascii=False) + "\n") print(f"Exported {len(sessions)} sessions to {args.output}") elif action == "delete": if not args.yes: confirm = input(f"Delete session '{args.session_id}' and all its messages? [y/N] ") if confirm.lower() not in ("y", "yes"): print("Cancelled.") return if db.delete_session(args.session_id): print(f"Deleted session '{args.session_id}'.") else: print(f"Session '{args.session_id}' not found.") elif action == "prune": days = args.older_than source_msg = f" from '{args.source}'" if args.source else "" if not args.yes: confirm = input(f"Delete all ended sessions older than {days} days{source_msg}? [y/N] ") if confirm.lower() not in ("y", "yes"): print("Cancelled.") return count = db.prune_sessions(older_than_days=days, source=args.source) print(f"Pruned {count} session(s).") elif action == "stats": total = db.session_count() msgs = db.message_count() print(f"Total sessions: {total}") print(f"Total messages: {msgs}") for src in ["cli", "telegram", "discord", "whatsapp", "slack"]: c = db.session_count(source=src) if c > 0: print(f" {src}: {c} sessions") import os db_path = db.db_path if db_path.exists(): size_mb = os.path.getsize(db_path) / (1024 * 1024) print(f"Database size: {size_mb:.1f} MB") else: sessions_parser.print_help() db.close() sessions_parser.set_defaults(func=cmd_sessions) # ========================================================================= # version command # ========================================================================= version_parser = subparsers.add_parser( "version", help="Show version information" ) version_parser.set_defaults(func=cmd_version) # ========================================================================= # update command # ========================================================================= update_parser = subparsers.add_parser( "update", help="Update Hermes Agent to the latest version", description="Pull the latest changes from git and reinstall dependencies" ) update_parser.set_defaults(func=cmd_update) # ========================================================================= # uninstall command # ========================================================================= uninstall_parser = subparsers.add_parser( "uninstall", help="Uninstall Hermes Agent", description="Remove Hermes Agent from your system. Can keep configs/data for reinstall." ) uninstall_parser.add_argument( "--full", action="store_true", help="Full uninstall - remove everything including configs and data" ) uninstall_parser.add_argument( "--yes", "-y", action="store_true", help="Skip confirmation prompts" ) uninstall_parser.set_defaults(func=cmd_uninstall) # ========================================================================= # Parse and execute # ========================================================================= args = parser.parse_args() # Handle --version flag if args.version: cmd_version(args) return # Handle top-level --resume / --continue as shortcut to chat if (args.resume or args.continue_last) and args.command is None: args.command = "chat" args.query = None args.model = None args.provider = None args.toolsets = None args.verbose = False cmd_chat(args) return # Default to chat if no command specified if args.command is None: args.query = None args.model = None args.provider = None args.toolsets = None args.verbose = False args.resume = None args.continue_last = False cmd_chat(args) return # Execute the command if hasattr(args, 'func'): args.func(args) else: parser.print_help() if __name__ == "__main__": main()