Enhanced the hermes CLI gateway with a new 'setup' command to configure messaging platforms (Telegram, Discord, Slack, WhatsApp). This includes prompts for necessary environment variables and improved user experience for platform configuration. Updated documentation to reflect the new command.
1687 lines
64 KiB
Python
1687 lines
64 KiB
Python
#!/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-<branch>/
|
||
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 <session_id> 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 <command> --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_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", "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()
|