Enhance CLI with multi-platform messaging integration and configuration management
- Updated CLI to load configuration from user-specific and project-specific YAML files, prioritizing user settings. - Introduced a new command `/platforms` to display the status of connected messaging platforms (Telegram, Discord, WhatsApp). - Implemented a gateway system for handling messaging interactions, including session management and delivery routing for cron job outputs. - Added support for environment variable configuration and a dedicated gateway configuration file for advanced settings. - Enhanced documentation in README.md and added a new messaging.md file to guide users on platform integrations and setup. - Updated toolsets to include platform-specific capabilities for Telegram, Discord, and WhatsApp, ensuring secure and tailored interactions.
This commit is contained in:
parent
a3ba41fce2
commit
619c72e566
37 changed files with 8559 additions and 757 deletions
14
hermes_cli/__init__.py
Normal file
14
hermes_cli/__init__.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"""
|
||||
Hermes CLI - Unified command-line interface for Hermes Agent.
|
||||
|
||||
Provides subcommands for:
|
||||
- hermes chat - Interactive chat (same as ./hermes)
|
||||
- hermes gateway - Run gateway in foreground
|
||||
- hermes gateway start - Start gateway service
|
||||
- hermes gateway stop - Stop gateway service
|
||||
- hermes setup - Interactive setup wizard
|
||||
- hermes status - Show status of all components
|
||||
- hermes cron - Manage cron jobs
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
392
hermes_cli/config.py
Normal file
392
hermes_cli/config.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
"""
|
||||
Configuration management for Hermes Agent.
|
||||
|
||||
Config files are stored in ~/.hermes/ for easy access:
|
||||
- ~/.hermes/config.yaml - All settings (model, toolsets, terminal, etc.)
|
||||
- ~/.hermes/.env - API keys and secrets
|
||||
|
||||
This module provides:
|
||||
- hermes config - Show current configuration
|
||||
- hermes config edit - Open config in editor
|
||||
- hermes config set - Set a specific value
|
||||
- hermes config wizard - Re-run setup wizard
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
# ANSI colors
|
||||
class Colors:
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
RED = "\033[31m"
|
||||
GREEN = "\033[32m"
|
||||
YELLOW = "\033[33m"
|
||||
BLUE = "\033[34m"
|
||||
MAGENTA = "\033[35m"
|
||||
CYAN = "\033[36m"
|
||||
|
||||
def color(text: str, *codes) -> str:
|
||||
if not sys.stdout.isatty():
|
||||
return text
|
||||
return "".join(codes) + text + Colors.RESET
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Config paths
|
||||
# =============================================================================
|
||||
|
||||
def get_hermes_home() -> Path:
|
||||
"""Get the Hermes home directory (~/.hermes)."""
|
||||
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
|
||||
def get_config_path() -> Path:
|
||||
"""Get the main config file path."""
|
||||
return get_hermes_home() / "config.yaml"
|
||||
|
||||
def get_env_path() -> Path:
|
||||
"""Get the .env file path (for API keys)."""
|
||||
return get_hermes_home() / ".env"
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""Get the project installation directory."""
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
|
||||
def ensure_hermes_home():
|
||||
"""Ensure ~/.hermes directory structure exists."""
|
||||
home = get_hermes_home()
|
||||
(home / "cron").mkdir(parents=True, exist_ok=True)
|
||||
(home / "sessions").mkdir(parents=True, exist_ok=True)
|
||||
(home / "logs").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Config loading/saving
|
||||
# =============================================================================
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"toolsets": ["hermes-cli"],
|
||||
"max_turns": 100,
|
||||
|
||||
"terminal": {
|
||||
"backend": "local",
|
||||
"cwd": ".", # Use current directory
|
||||
"timeout": 180,
|
||||
"docker_image": "python:3.11-slim",
|
||||
},
|
||||
|
||||
"browser": {
|
||||
"inactivity_timeout": 120,
|
||||
},
|
||||
|
||||
"compression": {
|
||||
"enabled": True,
|
||||
"threshold": 0.85,
|
||||
"summary_model": "google/gemini-2.0-flash-001",
|
||||
},
|
||||
|
||||
"display": {
|
||||
"compact": False,
|
||||
"personality": "kawaii",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
"""Load configuration from ~/.hermes/config.yaml."""
|
||||
config_path = get_config_path()
|
||||
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
|
||||
# Deep merge
|
||||
for key, value in user_config.items():
|
||||
if isinstance(value, dict) and key in config and isinstance(config[key], dict):
|
||||
config[key].update(value)
|
||||
else:
|
||||
config[key] = value
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load config: {e}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def save_config(config: Dict[str, Any]):
|
||||
"""Save configuration to ~/.hermes/config.yaml."""
|
||||
ensure_hermes_home()
|
||||
config_path = get_config_path()
|
||||
|
||||
with open(config_path, 'w') as f:
|
||||
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
|
||||
def load_env() -> Dict[str, str]:
|
||||
"""Load environment variables from ~/.hermes/.env."""
|
||||
env_path = get_env_path()
|
||||
env_vars = {}
|
||||
|
||||
if env_path.exists():
|
||||
with open(env_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, _, value = line.partition('=')
|
||||
env_vars[key.strip()] = value.strip().strip('"\'')
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
def save_env_value(key: str, value: str):
|
||||
"""Save or update a value in ~/.hermes/.env."""
|
||||
ensure_hermes_home()
|
||||
env_path = get_env_path()
|
||||
|
||||
# Load existing
|
||||
lines = []
|
||||
if env_path.exists():
|
||||
with open(env_path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find and update or append
|
||||
found = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith(f"{key}="):
|
||||
lines[i] = f"{key}={value}\n"
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
lines.append(f"{key}={value}\n")
|
||||
|
||||
with open(env_path, 'w') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
|
||||
def get_env_value(key: str) -> Optional[str]:
|
||||
"""Get a value from ~/.hermes/.env or environment."""
|
||||
# Check environment first
|
||||
if key in os.environ:
|
||||
return os.environ[key]
|
||||
|
||||
# Then check .env file
|
||||
env_vars = load_env()
|
||||
return env_vars.get(key)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Config display
|
||||
# =============================================================================
|
||||
|
||||
def redact_key(key: str) -> str:
|
||||
"""Redact an API key for display."""
|
||||
if not key:
|
||||
return color("(not set)", Colors.DIM)
|
||||
if len(key) < 12:
|
||||
return "***"
|
||||
return key[:4] + "..." + key[-4:]
|
||||
|
||||
|
||||
def show_config():
|
||||
"""Display current configuration."""
|
||||
config = load_config()
|
||||
env_vars = load_env()
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||
print(color("│ 🦋 Hermes Configuration │", Colors.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||
|
||||
# Paths
|
||||
print()
|
||||
print(color("◆ Paths", Colors.CYAN, Colors.BOLD))
|
||||
print(f" Config: {get_config_path()}")
|
||||
print(f" Secrets: {get_env_path()}")
|
||||
print(f" Install: {get_project_root()}")
|
||||
|
||||
# API Keys
|
||||
print()
|
||||
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
keys = [
|
||||
("OPENROUTER_API_KEY", "OpenRouter"),
|
||||
("ANTHROPIC_API_KEY", "Anthropic"),
|
||||
("OPENAI_API_KEY", "OpenAI"),
|
||||
("FIRECRAWL_API_KEY", "Firecrawl"),
|
||||
("BROWSERBASE_API_KEY", "Browserbase"),
|
||||
("FAL_KEY", "FAL"),
|
||||
]
|
||||
|
||||
for env_key, name in keys:
|
||||
value = get_env_value(env_key)
|
||||
print(f" {name:<14} {redact_key(value)}")
|
||||
|
||||
# Model settings
|
||||
print()
|
||||
print(color("◆ Model", Colors.CYAN, Colors.BOLD))
|
||||
print(f" Model: {config.get('model', 'not set')}")
|
||||
print(f" Max turns: {config.get('max_turns', 100)}")
|
||||
print(f" Toolsets: {', '.join(config.get('toolsets', ['all']))}")
|
||||
|
||||
# Terminal
|
||||
print()
|
||||
print(color("◆ Terminal", Colors.CYAN, Colors.BOLD))
|
||||
terminal = config.get('terminal', {})
|
||||
print(f" Backend: {terminal.get('backend', 'local')}")
|
||||
print(f" Working dir: {terminal.get('cwd', '.')}")
|
||||
print(f" Timeout: {terminal.get('timeout', 60)}s")
|
||||
|
||||
if terminal.get('backend') == 'docker':
|
||||
print(f" Docker image: {terminal.get('docker_image', 'python:3.11-slim')}")
|
||||
elif terminal.get('backend') == 'ssh':
|
||||
ssh_host = get_env_value('TERMINAL_SSH_HOST')
|
||||
ssh_user = get_env_value('TERMINAL_SSH_USER')
|
||||
print(f" SSH host: {ssh_host or '(not set)'}")
|
||||
print(f" SSH user: {ssh_user or '(not set)'}")
|
||||
|
||||
# Compression
|
||||
print()
|
||||
print(color("◆ Context Compression", Colors.CYAN, Colors.BOLD))
|
||||
compression = config.get('compression', {})
|
||||
enabled = compression.get('enabled', True)
|
||||
print(f" Enabled: {'yes' if enabled else 'no'}")
|
||||
if enabled:
|
||||
print(f" Threshold: {compression.get('threshold', 0.85) * 100:.0f}%")
|
||||
print(f" Model: {compression.get('summary_model', 'google/gemini-2.0-flash-001')}")
|
||||
|
||||
# Messaging
|
||||
print()
|
||||
print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
telegram_token = get_env_value('TELEGRAM_BOT_TOKEN')
|
||||
discord_token = get_env_value('DISCORD_BOT_TOKEN')
|
||||
|
||||
print(f" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}")
|
||||
print(f" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}")
|
||||
|
||||
print()
|
||||
print(color("─" * 60, Colors.DIM))
|
||||
print(color(" hermes config edit # Edit config file", Colors.DIM))
|
||||
print(color(" hermes config set KEY VALUE", Colors.DIM))
|
||||
print(color(" hermes setup # Run setup wizard", Colors.DIM))
|
||||
print()
|
||||
|
||||
|
||||
def edit_config():
|
||||
"""Open config file in user's editor."""
|
||||
config_path = get_config_path()
|
||||
|
||||
# Ensure config exists
|
||||
if not config_path.exists():
|
||||
save_config(DEFAULT_CONFIG)
|
||||
print(f"Created {config_path}")
|
||||
|
||||
# Find editor
|
||||
editor = os.getenv('EDITOR') or os.getenv('VISUAL')
|
||||
|
||||
if not editor:
|
||||
# Try common editors
|
||||
for cmd in ['nano', 'vim', 'vi', 'code', 'notepad']:
|
||||
import shutil
|
||||
if shutil.which(cmd):
|
||||
editor = cmd
|
||||
break
|
||||
|
||||
if not editor:
|
||||
print(f"No editor found. Config file is at:")
|
||||
print(f" {config_path}")
|
||||
return
|
||||
|
||||
print(f"Opening {config_path} in {editor}...")
|
||||
subprocess.run([editor, str(config_path)])
|
||||
|
||||
|
||||
def set_config_value(key: str, value: str):
|
||||
"""Set a configuration value."""
|
||||
# Check if it's an API key (goes to .env)
|
||||
api_keys = [
|
||||
'OPENROUTER_API_KEY', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY',
|
||||
'FIRECRAWL_API_KEY', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID',
|
||||
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
|
||||
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
|
||||
'SUDO_PASSWORD'
|
||||
]
|
||||
|
||||
if key.upper() in api_keys or key.upper().startswith('TERMINAL_SSH'):
|
||||
save_env_value(key.upper(), value)
|
||||
print(f"✓ Set {key} in {get_env_path()}")
|
||||
return
|
||||
|
||||
# Otherwise it goes to config.yaml
|
||||
config = load_config()
|
||||
|
||||
# Handle nested keys (e.g., "terminal.backend")
|
||||
parts = key.split('.')
|
||||
current = config
|
||||
|
||||
for part in parts[:-1]:
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
|
||||
# Convert value to appropriate type
|
||||
if value.lower() in ('true', 'yes', 'on'):
|
||||
value = True
|
||||
elif value.lower() in ('false', 'no', 'off'):
|
||||
value = False
|
||||
elif value.isdigit():
|
||||
value = int(value)
|
||||
elif value.replace('.', '', 1).isdigit():
|
||||
value = float(value)
|
||||
|
||||
current[parts[-1]] = value
|
||||
save_config(config)
|
||||
print(f"✓ Set {key} = {value} in {get_config_path()}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Command handler
|
||||
# =============================================================================
|
||||
|
||||
def config_command(args):
|
||||
"""Handle config subcommands."""
|
||||
subcmd = getattr(args, 'config_command', None)
|
||||
|
||||
if subcmd is None or subcmd == "show":
|
||||
show_config()
|
||||
|
||||
elif subcmd == "edit":
|
||||
edit_config()
|
||||
|
||||
elif subcmd == "set":
|
||||
key = getattr(args, 'key', None)
|
||||
value = getattr(args, 'value', None)
|
||||
if not key or not value:
|
||||
print("Usage: hermes config set KEY VALUE")
|
||||
print()
|
||||
print("Examples:")
|
||||
print(" hermes config set model anthropic/claude-sonnet-4")
|
||||
print(" hermes config set terminal.backend docker")
|
||||
print(" hermes config set OPENROUTER_API_KEY sk-or-...")
|
||||
sys.exit(1)
|
||||
set_config_value(key, value)
|
||||
|
||||
elif subcmd == "path":
|
||||
print(get_config_path())
|
||||
|
||||
elif subcmd == "env-path":
|
||||
print(get_env_path())
|
||||
|
||||
else:
|
||||
print(f"Unknown config command: {subcmd}")
|
||||
sys.exit(1)
|
||||
131
hermes_cli/cron.py
Normal file
131
hermes_cli/cron.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""
|
||||
Cron subcommand for hermes CLI.
|
||||
|
||||
Handles: hermes cron [list|daemon|tick]
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# ANSI colors
|
||||
class Colors:
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
RED = "\033[31m"
|
||||
GREEN = "\033[32m"
|
||||
YELLOW = "\033[33m"
|
||||
CYAN = "\033[36m"
|
||||
|
||||
def color(text: str, *codes) -> str:
|
||||
if not sys.stdout.isatty():
|
||||
return text
|
||||
return "".join(codes) + text + Colors.RESET
|
||||
|
||||
|
||||
def cron_list(show_all: bool = False):
|
||||
"""List all scheduled jobs."""
|
||||
from cron.jobs import list_jobs
|
||||
|
||||
jobs = list_jobs(include_disabled=show_all)
|
||||
|
||||
if not jobs:
|
||||
print(color("No scheduled jobs.", Colors.DIM))
|
||||
print(color("Create one with: hermes cron add <schedule> <prompt>", Colors.DIM))
|
||||
return
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||
print(color("│ Scheduled Jobs │", Colors.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||
print()
|
||||
|
||||
for job in jobs:
|
||||
job_id = job.get("id", "?")[:8]
|
||||
name = job.get("name", "(unnamed)")
|
||||
schedule = job.get("schedule_display", job.get("schedule", {}).get("value", "?"))
|
||||
enabled = job.get("enabled", True)
|
||||
next_run = job.get("next_run_at", "?")
|
||||
|
||||
# Repeat info
|
||||
repeat_info = job.get("repeat", {})
|
||||
repeat_times = repeat_info.get("times")
|
||||
repeat_completed = repeat_info.get("completed", 0)
|
||||
|
||||
if repeat_times:
|
||||
repeat_str = f"{repeat_completed}/{repeat_times}"
|
||||
else:
|
||||
repeat_str = "∞"
|
||||
|
||||
# Delivery targets
|
||||
deliver = job.get("deliver", ["local"])
|
||||
if isinstance(deliver, str):
|
||||
deliver = [deliver]
|
||||
deliver_str = ", ".join(deliver)
|
||||
|
||||
# Status indicator
|
||||
if not enabled:
|
||||
status = color("[disabled]", Colors.RED)
|
||||
else:
|
||||
status = color("[active]", Colors.GREEN)
|
||||
|
||||
print(f" {color(job_id, Colors.YELLOW)} {status}")
|
||||
print(f" Name: {name}")
|
||||
print(f" Schedule: {schedule}")
|
||||
print(f" Repeat: {repeat_str}")
|
||||
print(f" Next run: {next_run}")
|
||||
print(f" Deliver: {deliver_str}")
|
||||
print()
|
||||
|
||||
|
||||
def cron_daemon(interval: int = 60):
|
||||
"""Run the cron daemon."""
|
||||
from cron.scheduler import start_daemon
|
||||
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||
print(color("│ 🦋 Hermes Cron Daemon │", Colors.CYAN))
|
||||
print(color("├─────────────────────────────────────────────────────────┤", Colors.CYAN))
|
||||
print(color("│ Press Ctrl+C to stop │", Colors.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||
print()
|
||||
|
||||
try:
|
||||
start_daemon(interval=interval)
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print(color("Cron daemon stopped.", Colors.YELLOW))
|
||||
|
||||
|
||||
def cron_tick():
|
||||
"""Run due jobs once (for system cron integration)."""
|
||||
from cron.scheduler import tick
|
||||
|
||||
print(f"[{datetime.now().isoformat()}] Running cron tick...")
|
||||
tick()
|
||||
|
||||
|
||||
def cron_command(args):
|
||||
"""Handle cron subcommands."""
|
||||
subcmd = getattr(args, 'cron_command', None)
|
||||
|
||||
if subcmd is None or subcmd == "list":
|
||||
show_all = getattr(args, 'all', False)
|
||||
cron_list(show_all)
|
||||
|
||||
elif subcmd == "daemon":
|
||||
interval = getattr(args, 'interval', 60)
|
||||
cron_daemon(interval)
|
||||
|
||||
elif subcmd == "tick":
|
||||
cron_tick()
|
||||
|
||||
else:
|
||||
print(f"Unknown cron command: {subcmd}")
|
||||
print("Usage: hermes cron [list|daemon|tick]")
|
||||
sys.exit(1)
|
||||
278
hermes_cli/doctor.py
Normal file
278
hermes_cli/doctor.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"""
|
||||
Doctor command for hermes CLI.
|
||||
|
||||
Diagnoses issues with Hermes Agent setup.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
# ANSI colors
|
||||
class Colors:
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
RED = "\033[31m"
|
||||
GREEN = "\033[32m"
|
||||
YELLOW = "\033[33m"
|
||||
CYAN = "\033[36m"
|
||||
|
||||
def color(text: str, *codes) -> str:
|
||||
if not sys.stdout.isatty():
|
||||
return text
|
||||
return "".join(codes) + text + Colors.RESET
|
||||
|
||||
def check_ok(text: str, detail: str = ""):
|
||||
print(f" {color('✓', Colors.GREEN)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else ""))
|
||||
|
||||
def check_warn(text: str, detail: str = ""):
|
||||
print(f" {color('⚠', Colors.YELLOW)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else ""))
|
||||
|
||||
def check_fail(text: str, detail: str = ""):
|
||||
print(f" {color('✗', Colors.RED)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else ""))
|
||||
|
||||
def check_info(text: str):
|
||||
print(f" {color('→', Colors.CYAN)} {text}")
|
||||
|
||||
|
||||
def run_doctor(args):
|
||||
"""Run diagnostic checks."""
|
||||
should_fix = getattr(args, 'fix', False)
|
||||
|
||||
issues = []
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||
print(color("│ 🩺 Hermes Doctor │", Colors.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||
|
||||
# =========================================================================
|
||||
# Check: Python version
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Python Environment", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
py_version = sys.version_info
|
||||
if py_version >= (3, 10):
|
||||
check_ok(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}")
|
||||
elif py_version >= (3, 8):
|
||||
check_warn(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", "(3.10+ recommended)")
|
||||
else:
|
||||
check_fail(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", "(3.10+ required)")
|
||||
issues.append("Upgrade Python to 3.10+")
|
||||
|
||||
# Check if in virtual environment
|
||||
in_venv = sys.prefix != sys.base_prefix
|
||||
if in_venv:
|
||||
check_ok("Virtual environment active")
|
||||
else:
|
||||
check_warn("Not in virtual environment", "(recommended)")
|
||||
|
||||
# =========================================================================
|
||||
# Check: Required packages
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Required Packages", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
required_packages = [
|
||||
("openai", "OpenAI SDK"),
|
||||
("rich", "Rich (terminal UI)"),
|
||||
("dotenv", "python-dotenv"),
|
||||
("yaml", "PyYAML"),
|
||||
("httpx", "HTTPX"),
|
||||
]
|
||||
|
||||
optional_packages = [
|
||||
("croniter", "Croniter (cron expressions)"),
|
||||
("browserbase", "Browserbase SDK"),
|
||||
("telegram", "python-telegram-bot"),
|
||||
("discord", "discord.py"),
|
||||
]
|
||||
|
||||
for module, name in required_packages:
|
||||
try:
|
||||
__import__(module)
|
||||
check_ok(name)
|
||||
except ImportError:
|
||||
check_fail(name, "(missing)")
|
||||
issues.append(f"Install {name}: pip install {module}")
|
||||
|
||||
for module, name in optional_packages:
|
||||
try:
|
||||
__import__(module)
|
||||
check_ok(name, "(optional)")
|
||||
except ImportError:
|
||||
check_warn(name, "(optional, not installed)")
|
||||
|
||||
# =========================================================================
|
||||
# Check: Configuration files
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Configuration Files", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
env_path = PROJECT_ROOT / '.env'
|
||||
if env_path.exists():
|
||||
check_ok(".env file exists")
|
||||
|
||||
# Check for common issues
|
||||
content = env_path.read_text()
|
||||
if "OPENROUTER_API_KEY" in content or "ANTHROPIC_API_KEY" in content:
|
||||
check_ok("API key configured")
|
||||
else:
|
||||
check_warn("No API key found in .env")
|
||||
issues.append("Run 'hermes setup' to configure API keys")
|
||||
else:
|
||||
check_fail(".env file missing")
|
||||
check_info("Run 'hermes setup' to create one")
|
||||
issues.append("Run 'hermes setup' to create .env")
|
||||
|
||||
config_path = PROJECT_ROOT / 'cli-config.yaml'
|
||||
if config_path.exists():
|
||||
check_ok("cli-config.yaml exists")
|
||||
else:
|
||||
check_warn("cli-config.yaml not found", "(using defaults)")
|
||||
|
||||
# =========================================================================
|
||||
# Check: Directory structure
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Directory Structure", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
hermes_home = Path.home() / ".hermes"
|
||||
if hermes_home.exists():
|
||||
check_ok("~/.hermes directory exists")
|
||||
else:
|
||||
check_warn("~/.hermes not found", "(will be created on first use)")
|
||||
|
||||
logs_dir = PROJECT_ROOT / "logs"
|
||||
if logs_dir.exists():
|
||||
check_ok("logs/ directory exists")
|
||||
else:
|
||||
check_warn("logs/ not found", "(will be created on first use)")
|
||||
|
||||
# =========================================================================
|
||||
# Check: External tools
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ External Tools", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
# Git
|
||||
if shutil.which("git"):
|
||||
check_ok("git")
|
||||
else:
|
||||
check_warn("git not found", "(optional)")
|
||||
|
||||
# Docker (optional)
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "local")
|
||||
if terminal_env == "docker":
|
||||
if shutil.which("docker"):
|
||||
# Check if docker daemon is running
|
||||
result = subprocess.run(["docker", "info"], capture_output=True)
|
||||
if result.returncode == 0:
|
||||
check_ok("docker", "(daemon running)")
|
||||
else:
|
||||
check_fail("docker daemon not running")
|
||||
issues.append("Start Docker daemon")
|
||||
else:
|
||||
check_fail("docker not found", "(required for TERMINAL_ENV=docker)")
|
||||
issues.append("Install Docker or change TERMINAL_ENV")
|
||||
else:
|
||||
if shutil.which("docker"):
|
||||
check_ok("docker", "(optional)")
|
||||
else:
|
||||
check_warn("docker not found", "(optional)")
|
||||
|
||||
# SSH (if using ssh backend)
|
||||
if terminal_env == "ssh":
|
||||
ssh_host = os.getenv("TERMINAL_SSH_HOST")
|
||||
if ssh_host:
|
||||
# Try to connect
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
check_ok(f"SSH connection to {ssh_host}")
|
||||
else:
|
||||
check_fail(f"SSH connection to {ssh_host}")
|
||||
issues.append(f"Check SSH configuration for {ssh_host}")
|
||||
else:
|
||||
check_fail("TERMINAL_SSH_HOST not set", "(required for TERMINAL_ENV=ssh)")
|
||||
issues.append("Set TERMINAL_SSH_HOST in .env")
|
||||
|
||||
# =========================================================================
|
||||
# Check: API connectivity
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ API Connectivity", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
openrouter_key = os.getenv("OPENROUTER_API_KEY")
|
||||
if openrouter_key:
|
||||
try:
|
||||
import httpx
|
||||
response = httpx.get(
|
||||
"https://openrouter.ai/api/v1/models",
|
||||
headers={"Authorization": f"Bearer {openrouter_key}"},
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code == 200:
|
||||
check_ok("OpenRouter API")
|
||||
elif response.status_code == 401:
|
||||
check_fail("OpenRouter API", "(invalid API key)")
|
||||
issues.append("Check OPENROUTER_API_KEY in .env")
|
||||
else:
|
||||
check_fail("OpenRouter API", f"(HTTP {response.status_code})")
|
||||
except Exception as e:
|
||||
check_fail("OpenRouter API", f"({e})")
|
||||
issues.append("Check network connectivity")
|
||||
else:
|
||||
check_warn("OpenRouter API", "(not configured)")
|
||||
|
||||
anthropic_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
if anthropic_key:
|
||||
try:
|
||||
import httpx
|
||||
response = httpx.get(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers={
|
||||
"x-api-key": anthropic_key,
|
||||
"anthropic-version": "2023-06-01"
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code == 200:
|
||||
check_ok("Anthropic API")
|
||||
elif response.status_code == 401:
|
||||
check_fail("Anthropic API", "(invalid API key)")
|
||||
else:
|
||||
# Note: Anthropic may not have /models endpoint
|
||||
check_warn("Anthropic API", "(couldn't verify)")
|
||||
except Exception as e:
|
||||
check_warn("Anthropic API", f"({e})")
|
||||
|
||||
# =========================================================================
|
||||
# Summary
|
||||
# =========================================================================
|
||||
print()
|
||||
if issues:
|
||||
print(color("─" * 60, Colors.YELLOW))
|
||||
print(color(f" Found {len(issues)} issue(s) to address:", Colors.YELLOW, Colors.BOLD))
|
||||
print()
|
||||
for i, issue in enumerate(issues, 1):
|
||||
print(f" {i}. {issue}")
|
||||
print()
|
||||
|
||||
if should_fix:
|
||||
print(color(" Attempting auto-fix is not yet implemented.", Colors.DIM))
|
||||
print(color(" Please resolve issues manually.", Colors.DIM))
|
||||
else:
|
||||
print(color("─" * 60, Colors.GREEN))
|
||||
print(color(" All checks passed! 🎉", Colors.GREEN, Colors.BOLD))
|
||||
|
||||
print()
|
||||
371
hermes_cli/gateway.py
Normal file
371
hermes_cli/gateway.py
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
"""
|
||||
Gateway subcommand for hermes CLI.
|
||||
|
||||
Handles: hermes gateway [run|start|stop|restart|status|install|uninstall]
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
|
||||
def is_linux() -> bool:
|
||||
return sys.platform.startswith('linux')
|
||||
|
||||
def is_macos() -> bool:
|
||||
return sys.platform == 'darwin'
|
||||
|
||||
def is_windows() -> bool:
|
||||
return sys.platform == 'win32'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Service Configuration
|
||||
# =============================================================================
|
||||
|
||||
SERVICE_NAME = "hermes-gateway"
|
||||
SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration"
|
||||
|
||||
def get_systemd_unit_path() -> Path:
|
||||
return Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service"
|
||||
|
||||
def get_launchd_plist_path() -> Path:
|
||||
return Path.home() / "Library" / "LaunchAgents" / "ai.hermes.gateway.plist"
|
||||
|
||||
def get_python_path() -> str:
|
||||
venv_python = PROJECT_ROOT / "venv" / "bin" / "python"
|
||||
if venv_python.exists():
|
||||
return str(venv_python)
|
||||
return sys.executable
|
||||
|
||||
def get_hermes_cli_path() -> str:
|
||||
"""Get the path to the hermes CLI."""
|
||||
# Check if installed via pip
|
||||
import shutil
|
||||
hermes_bin = shutil.which("hermes")
|
||||
if hermes_bin:
|
||||
return hermes_bin
|
||||
|
||||
# Fallback to direct module execution
|
||||
return f"{get_python_path()} -m hermes_cli.main"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Systemd (Linux)
|
||||
# =============================================================================
|
||||
|
||||
def generate_systemd_unit() -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
|
||||
return f"""[Unit]
|
||||
Description={SERVICE_DESCRIPTION}
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={python_path} -m hermes_cli.main gateway run
|
||||
WorkingDirectory={working_dir}
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
"""
|
||||
|
||||
def systemd_install(force: bool = False):
|
||||
unit_path = get_systemd_unit_path()
|
||||
|
||||
if unit_path.exists() and not force:
|
||||
print(f"Service already installed at: {unit_path}")
|
||||
print("Use --force to reinstall")
|
||||
return
|
||||
|
||||
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Installing systemd service to: {unit_path}")
|
||||
unit_path.write_text(generate_systemd_unit())
|
||||
|
||||
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
|
||||
subprocess.run(["systemctl", "--user", "enable", SERVICE_NAME], check=True)
|
||||
|
||||
print()
|
||||
print("✓ Service installed and enabled!")
|
||||
print()
|
||||
print("Next steps:")
|
||||
print(f" hermes gateway start # Start the service")
|
||||
print(f" hermes gateway status # Check status")
|
||||
print(f" journalctl --user -u {SERVICE_NAME} -f # View logs")
|
||||
print()
|
||||
print("To enable lingering (keeps running after logout):")
|
||||
print(" sudo loginctl enable-linger $USER")
|
||||
|
||||
def systemd_uninstall():
|
||||
subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=False)
|
||||
subprocess.run(["systemctl", "--user", "disable", SERVICE_NAME], check=False)
|
||||
|
||||
unit_path = get_systemd_unit_path()
|
||||
if unit_path.exists():
|
||||
unit_path.unlink()
|
||||
print(f"✓ Removed {unit_path}")
|
||||
|
||||
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
|
||||
print("✓ Service uninstalled")
|
||||
|
||||
def systemd_start():
|
||||
subprocess.run(["systemctl", "--user", "start", SERVICE_NAME], check=True)
|
||||
print("✓ Service started")
|
||||
|
||||
def systemd_stop():
|
||||
subprocess.run(["systemctl", "--user", "stop", SERVICE_NAME], check=True)
|
||||
print("✓ Service stopped")
|
||||
|
||||
def systemd_restart():
|
||||
subprocess.run(["systemctl", "--user", "restart", SERVICE_NAME], check=True)
|
||||
print("✓ Service restarted")
|
||||
|
||||
def systemd_status(deep: bool = False):
|
||||
# Check if service unit file exists
|
||||
unit_path = get_systemd_unit_path()
|
||||
if not unit_path.exists():
|
||||
print("✗ Gateway service is not installed")
|
||||
print(" Run: hermes gateway install")
|
||||
return
|
||||
|
||||
# Show detailed status first
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "status", SERVICE_NAME, "--no-pager"],
|
||||
capture_output=False
|
||||
)
|
||||
|
||||
# Check if service is active
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", SERVICE_NAME],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
status = result.stdout.strip()
|
||||
|
||||
if status == "active":
|
||||
print("✓ Gateway service is running")
|
||||
else:
|
||||
print("✗ Gateway service is stopped")
|
||||
print(" Run: hermes gateway start")
|
||||
|
||||
if deep:
|
||||
print()
|
||||
print("Recent logs:")
|
||||
subprocess.run([
|
||||
"journalctl", "--user", "-u", SERVICE_NAME,
|
||||
"-n", "20", "--no-pager"
|
||||
])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Launchd (macOS)
|
||||
# =============================================================================
|
||||
|
||||
def generate_launchd_plist() -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
log_dir = Path.home() / ".hermes" / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>ai.hermes.gateway</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>{python_path}</string>
|
||||
<string>-m</string>
|
||||
<string>hermes_cli.main</string>
|
||||
<string>gateway</string>
|
||||
<string>run</string>
|
||||
</array>
|
||||
|
||||
<key>WorkingDirectory</key>
|
||||
<string>{working_dir}</string>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>{log_dir}/gateway.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>{log_dir}/gateway.error.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
|
||||
def launchd_install(force: bool = False):
|
||||
plist_path = get_launchd_plist_path()
|
||||
|
||||
if plist_path.exists() and not force:
|
||||
print(f"Service already installed at: {plist_path}")
|
||||
print("Use --force to reinstall")
|
||||
return
|
||||
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Installing launchd service to: {plist_path}")
|
||||
plist_path.write_text(generate_launchd_plist())
|
||||
|
||||
subprocess.run(["launchctl", "load", str(plist_path)], check=True)
|
||||
|
||||
print()
|
||||
print("✓ Service installed and loaded!")
|
||||
print()
|
||||
print("Next steps:")
|
||||
print(" hermes gateway status # Check status")
|
||||
print(" tail -f ~/.hermes/logs/gateway.log # View logs")
|
||||
|
||||
def launchd_uninstall():
|
||||
plist_path = get_launchd_plist_path()
|
||||
subprocess.run(["launchctl", "unload", str(plist_path)], check=False)
|
||||
|
||||
if plist_path.exists():
|
||||
plist_path.unlink()
|
||||
print(f"✓ Removed {plist_path}")
|
||||
|
||||
print("✓ Service uninstalled")
|
||||
|
||||
def launchd_start():
|
||||
subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
|
||||
print("✓ Service started")
|
||||
|
||||
def launchd_stop():
|
||||
subprocess.run(["launchctl", "stop", "ai.hermes.gateway"], check=True)
|
||||
print("✓ Service stopped")
|
||||
|
||||
def launchd_restart():
|
||||
launchd_stop()
|
||||
launchd_start()
|
||||
|
||||
def launchd_status(deep: bool = False):
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", "ai.hermes.gateway"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print("✓ Gateway service is loaded")
|
||||
print(result.stdout)
|
||||
else:
|
||||
print("✗ Gateway service is not loaded")
|
||||
|
||||
if deep:
|
||||
log_file = Path.home() / ".hermes" / "logs" / "gateway.log"
|
||||
if log_file.exists():
|
||||
print()
|
||||
print("Recent logs:")
|
||||
subprocess.run(["tail", "-20", str(log_file)])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Gateway Runner
|
||||
# =============================================================================
|
||||
|
||||
def run_gateway(verbose: bool = False):
|
||||
"""Run the gateway in foreground."""
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from gateway.run import start_gateway
|
||||
|
||||
print("┌─────────────────────────────────────────────────────────┐")
|
||||
print("│ 🦋 Hermes Gateway Starting... │")
|
||||
print("├─────────────────────────────────────────────────────────┤")
|
||||
print("│ Press Ctrl+C to stop │")
|
||||
print("└─────────────────────────────────────────────────────────┘")
|
||||
print()
|
||||
|
||||
asyncio.run(start_gateway())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Command Handler
|
||||
# =============================================================================
|
||||
|
||||
def gateway_command(args):
|
||||
"""Handle gateway subcommands."""
|
||||
subcmd = getattr(args, 'gateway_command', None)
|
||||
|
||||
# Default to run if no subcommand
|
||||
if subcmd is None or subcmd == "run":
|
||||
verbose = getattr(args, 'verbose', False)
|
||||
run_gateway(verbose)
|
||||
return
|
||||
|
||||
# Service management commands
|
||||
if subcmd == "install":
|
||||
force = getattr(args, 'force', False)
|
||||
if is_linux():
|
||||
systemd_install(force)
|
||||
elif is_macos():
|
||||
launchd_install(force)
|
||||
else:
|
||||
print("Service installation not supported on this platform.")
|
||||
print("Run manually: hermes gateway run")
|
||||
sys.exit(1)
|
||||
|
||||
elif subcmd == "uninstall":
|
||||
if is_linux():
|
||||
systemd_uninstall()
|
||||
elif is_macos():
|
||||
launchd_uninstall()
|
||||
else:
|
||||
print("Not supported on this platform.")
|
||||
sys.exit(1)
|
||||
|
||||
elif subcmd == "start":
|
||||
if is_linux():
|
||||
systemd_start()
|
||||
elif is_macos():
|
||||
launchd_start()
|
||||
else:
|
||||
print("Not supported on this platform.")
|
||||
sys.exit(1)
|
||||
|
||||
elif subcmd == "stop":
|
||||
if is_linux():
|
||||
systemd_stop()
|
||||
elif is_macos():
|
||||
launchd_stop()
|
||||
else:
|
||||
print("Not supported on this platform.")
|
||||
sys.exit(1)
|
||||
|
||||
elif subcmd == "restart":
|
||||
if is_linux():
|
||||
systemd_restart()
|
||||
elif is_macos():
|
||||
launchd_restart()
|
||||
else:
|
||||
print("Not supported on this platform.")
|
||||
sys.exit(1)
|
||||
|
||||
elif subcmd == "status":
|
||||
deep = getattr(args, 'deep', False)
|
||||
if is_linux():
|
||||
systemd_status(deep)
|
||||
elif is_macos():
|
||||
launchd_status(deep)
|
||||
else:
|
||||
print("Not supported on this platform.")
|
||||
sys.exit(1)
|
||||
432
hermes_cli/main.py
Normal file
432
hermes_cli/main.py
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
#!/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 status # Show status of all components
|
||||
hermes cron # Manage cron jobs
|
||||
hermes cron list # List cron jobs
|
||||
hermes cron daemon # Run cron daemon
|
||||
hermes doctor # Check configuration and dependencies
|
||||
hermes version # Show version
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# Load .env file
|
||||
from dotenv import load_dotenv
|
||||
env_path = PROJECT_ROOT / '.env'
|
||||
if env_path.exists():
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
from hermes_cli import __version__
|
||||
|
||||
|
||||
def cmd_chat(args):
|
||||
"""Run interactive chat CLI."""
|
||||
# Import and run the CLI
|
||||
from cli import main as cli_main
|
||||
|
||||
# Build kwargs from args
|
||||
kwargs = {
|
||||
"model": args.model,
|
||||
"toolsets": args.toolsets,
|
||||
"verbose": args.verbose,
|
||||
"query": args.query,
|
||||
}
|
||||
# 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_setup(args):
|
||||
"""Interactive setup wizard."""
|
||||
from hermes_cli.setup import run_setup_wizard
|
||||
run_setup_wizard(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_update(args):
|
||||
"""Update Hermes Agent to the latest version."""
|
||||
import subprocess
|
||||
|
||||
print("🦋 Updating Hermes Agent...")
|
||||
print()
|
||||
|
||||
# Check if we're in a git repo
|
||||
git_dir = PROJECT_ROOT / '.git'
|
||||
if not git_dir.exists():
|
||||
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)
|
||||
|
||||
# Fetch and pull
|
||||
try:
|
||||
print("→ Fetching updates...")
|
||||
subprocess.run(["git", "fetch", "origin"], cwd=PROJECT_ROOT, check=True)
|
||||
|
||||
# Get current branch
|
||||
result = subprocess.run(
|
||||
["git", "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", "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", "pull", "origin", branch], cwd=PROJECT_ROOT, check=True)
|
||||
|
||||
# Reinstall Python dependencies
|
||||
print("→ Updating Python dependencies...")
|
||||
venv_pip = PROJECT_ROOT / "venv" / "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("✓ Update complete!")
|
||||
print()
|
||||
print("Note: If you have the gateway service running, restart it:")
|
||||
print(" hermes gateway restart")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
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 setup Run setup wizard
|
||||
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 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"
|
||||
)
|
||||
|
||||
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(
|
||||
"-v", "--verbose",
|
||||
action="store_true",
|
||||
help="Verbose output"
|
||||
)
|
||||
chat_parser.set_defaults(func=cmd_chat)
|
||||
|
||||
# =========================================================================
|
||||
# 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_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)
|
||||
|
||||
# =========================================================================
|
||||
# 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 daemon
|
||||
cron_daemon = cron_subparsers.add_parser("daemon", help="Run cron daemon")
|
||||
cron_daemon.add_argument("--interval", type=int, default=60, help="Check interval in seconds")
|
||||
|
||||
# cron tick
|
||||
cron_tick = cron_subparsers.add_parser("tick", help="Run due jobs once (for system cron)")
|
||||
|
||||
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_parser.set_defaults(func=cmd_config)
|
||||
|
||||
# =========================================================================
|
||||
# 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)
|
||||
|
||||
# =========================================================================
|
||||
# Parse and execute
|
||||
# =========================================================================
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle --version flag
|
||||
if args.version:
|
||||
cmd_version(args)
|
||||
return
|
||||
|
||||
# Default to chat if no command specified
|
||||
if args.command is None:
|
||||
# No command = run chat
|
||||
args.query = None
|
||||
args.model = None
|
||||
args.toolsets = None
|
||||
args.verbose = False
|
||||
cmd_chat(args)
|
||||
return
|
||||
|
||||
# Execute the command
|
||||
if hasattr(args, 'func'):
|
||||
args.func(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
448
hermes_cli/setup.py
Normal file
448
hermes_cli/setup.py
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
"""
|
||||
Interactive setup wizard for Hermes Agent.
|
||||
|
||||
Guides users through:
|
||||
1. Installation directory confirmation
|
||||
2. API key configuration
|
||||
3. Model selection
|
||||
4. Terminal backend selection
|
||||
5. Messaging platform setup
|
||||
6. Optional features
|
||||
|
||||
Config files are stored in ~/.hermes/ for easy access.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
# Import config helpers
|
||||
from hermes_cli.config import (
|
||||
get_hermes_home, get_config_path, get_env_path,
|
||||
load_config, save_config, save_env_value, get_env_value,
|
||||
ensure_hermes_home, DEFAULT_CONFIG
|
||||
)
|
||||
|
||||
# ANSI colors
|
||||
class Colors:
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
RED = "\033[31m"
|
||||
GREEN = "\033[32m"
|
||||
YELLOW = "\033[33m"
|
||||
BLUE = "\033[34m"
|
||||
MAGENTA = "\033[35m"
|
||||
CYAN = "\033[36m"
|
||||
|
||||
def color(text: str, *codes) -> str:
|
||||
"""Apply color codes to text."""
|
||||
if not sys.stdout.isatty():
|
||||
return text
|
||||
return "".join(codes) + text + Colors.RESET
|
||||
|
||||
def print_header(title: str):
|
||||
"""Print a section header."""
|
||||
print()
|
||||
print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
def print_info(text: str):
|
||||
"""Print info text."""
|
||||
print(color(f" {text}", Colors.DIM))
|
||||
|
||||
def print_success(text: str):
|
||||
"""Print success message."""
|
||||
print(color(f"✓ {text}", Colors.GREEN))
|
||||
|
||||
def print_warning(text: str):
|
||||
"""Print warning message."""
|
||||
print(color(f"⚠ {text}", Colors.YELLOW))
|
||||
|
||||
def print_error(text: str):
|
||||
"""Print error message."""
|
||||
print(color(f"✗ {text}", Colors.RED))
|
||||
|
||||
def prompt(question: str, default: str = None, password: bool = False) -> str:
|
||||
"""Prompt for input with optional default."""
|
||||
if default:
|
||||
display = f"{question} [{default}]: "
|
||||
else:
|
||||
display = f"{question}: "
|
||||
|
||||
try:
|
||||
if password:
|
||||
import getpass
|
||||
value = getpass.getpass(color(display, Colors.YELLOW))
|
||||
else:
|
||||
value = input(color(display, Colors.YELLOW))
|
||||
|
||||
return value.strip() or default or ""
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
sys.exit(1)
|
||||
|
||||
def prompt_choice(question: str, choices: list, default: int = 0) -> int:
|
||||
"""Prompt for a choice from a list."""
|
||||
print(color(question, Colors.YELLOW))
|
||||
|
||||
for i, choice in enumerate(choices):
|
||||
marker = "●" if i == default else "○"
|
||||
if i == default:
|
||||
print(color(f" {marker} {choice}", Colors.GREEN))
|
||||
else:
|
||||
print(f" {marker} {choice}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
value = input(color(f" Select [1-{len(choices)}] ({default + 1}): ", Colors.DIM))
|
||||
if not value:
|
||||
return default
|
||||
idx = int(value) - 1
|
||||
if 0 <= idx < len(choices):
|
||||
return idx
|
||||
print_error(f"Please enter a number between 1 and {len(choices)}")
|
||||
except ValueError:
|
||||
print_error("Please enter a number")
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
sys.exit(1)
|
||||
|
||||
def prompt_yes_no(question: str, default: bool = True) -> bool:
|
||||
"""Prompt for yes/no."""
|
||||
default_str = "Y/n" if default else "y/N"
|
||||
|
||||
while True:
|
||||
value = input(color(f"{question} [{default_str}]: ", Colors.YELLOW)).strip().lower()
|
||||
|
||||
if not value:
|
||||
return default
|
||||
if value in ('y', 'yes'):
|
||||
return True
|
||||
if value in ('n', 'no'):
|
||||
return False
|
||||
print_error("Please enter 'y' or 'n'")
|
||||
|
||||
|
||||
def run_setup_wizard(args):
|
||||
"""Run the interactive setup wizard."""
|
||||
ensure_hermes_home()
|
||||
|
||||
config = load_config()
|
||||
hermes_home = get_hermes_home()
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA))
|
||||
print(color("│ 🦋 Hermes Agent Setup Wizard │", Colors.MAGENTA))
|
||||
print(color("├─────────────────────────────────────────────────────────┤", Colors.MAGENTA))
|
||||
print(color("│ Let's configure your Hermes Agent installation. │", Colors.MAGENTA))
|
||||
print(color("│ Press Ctrl+C at any time to exit. │", Colors.MAGENTA))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA))
|
||||
|
||||
# =========================================================================
|
||||
# Step 0: Show paths
|
||||
# =========================================================================
|
||||
print_header("Configuration Location")
|
||||
print_info(f"Config file: {get_config_path()}")
|
||||
print_info(f"Secrets file: {get_env_path()}")
|
||||
print_info(f"Data folder: {hermes_home}")
|
||||
print_info(f"Install dir: {PROJECT_ROOT}")
|
||||
print()
|
||||
print_info("You can edit these files directly or use 'hermes config edit'")
|
||||
|
||||
# =========================================================================
|
||||
# Step 1: API Keys
|
||||
# =========================================================================
|
||||
print_header("Model/Auth Provider")
|
||||
|
||||
# Check if already configured
|
||||
existing_or = get_env_value("OPENROUTER_API_KEY")
|
||||
existing_ant = get_env_value("ANTHROPIC_API_KEY")
|
||||
|
||||
if existing_or or existing_ant:
|
||||
configured = "OpenRouter" if existing_or else "Anthropic"
|
||||
print_info(f"Currently configured: {configured}")
|
||||
if not prompt_yes_no("Reconfigure API provider?", False):
|
||||
print_info("Keeping existing configuration")
|
||||
else:
|
||||
existing_or = None # Force reconfigure
|
||||
|
||||
if not existing_or and not existing_ant:
|
||||
provider_choices = [
|
||||
"OpenRouter (recommended - access to all models)",
|
||||
"Anthropic API (direct Claude access)",
|
||||
"OpenAI API",
|
||||
"Skip for now"
|
||||
]
|
||||
|
||||
provider_idx = prompt_choice("Select your primary model provider:", provider_choices, 0)
|
||||
|
||||
if provider_idx == 0: # OpenRouter
|
||||
print_info("Get your API key at: https://openrouter.ai/keys")
|
||||
api_key = prompt("OpenRouter API key", password=True)
|
||||
if api_key:
|
||||
save_env_value("OPENROUTER_API_KEY", api_key)
|
||||
print_success("OpenRouter API key saved")
|
||||
|
||||
elif provider_idx == 1: # Anthropic
|
||||
print_info("Get your API key at: https://console.anthropic.com/")
|
||||
api_key = prompt("Anthropic API key", password=True)
|
||||
if api_key:
|
||||
save_env_value("ANTHROPIC_API_KEY", api_key)
|
||||
print_success("Anthropic API key saved")
|
||||
|
||||
elif provider_idx == 2: # OpenAI
|
||||
print_info("Get your API key at: https://platform.openai.com/api-keys")
|
||||
api_key = prompt("OpenAI API key", password=True)
|
||||
if api_key:
|
||||
save_env_value("OPENAI_API_KEY", api_key)
|
||||
print_success("OpenAI API key saved")
|
||||
|
||||
# =========================================================================
|
||||
# Step 2: Model Selection
|
||||
# =========================================================================
|
||||
print_header("Default Model")
|
||||
|
||||
current_model = config.get('model', 'anthropic/claude-sonnet-4')
|
||||
print_info(f"Current: {current_model}")
|
||||
|
||||
model_choices = [
|
||||
"anthropic/claude-sonnet-4 (recommended)",
|
||||
"anthropic/claude-opus-4",
|
||||
"openai/gpt-4o",
|
||||
"google/gemini-2.0-flash",
|
||||
"Enter custom model",
|
||||
"Keep current"
|
||||
]
|
||||
|
||||
model_idx = prompt_choice("Select default model:", model_choices, 5) # Default: keep current
|
||||
|
||||
if model_idx == 0:
|
||||
config['model'] = "anthropic/claude-sonnet-4"
|
||||
elif model_idx == 1:
|
||||
config['model'] = "anthropic/claude-opus-4"
|
||||
elif model_idx == 2:
|
||||
config['model'] = "openai/gpt-4o"
|
||||
elif model_idx == 3:
|
||||
config['model'] = "google/gemini-2.0-flash"
|
||||
elif model_idx == 4:
|
||||
custom = prompt("Enter model name (e.g., anthropic/claude-sonnet-4)")
|
||||
if custom:
|
||||
config['model'] = custom
|
||||
|
||||
# =========================================================================
|
||||
# Step 3: Terminal Backend
|
||||
# =========================================================================
|
||||
print_header("Terminal Backend")
|
||||
print_info("The terminal tool allows the agent to run commands.")
|
||||
|
||||
current_backend = config.get('terminal', {}).get('backend', 'local')
|
||||
print_info(f"Current: {current_backend}")
|
||||
|
||||
terminal_choices = [
|
||||
"Local (run commands on this machine - no isolation)",
|
||||
"Docker (isolated containers - recommended for security)",
|
||||
"SSH (run commands on a remote server)",
|
||||
"Keep current"
|
||||
]
|
||||
|
||||
# Default based on current
|
||||
default_terminal = {'local': 0, 'docker': 1, 'ssh': 2}.get(current_backend, 0)
|
||||
|
||||
terminal_idx = prompt_choice("Select terminal backend:", terminal_choices, 3) # Default: keep
|
||||
|
||||
if terminal_idx == 0: # Local
|
||||
config.setdefault('terminal', {})['backend'] = 'local'
|
||||
print_success("Terminal set to local")
|
||||
|
||||
if prompt_yes_no("Enable sudo support? (allows agent to run sudo commands)", False):
|
||||
print_warning("SECURITY WARNING: Sudo password will be stored in plaintext")
|
||||
sudo_pass = prompt("Sudo password (leave empty to skip)", password=True)
|
||||
if sudo_pass:
|
||||
save_env_value("SUDO_PASSWORD", sudo_pass)
|
||||
print_success("Sudo password saved")
|
||||
|
||||
elif terminal_idx == 1: # Docker
|
||||
config.setdefault('terminal', {})['backend'] = 'docker'
|
||||
docker_image = prompt("Docker image", config.get('terminal', {}).get('docker_image', 'python:3.11-slim'))
|
||||
config['terminal']['docker_image'] = docker_image
|
||||
print_success("Terminal set to Docker")
|
||||
|
||||
elif terminal_idx == 2: # SSH
|
||||
config.setdefault('terminal', {})['backend'] = 'ssh'
|
||||
|
||||
current_host = get_env_value('TERMINAL_SSH_HOST') or ''
|
||||
current_user = get_env_value('TERMINAL_SSH_USER') or os.getenv("USER", "")
|
||||
|
||||
ssh_host = prompt("SSH host", current_host)
|
||||
ssh_user = prompt("SSH user", current_user)
|
||||
ssh_key = prompt("SSH key path", "~/.ssh/id_rsa")
|
||||
|
||||
if ssh_host:
|
||||
save_env_value("TERMINAL_SSH_HOST", ssh_host)
|
||||
if ssh_user:
|
||||
save_env_value("TERMINAL_SSH_USER", ssh_user)
|
||||
if ssh_key:
|
||||
save_env_value("TERMINAL_SSH_KEY", ssh_key)
|
||||
|
||||
print_success("Terminal set to SSH")
|
||||
|
||||
# =========================================================================
|
||||
# Step 4: Context Compression
|
||||
# =========================================================================
|
||||
print_header("Context Compression")
|
||||
print_info("Automatically summarize old messages when context gets too long.")
|
||||
|
||||
compression = config.get('compression', {})
|
||||
current_enabled = compression.get('enabled', True)
|
||||
|
||||
if prompt_yes_no(f"Enable context compression?", current_enabled):
|
||||
config.setdefault('compression', {})['enabled'] = True
|
||||
|
||||
current_threshold = compression.get('threshold', 0.85)
|
||||
threshold_str = prompt(f"Compression threshold (0.5-0.95)", str(current_threshold))
|
||||
try:
|
||||
threshold = float(threshold_str)
|
||||
if 0.5 <= threshold <= 0.95:
|
||||
config['compression']['threshold'] = threshold
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
print_success("Context compression enabled")
|
||||
else:
|
||||
config.setdefault('compression', {})['enabled'] = False
|
||||
|
||||
# =========================================================================
|
||||
# Step 5: Messaging Platforms (Optional)
|
||||
# =========================================================================
|
||||
print_header("Messaging Platforms (Optional)")
|
||||
print_info("Connect to messaging platforms to chat with Hermes from anywhere.")
|
||||
|
||||
# Telegram
|
||||
existing_telegram = get_env_value('TELEGRAM_BOT_TOKEN')
|
||||
if existing_telegram:
|
||||
print_info("Telegram: already configured")
|
||||
if prompt_yes_no("Reconfigure Telegram?", False):
|
||||
existing_telegram = None
|
||||
|
||||
if not existing_telegram and prompt_yes_no("Set up Telegram bot?", False):
|
||||
print_info("Create a bot via @BotFather on Telegram")
|
||||
token = prompt("Telegram bot token", password=True)
|
||||
if token:
|
||||
save_env_value("TELEGRAM_BOT_TOKEN", token)
|
||||
print_success("Telegram token saved")
|
||||
|
||||
home_channel = prompt("Home channel ID (optional, for cron delivery)")
|
||||
if home_channel:
|
||||
save_env_value("TELEGRAM_HOME_CHANNEL", home_channel)
|
||||
|
||||
# Discord
|
||||
existing_discord = get_env_value('DISCORD_BOT_TOKEN')
|
||||
if existing_discord:
|
||||
print_info("Discord: already configured")
|
||||
if prompt_yes_no("Reconfigure Discord?", False):
|
||||
existing_discord = None
|
||||
|
||||
if not existing_discord and prompt_yes_no("Set up Discord bot?", False):
|
||||
print_info("Create a bot at https://discord.com/developers/applications")
|
||||
token = prompt("Discord bot token", password=True)
|
||||
if token:
|
||||
save_env_value("DISCORD_BOT_TOKEN", token)
|
||||
print_success("Discord token saved")
|
||||
|
||||
home_channel = prompt("Home channel ID (optional, for cron delivery)")
|
||||
if home_channel:
|
||||
save_env_value("DISCORD_HOME_CHANNEL", home_channel)
|
||||
|
||||
# =========================================================================
|
||||
# Step 6: Additional Tools (Optional)
|
||||
# =========================================================================
|
||||
print_header("Additional Tools (Optional)")
|
||||
|
||||
# Firecrawl
|
||||
if not get_env_value('FIRECRAWL_API_KEY'):
|
||||
if prompt_yes_no("Set up web scraping (Firecrawl)?", False):
|
||||
print_info("Get your API key at: https://firecrawl.dev/")
|
||||
api_key = prompt("Firecrawl API key", password=True)
|
||||
if api_key:
|
||||
save_env_value("FIRECRAWL_API_KEY", api_key)
|
||||
print_success("Firecrawl API key saved")
|
||||
else:
|
||||
print_info("Firecrawl: already configured")
|
||||
|
||||
# Browserbase
|
||||
if not get_env_value('BROWSERBASE_API_KEY'):
|
||||
if prompt_yes_no("Set up browser automation (Browserbase)?", False):
|
||||
print_info("Get your API key at: https://browserbase.com/")
|
||||
api_key = prompt("Browserbase API key", password=True)
|
||||
project_id = prompt("Browserbase project ID")
|
||||
if api_key:
|
||||
save_env_value("BROWSERBASE_API_KEY", api_key)
|
||||
if project_id:
|
||||
save_env_value("BROWSERBASE_PROJECT_ID", project_id)
|
||||
print_success("Browserbase configured")
|
||||
else:
|
||||
print_info("Browserbase: already configured")
|
||||
|
||||
# FAL
|
||||
if not get_env_value('FAL_KEY'):
|
||||
if prompt_yes_no("Set up image generation (FAL)?", False):
|
||||
print_info("Get your API key at: https://fal.ai/")
|
||||
api_key = prompt("FAL API key", password=True)
|
||||
if api_key:
|
||||
save_env_value("FAL_KEY", api_key)
|
||||
print_success("FAL API key saved")
|
||||
else:
|
||||
print_info("FAL: already configured")
|
||||
|
||||
# =========================================================================
|
||||
# Save config
|
||||
# =========================================================================
|
||||
save_config(config)
|
||||
|
||||
# =========================================================================
|
||||
# Done!
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.GREEN))
|
||||
print(color("│ ✓ Setup Complete! │", Colors.GREEN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.GREEN))
|
||||
print()
|
||||
|
||||
# Show file locations prominently
|
||||
print(color("📁 Your configuration files:", Colors.CYAN, Colors.BOLD))
|
||||
print()
|
||||
print(f" {color('Settings:', Colors.YELLOW)} {get_config_path()}")
|
||||
print(f" Model, terminal backend, compression, etc.")
|
||||
print()
|
||||
print(f" {color('API Keys:', Colors.YELLOW)} {get_env_path()}")
|
||||
print(f" OpenRouter, Anthropic, Firecrawl, etc.")
|
||||
print()
|
||||
print(f" {color('Data:', Colors.YELLOW)} {hermes_home}/")
|
||||
print(f" Cron jobs, sessions, logs")
|
||||
print()
|
||||
|
||||
print(color("─" * 60, Colors.DIM))
|
||||
print()
|
||||
print(color("📝 To edit your configuration:", Colors.CYAN, Colors.BOLD))
|
||||
print()
|
||||
print(f" {color('hermes config', Colors.GREEN)} View current settings")
|
||||
print(f" {color('hermes config edit', Colors.GREEN)} Open config in your editor")
|
||||
print(f" {color('hermes config set KEY VALUE', Colors.GREEN)}")
|
||||
print(f" Set a specific value")
|
||||
print()
|
||||
print(f" Or edit the files directly:")
|
||||
print(f" {color(f'nano {get_config_path()}', Colors.DIM)}")
|
||||
print(f" {color(f'nano {get_env_path()}', Colors.DIM)}")
|
||||
print()
|
||||
|
||||
print(color("─" * 60, Colors.DIM))
|
||||
print()
|
||||
print(color("🚀 Ready to go!", Colors.CYAN, Colors.BOLD))
|
||||
print()
|
||||
print(f" {color('hermes', Colors.GREEN)} Start chatting")
|
||||
print(f" {color('hermes gateway', Colors.GREEN)} Start messaging gateway")
|
||||
print(f" {color('hermes doctor', Colors.GREEN)} Check for issues")
|
||||
print()
|
||||
239
hermes_cli/status.py
Normal file
239
hermes_cli/status.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"""
|
||||
Status command for hermes CLI.
|
||||
|
||||
Shows the status of all Hermes Agent components.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
# ANSI colors
|
||||
class Colors:
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
RED = "\033[31m"
|
||||
GREEN = "\033[32m"
|
||||
YELLOW = "\033[33m"
|
||||
CYAN = "\033[36m"
|
||||
|
||||
def color(text: str, *codes) -> str:
|
||||
if not sys.stdout.isatty():
|
||||
return text
|
||||
return "".join(codes) + text + Colors.RESET
|
||||
|
||||
def check_mark(ok: bool) -> str:
|
||||
if ok:
|
||||
return color("✓", Colors.GREEN)
|
||||
return color("✗", Colors.RED)
|
||||
|
||||
def redact_key(key: str) -> str:
|
||||
"""Redact an API key for display."""
|
||||
if not key:
|
||||
return "(not set)"
|
||||
if len(key) < 12:
|
||||
return "***"
|
||||
return key[:4] + "..." + key[-4:]
|
||||
|
||||
|
||||
def show_status(args):
|
||||
"""Show status of all Hermes Agent components."""
|
||||
show_all = getattr(args, 'all', False)
|
||||
deep = getattr(args, 'deep', False)
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||
print(color("│ 🦋 Hermes Agent Status │", Colors.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||
|
||||
# =========================================================================
|
||||
# Environment
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Environment", Colors.CYAN, Colors.BOLD))
|
||||
print(f" Project: {PROJECT_ROOT}")
|
||||
print(f" Python: {sys.version.split()[0]}")
|
||||
|
||||
env_path = PROJECT_ROOT / '.env'
|
||||
print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}")
|
||||
|
||||
# =========================================================================
|
||||
# API Keys
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
keys = {
|
||||
"OpenRouter": "OPENROUTER_API_KEY",
|
||||
"Anthropic": "ANTHROPIC_API_KEY",
|
||||
"OpenAI": "OPENAI_API_KEY",
|
||||
"Firecrawl": "FIRECRAWL_API_KEY",
|
||||
"Browserbase": "BROWSERBASE_API_KEY",
|
||||
"FAL": "FAL_KEY",
|
||||
}
|
||||
|
||||
for name, env_var in keys.items():
|
||||
value = os.getenv(env_var, "")
|
||||
has_key = bool(value)
|
||||
display = redact_key(value) if not show_all else value
|
||||
print(f" {name:<12} {check_mark(has_key)} {display}")
|
||||
|
||||
# =========================================================================
|
||||
# Terminal Configuration
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Terminal Backend", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "local")
|
||||
print(f" Backend: {terminal_env}")
|
||||
|
||||
if terminal_env == "ssh":
|
||||
ssh_host = os.getenv("TERMINAL_SSH_HOST", "")
|
||||
ssh_user = os.getenv("TERMINAL_SSH_USER", "")
|
||||
print(f" SSH Host: {ssh_host or '(not set)'}")
|
||||
print(f" SSH User: {ssh_user or '(not set)'}")
|
||||
elif terminal_env == "docker":
|
||||
docker_image = os.getenv("TERMINAL_DOCKER_IMAGE", "python:3.11-slim")
|
||||
print(f" Docker Image: {docker_image}")
|
||||
|
||||
sudo_password = os.getenv("SUDO_PASSWORD", "")
|
||||
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")
|
||||
|
||||
# =========================================================================
|
||||
# Messaging Platforms
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
platforms = {
|
||||
"Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"),
|
||||
"Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"),
|
||||
"WhatsApp": ("WHATSAPP_ENABLED", None),
|
||||
}
|
||||
|
||||
for name, (token_var, home_var) in platforms.items():
|
||||
token = os.getenv(token_var, "")
|
||||
has_token = bool(token)
|
||||
|
||||
home_channel = ""
|
||||
if home_var:
|
||||
home_channel = os.getenv(home_var, "")
|
||||
|
||||
status = "configured" if has_token else "not configured"
|
||||
if home_channel:
|
||||
status += f" (home: {home_channel})"
|
||||
|
||||
print(f" {name:<12} {check_mark(has_token)} {status}")
|
||||
|
||||
# =========================================================================
|
||||
# Gateway Status
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
if sys.platform.startswith('linux'):
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", "hermes-gateway"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
is_active = result.stdout.strip() == "active"
|
||||
print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}")
|
||||
print(f" Manager: systemd (user)")
|
||||
|
||||
elif sys.platform == 'darwin':
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", "ai.hermes.gateway"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
is_loaded = result.returncode == 0
|
||||
print(f" Status: {check_mark(is_loaded)} {'loaded' if is_loaded else 'not loaded'}")
|
||||
print(f" Manager: launchd")
|
||||
else:
|
||||
print(f" Status: {color('N/A', Colors.DIM)}")
|
||||
print(f" Manager: (not supported on this platform)")
|
||||
|
||||
# =========================================================================
|
||||
# Cron Jobs
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
jobs_file = Path.home() / ".hermes" / "cron" / "jobs.json"
|
||||
if jobs_file.exists():
|
||||
import json
|
||||
try:
|
||||
with open(jobs_file) as f:
|
||||
data = json.load(f)
|
||||
jobs = data.get("jobs", [])
|
||||
enabled_jobs = [j for j in jobs if j.get("enabled", True)]
|
||||
print(f" Jobs: {len(enabled_jobs)} active, {len(jobs)} total")
|
||||
except:
|
||||
print(f" Jobs: (error reading jobs file)")
|
||||
else:
|
||||
print(f" Jobs: 0")
|
||||
|
||||
# =========================================================================
|
||||
# Sessions
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Sessions", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
sessions_file = Path.home() / ".hermes" / "sessions" / "sessions.json"
|
||||
if sessions_file.exists():
|
||||
import json
|
||||
try:
|
||||
with open(sessions_file) as f:
|
||||
data = json.load(f)
|
||||
print(f" Active: {len(data)} session(s)")
|
||||
except:
|
||||
print(f" Active: (error reading sessions file)")
|
||||
else:
|
||||
print(f" Active: 0")
|
||||
|
||||
# =========================================================================
|
||||
# Deep checks
|
||||
# =========================================================================
|
||||
if deep:
|
||||
print()
|
||||
print(color("◆ Deep Checks", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
# Check OpenRouter connectivity
|
||||
openrouter_key = os.getenv("OPENROUTER_API_KEY", "")
|
||||
if openrouter_key:
|
||||
try:
|
||||
import httpx
|
||||
response = httpx.get(
|
||||
"https://openrouter.ai/api/v1/models",
|
||||
headers={"Authorization": f"Bearer {openrouter_key}"},
|
||||
timeout=10
|
||||
)
|
||||
ok = response.status_code == 200
|
||||
print(f" OpenRouter: {check_mark(ok)} {'reachable' if ok else f'error ({response.status_code})'}")
|
||||
except Exception as e:
|
||||
print(f" OpenRouter: {check_mark(False)} error: {e}")
|
||||
|
||||
# Check gateway port
|
||||
try:
|
||||
import socket
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(1)
|
||||
result = sock.connect_ex(('127.0.0.1', 18789))
|
||||
sock.close()
|
||||
# Port in use = gateway likely running
|
||||
port_in_use = result == 0
|
||||
# This is informational, not necessarily bad
|
||||
print(f" Port 18789: {'in use' if port_in_use else 'available'}")
|
||||
except:
|
||||
pass
|
||||
|
||||
print()
|
||||
print(color("─" * 60, Colors.DIM))
|
||||
print(color(" Run 'hermes doctor' for detailed diagnostics", Colors.DIM))
|
||||
print(color(" Run 'hermes setup' to configure", Colors.DIM))
|
||||
print()
|
||||
Loading…
Add table
Add a link
Reference in a new issue