Merge remote-tracking branch 'origin/main' into codex/align-codex-provider-conventions-mainrepo
# Conflicts: # cron/scheduler.py # gateway/run.py # tools/delegate_tool.py
This commit is contained in:
commit
32070e6bc0
61 changed files with 8482 additions and 244 deletions
|
|
@ -23,9 +23,13 @@ if _env_path.exists():
|
|||
load_dotenv(_env_path, encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
load_dotenv(_env_path, encoding="latin-1")
|
||||
# Also try project .env as fallback
|
||||
# Also try project .env as dev fallback
|
||||
load_dotenv(PROJECT_ROOT / ".env", override=False, encoding="utf-8")
|
||||
|
||||
# Point mini-swe-agent at ~/.hermes/ so it shares our config
|
||||
os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(HERMES_HOME))
|
||||
os.environ.setdefault("MSWEA_SILENT_STARTUP", "1")
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
|
||||
|
|
@ -207,7 +211,7 @@ def run_doctor(args):
|
|||
print()
|
||||
print(color("◆ Directory Structure", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
hermes_home = Path.home() / ".hermes"
|
||||
hermes_home = HERMES_HOME
|
||||
if hermes_home.exists():
|
||||
check_ok("~/.hermes directory exists")
|
||||
else:
|
||||
|
|
@ -255,17 +259,6 @@ def run_doctor(args):
|
|||
check_ok("Created ~/.hermes/SOUL.md with basic template")
|
||||
fixed_count += 1
|
||||
|
||||
logs_dir = PROJECT_ROOT / "logs"
|
||||
if logs_dir.exists():
|
||||
check_ok("logs/ directory exists (project root)")
|
||||
else:
|
||||
if should_fix:
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
check_ok("Created logs/ directory")
|
||||
fixed_count += 1
|
||||
else:
|
||||
check_warn("logs/ not found", "(will be created on first use)")
|
||||
|
||||
# Check memory directory
|
||||
memories_dir = hermes_home / "memories"
|
||||
if memories_dir.exists():
|
||||
|
|
@ -374,6 +367,41 @@ def run_doctor(args):
|
|||
else:
|
||||
check_warn("Node.js not found", "(optional, needed for browser tools)")
|
||||
|
||||
# npm audit for all Node.js packages
|
||||
if shutil.which("npm"):
|
||||
npm_dirs = [
|
||||
(PROJECT_ROOT, "Browser tools (agent-browser)"),
|
||||
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge"),
|
||||
]
|
||||
for npm_dir, label in npm_dirs:
|
||||
if not (npm_dir / "node_modules").exists():
|
||||
continue
|
||||
try:
|
||||
audit_result = subprocess.run(
|
||||
["npm", "audit", "--json"],
|
||||
cwd=str(npm_dir),
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
import json as _json
|
||||
audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}
|
||||
vuln_count = audit_data.get("metadata", {}).get("vulnerabilities", {})
|
||||
critical = vuln_count.get("critical", 0)
|
||||
high = vuln_count.get("high", 0)
|
||||
moderate = vuln_count.get("moderate", 0)
|
||||
total = critical + high + moderate
|
||||
if total == 0:
|
||||
check_ok(f"{label} deps", "(no known vulnerabilities)")
|
||||
elif critical > 0 or high > 0:
|
||||
check_warn(
|
||||
f"{label} deps",
|
||||
f"({critical} critical, {high} high, {moderate} moderate — run: cd {npm_dir} && npm audit fix)"
|
||||
)
|
||||
issues.append(f"{label} has {total} npm vulnerability(ies)")
|
||||
else:
|
||||
check_ok(f"{label} deps", f"({moderate} moderate vulnerability(ies))")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# =========================================================================
|
||||
# Check: API connectivity
|
||||
# =========================================================================
|
||||
|
|
@ -477,14 +505,15 @@ def run_doctor(args):
|
|||
check_ok(info.get("name", tid))
|
||||
|
||||
for item in unavailable:
|
||||
if item["missing_vars"]:
|
||||
vars_str = ", ".join(item["missing_vars"])
|
||||
env_vars = item.get("missing_vars") or item.get("env_vars") or []
|
||||
if env_vars:
|
||||
vars_str = ", ".join(env_vars)
|
||||
check_warn(item["name"], f"(missing {vars_str})")
|
||||
else:
|
||||
check_warn(item["name"], "(system dependency not met)")
|
||||
|
||||
|
||||
# Count disabled tools with API key requirements
|
||||
api_disabled = [u for u in unavailable if u["missing_vars"]]
|
||||
api_disabled = [u for u in unavailable if (u.get("missing_vars") or u.get("env_vars"))]
|
||||
if api_disabled:
|
||||
issues.append("Run 'hermes setup' to configure missing API keys for full tool access")
|
||||
except Exception as e:
|
||||
|
|
@ -496,7 +525,7 @@ def run_doctor(args):
|
|||
print()
|
||||
print(color("◆ Skills Hub", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
hub_dir = PROJECT_ROOT / "skills" / ".hub"
|
||||
hub_dir = HERMES_HOME / "skills" / ".hub"
|
||||
if hub_dir.exists():
|
||||
check_ok("Skills Hub directory exists")
|
||||
lock_file = hub_dir / "lock.json"
|
||||
|
|
@ -515,7 +544,8 @@ def run_doctor(args):
|
|||
else:
|
||||
check_warn("Skills Hub directory not initialized", "(run: hermes skills list)")
|
||||
|
||||
github_token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
||||
from hermes_cli.config import get_env_value
|
||||
github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN")
|
||||
if github_token:
|
||||
check_ok("GitHub token configured (authenticated API access)")
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -28,19 +28,26 @@ 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 file
|
||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback
|
||||
from dotenv import load_dotenv
|
||||
env_path = PROJECT_ROOT / '.env'
|
||||
if env_path.exists():
|
||||
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=env_path, encoding="utf-8")
|
||||
load_dotenv(dotenv_path=_user_env, encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
load_dotenv(dotenv_path=env_path, encoding="latin-1")
|
||||
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
|
||||
|
||||
|
|
@ -91,8 +98,31 @@ def _has_any_provider_configured() -> bool:
|
|||
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()
|
||||
|
|
@ -121,6 +151,7 @@ def cmd_chat(args):
|
|||
"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}
|
||||
|
|
@ -134,6 +165,116 @@ def cmd_gateway(args):
|
|||
gateway_command(args)
|
||||
|
||||
|
||||
def cmd_whatsapp(args):
|
||||
"""Set up WhatsApp: enable, configure allowed users, 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)
|
||||
print()
|
||||
print("This will link your WhatsApp account to Hermes Agent.")
|
||||
print("The agent will respond to messages sent to your WhatsApp number.")
|
||||
print()
|
||||
|
||||
# Step 1: Enable WhatsApp
|
||||
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 2: Allowed users
|
||||
current_users = get_env_value("WHATSAPP_ALLOWED_USERS") or ""
|
||||
if current_users:
|
||||
print(f"✓ Allowed users: {current_users}")
|
||||
response = input("\n Update allowed users? [y/N] ").strip()
|
||||
if response.lower() in ("y", "yes"):
|
||||
phone = input(" Phone number(s) (e.g. 15551234567, comma-separated): ").strip()
|
||||
if phone:
|
||||
save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", ""))
|
||||
print(f" ✓ Updated to: {phone}")
|
||||
else:
|
||||
print()
|
||||
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 3: Install bridge deps
|
||||
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 4: 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")
|
||||
response = input("\n Re-pair? This will clear the existing session. [y/N] ").strip()
|
||||
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 5: Run bridge in pair-only mode (no HTTP server, exits after QR scan)
|
||||
print()
|
||||
print("─" * 50)
|
||||
print("📱 Scan the QR code with your phone:")
|
||||
print(" WhatsApp → 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
|
||||
|
||||
print()
|
||||
if (session_dir / "creds.json").exists():
|
||||
print("✓ WhatsApp paired successfully!")
|
||||
print()
|
||||
print("Start the gateway with: hermes gateway")
|
||||
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
|
||||
|
|
@ -682,6 +823,8 @@ def main():
|
|||
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 login Authenticate with an inference provider
|
||||
hermes logout Clear stored authentication
|
||||
|
|
@ -691,6 +834,7 @@ Examples:
|
|||
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:
|
||||
|
|
@ -703,6 +847,19 @@ For more help on a command:
|
|||
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")
|
||||
|
||||
|
|
@ -737,6 +894,18 @@ For more help on a command:
|
|||
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)
|
||||
|
||||
# =========================================================================
|
||||
|
|
@ -805,6 +974,16 @@ For more help on a command:
|
|||
)
|
||||
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
|
||||
# =========================================================================
|
||||
|
|
@ -1233,6 +1412,17 @@ For more help on a command:
|
|||
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
|
||||
|
|
@ -1240,6 +1430,8 @@ For more help on a command:
|
|||
args.provider = None
|
||||
args.toolsets = None
|
||||
args.verbose = False
|
||||
args.resume = None
|
||||
args.continue_last = False
|
||||
cmd_chat(args)
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -163,8 +163,15 @@ def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list
|
|||
|
||||
try:
|
||||
from simple_term_menu import TerminalMenu
|
||||
import re
|
||||
|
||||
menu_items = [f" {item}" for item in items]
|
||||
# Strip emoji characters from menu labels — simple_term_menu miscalculates
|
||||
# visual width of emojis on macOS, causing duplicated/garbled lines.
|
||||
_emoji_re = re.compile(
|
||||
"[\U0001f300-\U0001f9ff\U00002600-\U000027bf\U0000fe00-\U0000fe0f"
|
||||
"\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", flags=re.UNICODE
|
||||
)
|
||||
menu_items = [f" {_emoji_re.sub('', item).strip()}" for item in items]
|
||||
|
||||
# Map pre-selected indices to the actual menu entry strings
|
||||
preselected = [menu_items[i] for i in pre_selected if i < len(menu_items)]
|
||||
|
|
@ -1272,13 +1279,22 @@ def run_setup_wizard(args):
|
|||
# WhatsApp
|
||||
existing_whatsapp = get_env_value('WHATSAPP_ENABLED')
|
||||
if not existing_whatsapp and prompt_yes_no("Set up WhatsApp?", False):
|
||||
print_info("WhatsApp uses a bridge service for connectivity.")
|
||||
print_info("See docs/messaging.md for detailed WhatsApp setup instructions.")
|
||||
print_info("WhatsApp connects via a built-in bridge (Baileys).")
|
||||
print_info("Requires Node.js (already installed if you have browser tools).")
|
||||
print_info("On first gateway start, you'll scan a QR code with your phone.")
|
||||
print()
|
||||
if prompt_yes_no("Enable WhatsApp bridge?", True):
|
||||
if prompt_yes_no("Enable WhatsApp?", True):
|
||||
save_env_value("WHATSAPP_ENABLED", "true")
|
||||
print_success("WhatsApp enabled")
|
||||
print_info("Run 'hermes gateway' to complete WhatsApp pairing via QR code")
|
||||
|
||||
allowed_users = prompt(" Your phone number (e.g. 15551234567, comma-separated for multiple)")
|
||||
if allowed_users:
|
||||
save_env_value("WHATSAPP_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
||||
print_success("WhatsApp allowlist configured")
|
||||
else:
|
||||
print_info("⚠️ No allowlist set — anyone who messages your WhatsApp will get a response!")
|
||||
|
||||
print_info("Start the gateway with 'hermes gateway' and scan the QR code.")
|
||||
|
||||
# Gateway reminder
|
||||
any_messaging = (
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from pathlib import Path
|
|||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.config import get_env_path, get_env_value
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
|
||||
def check_mark(ok: bool) -> str:
|
||||
|
|
@ -65,7 +66,7 @@ def show_status(args):
|
|||
print(f" Project: {PROJECT_ROOT}")
|
||||
print(f" Python: {sys.version.split()[0]}")
|
||||
|
||||
env_path = PROJECT_ROOT / '.env'
|
||||
env_path = get_env_path()
|
||||
print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}")
|
||||
|
||||
# =========================================================================
|
||||
|
|
@ -88,7 +89,7 @@ def show_status(args):
|
|||
}
|
||||
|
||||
for name, env_var in keys.items():
|
||||
value = os.getenv(env_var, "")
|
||||
value = get_env_value(env_var) or ""
|
||||
has_key = bool(value)
|
||||
display = redact_key(value) if not show_all else value
|
||||
print(f" {name:<12} {check_mark(has_key)} {display}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue