| |
| """ |
| Hermes CLI - Main entry point. |
| |
| Usage: |
| hermes # Interactive chat (default) |
| hermes chat # Interactive chat |
| hermes gateway # Run gateway in foreground |
| hermes gateway start # Start gateway as service |
| hermes gateway stop # Stop gateway service |
| hermes gateway status # Show gateway status |
| hermes gateway install # Install gateway service |
| hermes gateway uninstall # Uninstall gateway service |
| hermes setup # Interactive setup wizard |
| hermes logout # Clear stored authentication |
| hermes status # Show status of all components |
| hermes cron # Manage cron jobs |
| hermes cron list # List cron jobs |
| hermes cron status # Check if cron scheduler is running |
| hermes doctor # Check configuration and dependencies |
| hermes honcho setup # Configure Honcho AI memory integration |
| hermes honcho status # Show Honcho config and connection status |
| hermes honcho sessions # List directory β session name mappings |
| hermes honcho map <name> # Map current directory to a session name |
| hermes honcho peer # Show peer names and dialectic settings |
| hermes honcho peer --user NAME # Set user peer name |
| hermes honcho peer --ai NAME # Set AI peer name |
| hermes honcho peer --reasoning LEVEL # Set dialectic reasoning level |
| hermes honcho mode # Show current memory mode |
| hermes honcho mode [hybrid|honcho|local] # Set memory mode |
| hermes honcho tokens # Show token budget settings |
| hermes honcho tokens --context N # Set session.context() token cap |
| hermes honcho tokens --dialectic N # Set dialectic result char cap |
| hermes honcho identity # Show AI peer identity representation |
| hermes honcho identity <file> # Seed AI peer identity from a file (SOUL.md etc.) |
| hermes honcho migrate # Step-by-step migration guide: OpenClaw native β Hermes + Honcho |
| hermes version Show version |
| hermes update Update to latest version |
| hermes uninstall Uninstall Hermes Agent |
| hermes acp Run as an ACP server for editor integration |
| hermes sessions browse Interactive session picker with search |
| |
| hermes claw migrate --dry-run # Preview migration without changes |
| """ |
|
|
| import argparse |
| import os |
| import shutil |
| import subprocess |
| import sys |
| from pathlib import Path |
| from typing import Optional |
|
|
| def _add_accept_hooks_flag(parser) -> None: |
| """Attach the ``--accept-hooks`` flag. Shared across every agent |
| subparser so the flag works regardless of CLI position.""" |
| parser.add_argument( |
| "--accept-hooks", |
| action="store_true", |
| default=argparse.SUPPRESS, |
| help=( |
| "Auto-approve unseen shell hooks without a TTY prompt " |
| "(equivalent to HERMES_ACCEPT_HOOKS=1 / hooks_auto_accept: true)." |
| ), |
| ) |
|
|
|
|
| def _require_tty(command_name: str) -> None: |
| """Exit with a clear error if stdin is not a terminal. |
| |
| Interactive TUI commands (hermes tools, hermes setup, hermes model) use |
| curses or input() prompts that spin at 100% CPU when stdin is a pipe. |
| This guard prevents accidental non-interactive invocation. |
| """ |
| if not sys.stdin.isatty(): |
| print( |
| f"Error: 'hermes {command_name}' requires an interactive terminal.\n" |
| f"It cannot be run through a pipe or non-interactive subprocess.\n" |
| f"Run it directly in your terminal instead.", |
| file=sys.stderr, |
| ) |
| sys.exit(1) |
|
|
|
|
| |
| PROJECT_ROOT = Path(__file__).parent.parent.resolve() |
| sys.path.insert(0, str(PROJECT_ROOT)) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| def _apply_profile_override() -> None: |
| """Pre-parse --profile/-p and set HERMES_HOME before module imports.""" |
| argv = sys.argv[1:] |
| profile_name = None |
| consume = 0 |
|
|
| |
| for i, arg in enumerate(argv): |
| if arg in ("--profile", "-p") and i + 1 < len(argv): |
| profile_name = argv[i + 1] |
| consume = 2 |
| break |
| elif arg.startswith("--profile="): |
| profile_name = arg.split("=", 1)[1] |
| consume = 1 |
| break |
|
|
| |
| if profile_name is None: |
| try: |
| from hermes_constants import get_default_hermes_root |
|
|
| active_path = get_default_hermes_root() / "active_profile" |
| if active_path.exists(): |
| name = active_path.read_text().strip() |
| if name and name != "default": |
| profile_name = name |
| consume = 0 |
| except (UnicodeDecodeError, OSError): |
| pass |
|
|
| |
| if profile_name is not None: |
| try: |
| from hermes_cli.profiles import resolve_profile_env |
|
|
| hermes_home = resolve_profile_env(profile_name) |
| except (ValueError, FileNotFoundError) as exc: |
| print(f"Error: {exc}", file=sys.stderr) |
| sys.exit(1) |
| except Exception as exc: |
| |
| print( |
| f"Warning: profile override failed ({exc}), using default", |
| file=sys.stderr, |
| ) |
| return |
| os.environ["HERMES_HOME"] = hermes_home |
| |
| if consume > 0: |
| for i, arg in enumerate(argv): |
| if arg in ("--profile", "-p"): |
| start = i + 1 |
| sys.argv = sys.argv[:start] + sys.argv[start + consume :] |
| break |
| elif arg.startswith("--profile="): |
| start = i + 1 |
| sys.argv = sys.argv[:start] + sys.argv[start + 1 :] |
| break |
|
|
|
|
| _apply_profile_override() |
|
|
| |
| |
| from hermes_cli.config import get_hermes_home |
| from hermes_cli.env_loader import load_hermes_dotenv |
|
|
| load_hermes_dotenv(project_env=PROJECT_ROOT / ".env") |
|
|
| |
| |
| try: |
| from hermes_logging import setup_logging as _setup_logging |
|
|
| _setup_logging(mode="cli") |
| except Exception: |
| pass |
|
|
| |
| try: |
| from hermes_cli.config import load_config as _load_config_early |
| from hermes_constants import apply_ipv4_preference as _apply_ipv4 |
|
|
| _early_cfg = _load_config_early() |
| _net = _early_cfg.get("network", {}) |
| if isinstance(_net, dict) and _net.get("force_ipv4"): |
| _apply_ipv4(force=True) |
| del _early_cfg, _net |
| except Exception: |
| pass |
|
|
| import logging |
| import time as _time |
| from datetime import datetime |
|
|
| from hermes_cli import __version__, __release_date__ |
| from hermes_constants import AI_GATEWAY_BASE_URL, OPENROUTER_BASE_URL |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| def _relative_time(ts) -> str: |
| """Format a timestamp as relative time (e.g., '2h ago', 'yesterday').""" |
| if not ts: |
| return "?" |
| delta = _time.time() - ts |
| if delta < 60: |
| return "just now" |
| if delta < 3600: |
| return f"{int(delta / 60)}m ago" |
| if delta < 86400: |
| return f"{int(delta / 3600)}h ago" |
| if delta < 172800: |
| return "yesterday" |
| if delta < 604800: |
| return f"{int(delta / 86400)}d ago" |
| return datetime.fromtimestamp(ts).strftime("%Y-%m-%d") |
|
|
|
|
| def _has_any_provider_configured() -> bool: |
| """Check if at least one inference provider is usable.""" |
| from hermes_cli.config import get_env_path, get_hermes_home, load_config |
| from hermes_cli.auth import get_auth_status |
|
|
| |
| |
| |
| |
| from hermes_cli.config import DEFAULT_CONFIG |
|
|
| _DEFAULT_MODEL = DEFAULT_CONFIG.get("model", "") |
| cfg = load_config() |
| model_cfg = cfg.get("model") |
| if isinstance(model_cfg, dict): |
| _model_name = (model_cfg.get("default") or "").strip() |
| elif isinstance(model_cfg, str): |
| _model_name = model_cfg.strip() |
| else: |
| _model_name = "" |
| _has_hermes_config = _model_name and _model_name != _DEFAULT_MODEL |
|
|
| |
| |
| |
| from hermes_cli.auth import PROVIDER_REGISTRY |
|
|
| |
| provider_env_vars = { |
| "OPENROUTER_API_KEY", |
| "OPENAI_API_KEY", |
| "ANTHROPIC_API_KEY", |
| "ANTHROPIC_TOKEN", |
| "OPENAI_BASE_URL", |
| } |
| for pconfig in PROVIDER_REGISTRY.values(): |
| if pconfig.auth_type == "api_key": |
| provider_env_vars.update(pconfig.api_key_env_vars) |
| if any(os.getenv(v) for v in provider_env_vars): |
| return True |
|
|
| |
| env_file = get_env_path() |
| if env_file.exists(): |
| try: |
| for line in env_file.read_text().splitlines(): |
| line = line.strip() |
| if line.startswith("#") or "=" not in line: |
| continue |
| key, _, val = line.partition("=") |
| val = val.strip().strip("'\"") |
| if key.strip() in provider_env_vars and val: |
| return True |
| except Exception: |
| pass |
|
|
| |
| try: |
| for provider_id, pconfig in PROVIDER_REGISTRY.items(): |
| if pconfig.auth_type != "api_key": |
| continue |
| status = get_auth_status(provider_id) |
| if status.get("logged_in"): |
| return True |
| except Exception: |
| pass |
|
|
| |
| auth_file = get_hermes_home() / "auth.json" |
| if auth_file.exists(): |
| try: |
| import json |
|
|
| auth = json.loads(auth_file.read_text()) |
| active = auth.get("active_provider") |
| if active: |
| status = get_auth_status(active) |
| if status.get("logged_in"): |
| return True |
| except Exception: |
| pass |
|
|
| |
| |
| |
| |
| if isinstance(model_cfg, dict): |
| cfg_provider = (model_cfg.get("provider") or "").strip() |
| cfg_base_url = (model_cfg.get("base_url") or "").strip() |
| cfg_api_key = (model_cfg.get("api_key") or "").strip() |
| if cfg_provider or cfg_base_url or cfg_api_key: |
| return True |
|
|
| |
| |
| |
| if _has_hermes_config: |
| try: |
| from agent.anthropic_adapter import ( |
| read_claude_code_credentials, |
| is_claude_code_token_valid, |
| ) |
|
|
| creds = read_claude_code_credentials() |
| if creds and ( |
| is_claude_code_token_valid(creds) or creds.get("refreshToken") |
| ): |
| return True |
| except Exception: |
| pass |
|
|
| return False |
|
|
|
|
| def _session_browse_picker(sessions: list) -> Optional[str]: |
| """Interactive curses-based session browser with live search filtering. |
| |
| Returns the selected session ID, or None if cancelled. |
| Uses curses (not simple_term_menu) to avoid the ghost-duplication rendering |
| bug in tmux/iTerm when arrow keys are used. |
| """ |
| if not sessions: |
| print("No sessions found.") |
| return None |
|
|
| |
| try: |
| import curses |
|
|
| result_holder = [None] |
|
|
| def _format_row(s, max_x): |
| """Format a session row for display.""" |
| title = (s.get("title") or "").strip() |
| preview = (s.get("preview") or "").strip() |
| source = s.get("source", "")[:6] |
| last_active = _relative_time(s.get("last_active")) |
| sid = s["id"][:18] |
|
|
| |
| |
| fixed_cols = 3 + 12 + 6 + 18 + 6 |
| name_width = max(20, max_x - fixed_cols) |
|
|
| if title: |
| name = title[:name_width] |
| elif preview: |
| name = preview[:name_width] |
| else: |
| name = sid |
|
|
| return f"{name:<{name_width}} {last_active:<10} {source:<5} {sid}" |
|
|
| def _match(s, query): |
| """Check if a session matches the search query (case-insensitive).""" |
| q = query.lower() |
| return ( |
| q in (s.get("title") or "").lower() |
| or q in (s.get("preview") or "").lower() |
| or q in s.get("id", "").lower() |
| or q in (s.get("source") or "").lower() |
| ) |
|
|
| def _curses_browse(stdscr): |
| curses.curs_set(0) |
| if curses.has_colors(): |
| curses.start_color() |
| curses.use_default_colors() |
| curses.init_pair(1, curses.COLOR_GREEN, -1) |
| curses.init_pair(2, curses.COLOR_YELLOW, -1) |
| curses.init_pair(3, curses.COLOR_CYAN, -1) |
| curses.init_pair(4, 8, -1) |
|
|
| cursor = 0 |
| scroll_offset = 0 |
| search_text = "" |
| filtered = list(sessions) |
|
|
| while True: |
| stdscr.clear() |
| max_y, max_x = stdscr.getmaxyx() |
| if max_y < 5 or max_x < 40: |
| |
| try: |
| stdscr.addstr(0, 0, "Terminal too small") |
| except curses.error: |
| pass |
| stdscr.refresh() |
| stdscr.getch() |
| return |
|
|
| |
| if search_text: |
| header = f" Browse sessions β filter: {search_text}β" |
| header_attr = curses.A_BOLD |
| if curses.has_colors(): |
| header_attr |= curses.color_pair(3) |
| else: |
| header = " Browse sessions β ββ navigate Enter select Type to filter Esc quit" |
| header_attr = curses.A_BOLD |
| if curses.has_colors(): |
| header_attr |= curses.color_pair(2) |
| try: |
| stdscr.addnstr(0, 0, header, max_x - 1, header_attr) |
| except curses.error: |
| pass |
|
|
| |
| fixed_cols = 3 + 12 + 6 + 18 + 6 |
| name_width = max(20, max_x - fixed_cols) |
| col_header = f" {'Title / Preview':<{name_width}} {'Active':<10} {'Src':<5} {'ID'}" |
| try: |
| dim_attr = ( |
| curses.color_pair(4) if curses.has_colors() else curses.A_DIM |
| ) |
| stdscr.addnstr(1, 0, col_header, max_x - 1, dim_attr) |
| except curses.error: |
| pass |
|
|
| |
| visible_rows = max_y - 4 |
| if visible_rows < 1: |
| visible_rows = 1 |
|
|
| |
| if not filtered: |
| try: |
| msg = " No sessions match the filter." |
| stdscr.addnstr(3, 0, msg, max_x - 1, curses.A_DIM) |
| except curses.error: |
| pass |
| else: |
| if cursor >= len(filtered): |
| cursor = len(filtered) - 1 |
| if cursor < 0: |
| cursor = 0 |
| if cursor < scroll_offset: |
| scroll_offset = cursor |
| elif cursor >= scroll_offset + visible_rows: |
| scroll_offset = cursor - visible_rows + 1 |
|
|
| for draw_i, i in enumerate( |
| range( |
| scroll_offset, |
| min(len(filtered), scroll_offset + visible_rows), |
| ) |
| ): |
| y = draw_i + 3 |
| if y >= max_y - 1: |
| break |
| s = filtered[i] |
| arrow = " β " if i == cursor else " " |
| row = arrow + _format_row(s, max_x - 3) |
| attr = curses.A_NORMAL |
| if i == cursor: |
| attr = curses.A_BOLD |
| if curses.has_colors(): |
| attr |= curses.color_pair(1) |
| try: |
| stdscr.addnstr(y, 0, row, max_x - 1, attr) |
| except curses.error: |
| pass |
|
|
| |
| footer_y = max_y - 1 |
| if filtered: |
| footer = f" {cursor + 1}/{len(filtered)} sessions" |
| if len(filtered) < len(sessions): |
| footer += f" (filtered from {len(sessions)})" |
| else: |
| footer = f" 0/{len(sessions)} sessions" |
| try: |
| stdscr.addnstr( |
| footer_y, |
| 0, |
| footer, |
| max_x - 1, |
| curses.color_pair(4) if curses.has_colors() else curses.A_DIM, |
| ) |
| except curses.error: |
| pass |
|
|
| stdscr.refresh() |
| key = stdscr.getch() |
|
|
| if key in (curses.KEY_UP,): |
| if filtered: |
| cursor = (cursor - 1) % len(filtered) |
| elif key in (curses.KEY_DOWN,): |
| if filtered: |
| cursor = (cursor + 1) % len(filtered) |
| elif key in (curses.KEY_ENTER, 10, 13): |
| if filtered: |
| result_holder[0] = filtered[cursor]["id"] |
| return |
| elif key == 27: |
| if search_text: |
| |
| search_text = "" |
| filtered = list(sessions) |
| cursor = 0 |
| scroll_offset = 0 |
| else: |
| |
| return |
| elif key in (curses.KEY_BACKSPACE, 127, 8): |
| if search_text: |
| search_text = search_text[:-1] |
| if search_text: |
| filtered = [s for s in sessions if _match(s, search_text)] |
| else: |
| filtered = list(sessions) |
| cursor = 0 |
| scroll_offset = 0 |
| elif key == ord("q") and not search_text: |
| return |
| elif 32 <= key <= 126: |
| |
| search_text += chr(key) |
| filtered = [s for s in sessions if _match(s, search_text)] |
| cursor = 0 |
| scroll_offset = 0 |
|
|
| curses.wrapper(_curses_browse) |
| return result_holder[0] |
|
|
| except Exception: |
| pass |
|
|
| |
| print("\n Browse sessions (enter number to resume, q to cancel)\n") |
| for i, s in enumerate(sessions): |
| title = (s.get("title") or "").strip() |
| preview = (s.get("preview") or "").strip() |
| label = title or preview or s["id"] |
| if len(label) > 50: |
| label = label[:47] + "..." |
| last_active = _relative_time(s.get("last_active")) |
| src = s.get("source", "")[:6] |
| print(f" {i + 1:>3}. {label:<50} {last_active:<10} {src}") |
|
|
| while True: |
| try: |
| val = input(f"\n Select [1-{len(sessions)}]: ").strip() |
| if not val or val.lower() in ("q", "quit", "exit"): |
| return None |
| idx = int(val) - 1 |
| if 0 <= idx < len(sessions): |
| return sessions[idx]["id"] |
| print(f" Invalid selection. Enter 1-{len(sessions)} or q to cancel.") |
| except ValueError: |
| print(" Invalid input. Enter a number or q to cancel.") |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return None |
|
|
|
|
| def _resolve_last_session(source: str = "cli") -> Optional[str]: |
| """Look up the most recent session ID for a source.""" |
| try: |
| from hermes_state import SessionDB |
|
|
| db = SessionDB() |
| sessions = db.search_sessions(source=source, limit=1) |
| db.close() |
| if sessions: |
| return sessions[0]["id"] |
| except Exception: |
| pass |
| return None |
|
|
|
|
| def _probe_container(cmd: list, backend: str, via_sudo: bool = False): |
| """Run a container inspect probe, returning the CompletedProcess. |
| |
| Catches TimeoutExpired specifically for a human-readable message; |
| all other exceptions propagate naturally. |
| """ |
| try: |
| return subprocess.run(cmd, capture_output=True, text=True, timeout=15) |
| except subprocess.TimeoutExpired: |
| label = f"sudo {backend}" if via_sudo else backend |
| print( |
| f"Error: timed out waiting for {label} to respond.\n" |
| f"The {backend} daemon may be unresponsive or starting up.", |
| file=sys.stderr, |
| ) |
| sys.exit(1) |
|
|
|
|
| def _exec_in_container(container_info: dict, cli_args: list): |
| """Replace the current process with a command inside the managed container. |
| |
| Probes whether sudo is needed (rootful containers), then os.execvp |
| into the container. On success the Python process is replaced entirely |
| and the container's exit code becomes the process exit code (OS semantics). |
| On failure, OSError propagates naturally. |
| |
| Args: |
| container_info: dict with backend, container_name, exec_user, hermes_bin |
| cli_args: the original CLI arguments (everything after 'hermes') |
| """ |
|
|
| backend = container_info["backend"] |
| container_name = container_info["container_name"] |
| exec_user = container_info["exec_user"] |
| hermes_bin = container_info["hermes_bin"] |
|
|
| runtime = shutil.which(backend) |
| if not runtime: |
| print( |
| f"Error: {backend} not found on PATH. Cannot route to container.", |
| file=sys.stderr, |
| ) |
| sys.exit(1) |
|
|
| |
| |
| |
| sudo_path = None |
| probe = _probe_container( |
| [runtime, "inspect", "--format", "ok", container_name], |
| backend, |
| ) |
| if probe.returncode != 0: |
| sudo_path = shutil.which("sudo") |
| if sudo_path: |
| probe2 = _probe_container( |
| [sudo_path, "-n", runtime, "inspect", "--format", "ok", container_name], |
| backend, |
| via_sudo=True, |
| ) |
| if probe2.returncode != 0: |
| print( |
| f"Error: container '{container_name}' not found via {backend}.\n" |
| f"\n" |
| f"The container is likely running as root. Your user cannot see it\n" |
| f"because {backend} uses per-user namespaces. Grant passwordless\n" |
| f"sudo for {backend} β the -n (non-interactive) flag is required\n" |
| f"because a password prompt would hang or break piped commands.\n" |
| f"\n" |
| f"On NixOS:\n" |
| f"\n" |
| f" security.sudo.extraRules = [{{\n" |
| f' users = [ "{os.getenv("USER", "your-user")}" ];\n' |
| f' commands = [{{ command = "{runtime}"; options = [ "NOPASSWD" ]; }}];\n' |
| f" }}];\n" |
| f"\n" |
| f"Or run: sudo hermes {' '.join(cli_args)}", |
| file=sys.stderr, |
| ) |
| sys.exit(1) |
| else: |
| print( |
| f"Error: container '{container_name}' not found via {backend}.\n" |
| f"The container may be running under root. Try: sudo hermes {' '.join(cli_args)}", |
| file=sys.stderr, |
| ) |
| sys.exit(1) |
|
|
| is_tty = sys.stdin.isatty() |
| tty_flags = ["-it"] if is_tty else ["-i"] |
|
|
| env_flags = [] |
| for var in ("TERM", "COLORTERM", "LANG", "LC_ALL"): |
| val = os.environ.get(var) |
| if val: |
| env_flags.extend(["-e", f"{var}={val}"]) |
|
|
| cmd_prefix = [sudo_path, "-n", runtime] if sudo_path else [runtime] |
| exec_cmd = ( |
| cmd_prefix |
| + ["exec"] |
| + tty_flags |
| + ["-u", exec_user] |
| + env_flags |
| + [container_name, hermes_bin] |
| + cli_args |
| ) |
|
|
| os.execvp(exec_cmd[0], exec_cmd) |
|
|
|
|
| def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]: |
| """Resolve a session name (title) or ID to a session ID. |
| |
| - If it looks like a session ID (contains underscore + hex), try direct lookup first. |
| - Otherwise, treat it as a title and use resolve_session_by_title (auto-latest). |
| - Falls back to the other method if the first doesn't match. |
| - If the resolved session is a compression root, follow the chain forward |
| to the latest continuation. Users who remember the old root ID (e.g. |
| from an exit summary printed before the bug fix, or from notes) get |
| resumed at the live tip instead of a stale parent with no messages. |
| """ |
| try: |
| from hermes_state import SessionDB |
|
|
| db = SessionDB() |
|
|
| |
| session = db.get_session(name_or_id) |
| resolved_id: Optional[str] = None |
| if session: |
| resolved_id = session["id"] |
| else: |
| |
| resolved_id = db.resolve_session_by_title(name_or_id) |
|
|
| if resolved_id: |
| |
| |
| try: |
| resolved_id = db.get_compression_tip(resolved_id) or resolved_id |
| except Exception: |
| pass |
|
|
| db.close() |
| return resolved_id |
| except Exception: |
| pass |
| return None |
|
|
|
|
| def _print_tui_exit_summary(session_id: Optional[str]) -> None: |
| """Print a shell-visible epilogue after TUI exits.""" |
| target = session_id or _resolve_last_session(source="tui") |
| if not target: |
| return |
|
|
| db = None |
| try: |
| from hermes_state import SessionDB |
|
|
| db = SessionDB() |
| session = db.get_session(target) |
| if not session: |
| return |
|
|
| title = db.get_session_title(target) |
| message_count = int(session.get("message_count") or 0) |
| input_tokens = int(session.get("input_tokens") or 0) |
| output_tokens = int(session.get("output_tokens") or 0) |
| cache_read_tokens = int(session.get("cache_read_tokens") or 0) |
| cache_write_tokens = int(session.get("cache_write_tokens") or 0) |
| reasoning_tokens = int(session.get("reasoning_tokens") or 0) |
| total_tokens = ( |
| input_tokens |
| + output_tokens |
| + cache_read_tokens |
| + cache_write_tokens |
| + reasoning_tokens |
| ) |
| except Exception: |
| return |
| finally: |
| if db is not None: |
| db.close() |
|
|
| print() |
| print("Resume this session with:") |
| print(f" hermes --tui --resume {target}") |
| if title: |
| print(f' hermes --tui -c "{title}"') |
| print() |
| print(f"Session: {target}") |
| if title: |
| print(f"Title: {title}") |
| print(f"Messages: {message_count}") |
| print( |
| "Tokens: " |
| f"{total_tokens} (in {input_tokens}, out {output_tokens}, " |
| f"cache {cache_read_tokens + cache_write_tokens}, reasoning {reasoning_tokens})" |
| ) |
|
|
|
|
| def _tui_need_npm_install(root: Path) -> bool: |
| """True when @hermes/ink is missing or node_modules is behind package-lock.json (post-pull).""" |
| ink = root / "node_modules" / "@hermes" / "ink" / "package.json" |
| if not ink.is_file(): |
| return True |
| lock = root / "package-lock.json" |
| if not lock.is_file(): |
| return False |
| marker = root / "node_modules" / ".package-lock.json" |
| if not marker.is_file(): |
| return True |
| return lock.stat().st_mtime > marker.stat().st_mtime |
|
|
|
|
| def _find_bundled_tui(tui_dir: Path) -> Optional[Path]: |
| """Directory whose dist/entry.js we should run: HERMES_TUI_DIR first, else repo ui-tui.""" |
| env = os.environ.get("HERMES_TUI_DIR") |
| if env: |
| p = Path(env) |
| if (p / "dist" / "entry.js").exists() and not _tui_need_npm_install(p): |
| return p |
| if (tui_dir / "dist" / "entry.js").exists() and not _tui_need_npm_install(tui_dir): |
| return tui_dir |
| return None |
|
|
|
|
| def _tui_build_needed(tui_dir: Path) -> bool: |
| entry = tui_dir / "dist" / "entry.js" |
| if not entry.exists(): |
| return True |
| dist_m = entry.stat().st_mtime |
| skip = frozenset({"node_modules", "dist"}) |
| for dirpath, dirnames, filenames in os.walk(tui_dir, topdown=True): |
| dirnames[:] = [d for d in dirnames if d not in skip] |
| for fn in filenames: |
| if fn.endswith((".ts", ".tsx")): |
| if os.path.getmtime(os.path.join(dirpath, fn)) > dist_m: |
| return True |
| for meta in ( |
| "package.json", |
| "package-lock.json", |
| "tsconfig.json", |
| "tsconfig.build.json", |
| ): |
| mp = tui_dir / meta |
| if mp.exists() and mp.stat().st_mtime > dist_m: |
| return True |
| return False |
|
|
|
|
| def _hermes_ink_bundle_stale(tui_dir: Path) -> bool: |
| ink_root = tui_dir / "packages" / "hermes-ink" |
| bundle = ink_root / "dist" / "ink-bundle.js" |
| if not bundle.exists(): |
| return True |
| bm = bundle.stat().st_mtime |
| skip = frozenset({"node_modules", "dist"}) |
| for dirpath, dirnames, filenames in os.walk(ink_root, topdown=True): |
| dirnames[:] = [d for d in dirnames if d not in skip] |
| for fn in filenames: |
| if fn.endswith((".ts", ".tsx")): |
| if os.path.getmtime(os.path.join(dirpath, fn)) > bm: |
| return True |
| mp = ink_root / "package.json" |
| if mp.exists() and mp.stat().st_mtime > bm: |
| return True |
| return False |
|
|
|
|
| def _ensure_tui_node() -> None: |
| """Make sure `node` + `npm` are on PATH for the TUI. |
| |
| If either is missing and scripts/lib/node-bootstrap.sh is available, source |
| it and call `ensure_node` (fnm/nvm/proto/brew/bundled cascade). After |
| install, capture the resolved node binary path from the bash subprocess |
| and prepend its directory to os.environ["PATH"] so shutil.which finds the |
| new binaries in this Python process β regardless of which version manager |
| was used (nvm, fnm, proto, brew, or the bundled fallback). |
| |
| Idempotent no-op when node+npm are already discoverable. Set |
| ``HERMES_SKIP_NODE_BOOTSTRAP=1`` to disable auto-install. |
| """ |
| if shutil.which("node") and shutil.which("npm"): |
| return |
| if os.environ.get("HERMES_SKIP_NODE_BOOTSTRAP"): |
| return |
|
|
| helper = PROJECT_ROOT / "scripts" / "lib" / "node-bootstrap.sh" |
| if not helper.is_file(): |
| return |
|
|
| hermes_home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes") |
| try: |
| |
| |
| |
| result = subprocess.run( |
| [ |
| "bash", |
| "-c", |
| f'source "{helper}" >&2 && ensure_node >&2 && command -v node', |
| ], |
| env={**os.environ, "HERMES_HOME": hermes_home}, |
| capture_output=True, |
| text=True, |
| check=False, |
| ) |
| except (OSError, subprocess.SubprocessError): |
| return |
|
|
| parts = os.environ.get("PATH", "").split(os.pathsep) |
| extras: list[Path] = [] |
|
|
| resolved = (result.stdout or "").strip() |
| if resolved: |
| extras.append(Path(resolved).resolve().parent) |
|
|
| extras.extend([Path(hermes_home) / "node" / "bin", Path.home() / ".local" / "bin"]) |
|
|
| for extra in extras: |
| s = str(extra) |
| if extra.is_dir() and s not in parts: |
| parts.insert(0, s) |
| os.environ["PATH"] = os.pathsep.join(parts) |
|
|
|
|
| def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: |
| """TUI: --dev β tsx src; else node dist (HERMES_TUI_DIR or ui-tui, build when stale).""" |
| _ensure_tui_node() |
|
|
| def _node_bin(bin: str) -> str: |
| if bin == "node": |
| env_node = os.environ.get("HERMES_NODE") |
| if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK): |
| return env_node |
| path = shutil.which(bin) |
| if not path: |
| print(f"{bin} not found β install Node.js to use the TUI.") |
| sys.exit(1) |
| return path |
|
|
| |
| if not tui_dev: |
| ext_dir = os.environ.get("HERMES_TUI_DIR") |
| if ext_dir: |
| p = Path(ext_dir) |
| if (p / "dist" / "entry.js").exists() and not _tui_need_npm_install(p): |
| node = _node_bin("node") |
| return [node, str(p / "dist" / "entry.js")], p |
|
|
| npm = _node_bin("npm") |
| if _tui_need_npm_install(tui_dir): |
| if not os.environ.get("HERMES_QUIET"): |
| print("Installing TUI dependenciesβ¦") |
| result = subprocess.run( |
| [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], |
| cwd=str(tui_dir), |
| stdout=subprocess.DEVNULL, |
| stderr=subprocess.PIPE, |
| text=True, |
| env={**os.environ, "CI": "1"}, |
| ) |
| if result.returncode != 0: |
| err = (result.stderr or "").strip() |
| preview = "\n".join(err.splitlines()[-30:]) |
| print("npm install failed.") |
| if preview: |
| print(preview) |
| sys.exit(1) |
|
|
| if tui_dev: |
| if _hermes_ink_bundle_stale(tui_dir): |
| result = subprocess.run( |
| [npm, "run", "build", "--prefix", "packages/hermes-ink"], |
| cwd=str(tui_dir), |
| capture_output=True, |
| text=True, |
| ) |
| if result.returncode != 0: |
| combined = f"{result.stdout or ''}{result.stderr or ''}".strip() |
| preview = "\n".join(combined.splitlines()[-30:]) |
| print("@hermes/ink build failed.") |
| if preview: |
| print(preview) |
| sys.exit(1) |
| tsx = tui_dir / "node_modules" / ".bin" / "tsx" |
| if tsx.exists(): |
| return [str(tsx), "src/entry.tsx"], tui_dir |
| return [npm, "start"], tui_dir |
|
|
| if _tui_build_needed(tui_dir): |
| result = subprocess.run( |
| [npm, "run", "build"], |
| cwd=str(tui_dir), |
| capture_output=True, |
| text=True, |
| ) |
| if result.returncode != 0: |
| combined = f"{result.stdout or ''}{result.stderr or ''}".strip() |
| preview = "\n".join(combined.splitlines()[-30:]) |
| print("TUI build failed.") |
| if preview: |
| print(preview) |
| sys.exit(1) |
|
|
| root = _find_bundled_tui(tui_dir) |
| if not root: |
| print("TUI build did not produce dist/entry.js") |
| sys.exit(1) |
|
|
| node = _node_bin("node") |
| return [node, str(root / "dist" / "entry.js")], root |
|
|
|
|
| def _launch_tui(resume_session_id: Optional[str] = None, tui_dev: bool = False): |
| """Replace current process with the TUI.""" |
| tui_dir = PROJECT_ROOT / "ui-tui" |
|
|
| env = os.environ.copy() |
| env["HERMES_PYTHON_SRC_ROOT"] = os.environ.get( |
| "HERMES_PYTHON_SRC_ROOT", str(PROJECT_ROOT) |
| ) |
| env.setdefault("HERMES_PYTHON", sys.executable) |
| env.setdefault("HERMES_CWD", os.getcwd()) |
| |
| |
| |
| |
| |
| _tokens = env.get("NODE_OPTIONS", "").split() |
| if not any(t.startswith("--max-old-space-size=") for t in _tokens): |
| _tokens.append("--max-old-space-size=8192") |
| if "--expose-gc" not in _tokens: |
| _tokens.append("--expose-gc") |
| env["NODE_OPTIONS"] = " ".join(_tokens) |
| if resume_session_id: |
| env["HERMES_TUI_RESUME"] = resume_session_id |
|
|
| argv, cwd = _make_tui_argv(tui_dir, tui_dev) |
| try: |
| code = subprocess.call(argv, cwd=str(cwd), env=env) |
| except KeyboardInterrupt: |
| code = 130 |
|
|
| if code in (0, 130): |
| _print_tui_exit_summary(resume_session_id) |
|
|
| sys.exit(code) |
|
|
|
|
| def cmd_chat(args): |
| """Run interactive chat CLI.""" |
| use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1" |
|
|
| |
| continue_val = getattr(args, "continue_last", None) |
| if continue_val and not getattr(args, "resume", None): |
| if isinstance(continue_val, str): |
| |
| resolved = _resolve_session_by_name_or_id(continue_val) |
| if resolved: |
| args.resume = resolved |
| else: |
| print(f"No session found matching '{continue_val}'.") |
| print("Use 'hermes sessions list' to see available sessions.") |
| sys.exit(1) |
| else: |
| |
| source = "tui" if use_tui else "cli" |
| last_id = _resolve_last_session(source=source) |
| if not last_id and source == "tui": |
| last_id = _resolve_last_session(source="cli") |
| if last_id: |
| args.resume = last_id |
| else: |
| kind = "TUI" if use_tui else "CLI" |
| print(f"No previous {kind} session found to continue.") |
| sys.exit(1) |
|
|
| |
| resume_val = getattr(args, "resume", None) |
| if resume_val: |
| resolved = _resolve_session_by_name_or_id(resume_val) |
| if resolved: |
| args.resume = resolved |
| |
| |
|
|
| |
| if not _has_any_provider_configured(): |
| print() |
| print( |
| "It looks like Hermes isn't configured yet -- no API keys or providers found." |
| ) |
| print() |
| print(" Run: hermes setup") |
| print() |
|
|
| from hermes_cli.setup import ( |
| is_interactive_stdin, |
| print_noninteractive_setup_guidance, |
| ) |
|
|
| if not is_interactive_stdin(): |
| print_noninteractive_setup_guidance( |
| "No interactive TTY detected for the first-run setup prompt." |
| ) |
| sys.exit(1) |
|
|
| try: |
| reply = input("Run setup now? [Y/n] ").strip().lower() |
| except (EOFError, KeyboardInterrupt): |
| reply = "n" |
| if reply in ("", "y", "yes"): |
| cmd_setup(args) |
| return |
| print() |
| print("You can run 'hermes setup' at any time to configure.") |
| sys.exit(1) |
|
|
| |
| try: |
| from hermes_cli.banner import prefetch_update_check |
|
|
| prefetch_update_check() |
| except Exception: |
| pass |
|
|
| |
| try: |
| from tools.skills_sync import sync_skills |
|
|
| sync_skills(quiet=True) |
| except Exception: |
| pass |
|
|
| |
| if getattr(args, "yolo", False): |
| os.environ["HERMES_YOLO_MODE"] = "1" |
|
|
| |
| |
| |
| |
| |
| if getattr(args, "ignore_user_config", False): |
| os.environ["HERMES_IGNORE_USER_CONFIG"] = "1" |
|
|
| |
| |
| |
| if getattr(args, "ignore_rules", False): |
| os.environ["HERMES_IGNORE_RULES"] = "1" |
|
|
| |
| if getattr(args, "source", None): |
| os.environ["HERMES_SESSION_SOURCE"] = args.source |
|
|
| if use_tui: |
| _launch_tui( |
| getattr(args, "resume", None), |
| tui_dev=getattr(args, "tui_dev", False), |
| ) |
|
|
| |
| from cli import main as cli_main |
|
|
| |
| kwargs = { |
| "model": args.model, |
| "provider": getattr(args, "provider", None), |
| "toolsets": args.toolsets, |
| "skills": getattr(args, "skills", None), |
| "verbose": args.verbose, |
| "quiet": getattr(args, "quiet", False), |
| "query": args.query, |
| "image": getattr(args, "image", None), |
| "resume": getattr(args, "resume", None), |
| "worktree": getattr(args, "worktree", False), |
| "checkpoints": getattr(args, "checkpoints", False), |
| "pass_session_id": getattr(args, "pass_session_id", False), |
| "max_turns": getattr(args, "max_turns", None), |
| "ignore_rules": getattr(args, "ignore_rules", False), |
| "ignore_user_config": getattr(args, "ignore_user_config", False), |
| } |
| |
| kwargs = {k: v for k, v in kwargs.items() if v is not None} |
|
|
| try: |
| cli_main(**kwargs) |
| except ValueError as e: |
| print(f"Error: {e}") |
| sys.exit(1) |
|
|
|
|
| def cmd_gateway(args): |
| """Gateway management commands.""" |
| from hermes_cli.gateway import gateway_command |
|
|
| gateway_command(args) |
|
|
|
|
| def cmd_whatsapp(args): |
| """Set up WhatsApp: choose mode, configure, install bridge, pair via QR.""" |
| _require_tty("whatsapp") |
| from hermes_cli.config import get_env_value, save_env_value |
|
|
| print() |
| print("β WhatsApp Setup") |
| print("=" * 50) |
|
|
| |
| current_mode = get_env_value("WHATSAPP_MODE") or "" |
| if not current_mode: |
| print() |
| print("How will you use WhatsApp with Hermes?") |
| print() |
| print(" 1. Separate bot number (recommended)") |
| print(" People message the bot's number directly β cleanest experience.") |
| print( |
| " Requires a second phone number with WhatsApp installed on a device." |
| ) |
| print() |
| print(" 2. Personal number (self-chat)") |
| print(" You message yourself to talk to the agent.") |
| print(" Quick to set up, but the UX is less intuitive.") |
| print() |
| try: |
| choice = input(" Choose [1/2]: ").strip() |
| except (EOFError, KeyboardInterrupt): |
| print("\nSetup cancelled.") |
| return |
|
|
| if choice == "1": |
| save_env_value("WHATSAPP_MODE", "bot") |
| wa_mode = "bot" |
| print(" β Mode: separate bot number") |
| print() |
| print(" βββββββββββββββββββββββββββββββββββββββββββββββββββ") |
| print(" β Getting a second number for the bot: β") |
| print(" β β") |
| print(" β Easiest: Install WhatsApp Business (free app) β") |
| print(" β on your phone with a second number: β") |
| print(" β β’ Dual-SIM: use your 2nd SIM slot β") |
| print(" β β’ Google Voice: free US number (voice.google) β") |
| print(" β β’ Prepaid SIM: $3-10, verify once β") |
| print(" β β") |
| print(" β WhatsApp Business runs alongside your personal β") |
| print(" β WhatsApp β no second phone needed. β") |
| print(" βββββββββββββββββββββββββββββββββββββββββββββββββββ") |
| else: |
| save_env_value("WHATSAPP_MODE", "self-chat") |
| wa_mode = "self-chat" |
| print(" β Mode: personal number (self-chat)") |
| else: |
| wa_mode = current_mode |
| mode_label = ( |
| "separate bot number" if wa_mode == "bot" else "personal number (self-chat)" |
| ) |
| print(f"\nβ Mode: {mode_label}") |
|
|
| |
| print() |
| current = get_env_value("WHATSAPP_ENABLED") |
| if current and current.lower() == "true": |
| print("β WhatsApp is already enabled") |
| else: |
| save_env_value("WHATSAPP_ENABLED", "true") |
| print("β WhatsApp enabled") |
|
|
| |
| current_users = get_env_value("WHATSAPP_ALLOWED_USERS") or "" |
| if current_users: |
| print(f"β Allowed users: {current_users}") |
| try: |
| response = input("\n Update allowed users? [y/N] ").strip() |
| except (EOFError, KeyboardInterrupt): |
| response = "n" |
| if response.lower() in ("y", "yes"): |
| if wa_mode == "bot": |
| phone = input( |
| " Phone numbers that can message the bot (comma-separated): " |
| ).strip() |
| else: |
| phone = input(" Your phone number (e.g. 15551234567): ").strip() |
| if phone: |
| save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", "")) |
| print(f" β Updated to: {phone}") |
| else: |
| print() |
| if wa_mode == "bot": |
| print(" Who should be allowed to message the bot?") |
| phone = input( |
| " Phone numbers (comma-separated, or * for anyone): " |
| ).strip() |
| else: |
| phone = input(" Your phone number (e.g. 15551234567): ").strip() |
| if phone: |
| save_env_value("WHATSAPP_ALLOWED_USERS", phone.replace(" ", "")) |
| print(f" β Allowed users set: {phone}") |
| else: |
| print(" β No allowlist β the agent will respond to ALL incoming messages") |
|
|
| |
| 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 (this can take a few minutes)...") |
| npm = shutil.which("npm") |
| if not npm: |
| print(" β npm not found on PATH β install Node.js first") |
| return |
| try: |
| result = subprocess.run( |
| [npm, "install", "--no-fund", "--no-audit", "--progress=false"], |
| cwd=str(bridge_dir), |
| stdout=subprocess.DEVNULL, |
| stderr=subprocess.PIPE, |
| text=True, |
| ) |
| except KeyboardInterrupt: |
| print("\n β Install cancelled") |
| return |
| if result.returncode != 0: |
| err = (result.stderr or "").strip() |
| preview = "\n".join(err.splitlines()[-30:]) if err else "(no output)" |
| print(" β npm install failed:") |
| print(preview) |
| return |
| print(" β Dependencies installed") |
| else: |
| print("β Bridge dependencies already installed") |
|
|
| |
| session_dir = get_hermes_home() / "whatsapp" / "session" |
| session_dir.mkdir(parents=True, exist_ok=True) |
|
|
| if (session_dir / "creds.json").exists(): |
| print("β Existing WhatsApp session found") |
| try: |
| response = input( |
| "\n Re-pair? This will clear the existing session. [y/N] " |
| ).strip() |
| except (EOFError, KeyboardInterrupt): |
| response = "n" |
| if response.lower() in ("y", "yes"): |
| 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 |
|
|
| |
| print() |
| print("β" * 50) |
| if wa_mode == "bot": |
| print("π± Open WhatsApp (or WhatsApp Business) on the") |
| print(" phone with the BOT's number, then scan:") |
| else: |
| print("π± Open WhatsApp on your phone, then scan:") |
| print() |
| print(" Settings β Linked Devices β Link a Device") |
| print("β" * 50) |
| print() |
|
|
| try: |
| subprocess.run( |
| ["node", str(bridge_script), "--pair-only", "--session", str(session_dir)], |
| cwd=str(bridge_dir), |
| ) |
| except KeyboardInterrupt: |
| pass |
|
|
| |
| print() |
| if (session_dir / "creds.json").exists(): |
| print("β WhatsApp paired successfully!") |
| print() |
| if wa_mode == "bot": |
| print(" Next steps:") |
| print(" 1. Start the gateway: hermes gateway") |
| print(" 2. Send a message to the bot's WhatsApp number") |
| print(" 3. The agent will reply automatically") |
| print() |
| print(" Tip: Agent responses are prefixed with 'β Hermes Agent'") |
| else: |
| print(" Next steps:") |
| print(" 1. Start the gateway: hermes gateway") |
| print(" 2. Open WhatsApp β Message Yourself") |
| print(" 3. Type a message β the agent will reply") |
| print() |
| print(" Tip: Agent responses are prefixed with 'β Hermes Agent'") |
| print(" so you can tell them apart from your own messages.") |
| print() |
| print(" Or install as a service: hermes gateway install") |
| else: |
| print("β Pairing may not have completed. Run 'hermes whatsapp' to try again.") |
|
|
|
|
| def cmd_setup(args): |
| """Interactive setup wizard.""" |
| from hermes_cli.setup import run_setup_wizard |
|
|
| run_setup_wizard(args) |
|
|
|
|
| def cmd_model(args): |
| """Select default model β starts with provider selection, then model picker.""" |
| _require_tty("model") |
| select_provider_and_model(args=args) |
|
|
|
|
| def select_provider_and_model(args=None): |
| """Core provider selection + model picking logic. |
| |
| Shared by ``cmd_model`` (``hermes model``) and the setup wizard |
| (``setup_model_provider`` in setup.py). Handles the full flow: |
| provider picker, credential prompting, model selection, and config |
| persistence. |
| """ |
| from hermes_cli.auth import ( |
| resolve_provider, |
| AuthError, |
| format_auth_error, |
| ) |
| from hermes_cli.config import ( |
| get_compatible_custom_providers, |
| load_config, |
| get_env_value, |
| ) |
|
|
| config = load_config() |
| current_model = config.get("model") |
| if isinstance(current_model, dict): |
| current_model = current_model.get("default", "") |
| current_model = current_model or "(not set)" |
|
|
| |
| |
| config_provider = None |
| model_cfg = config.get("model") |
| if isinstance(model_cfg, dict): |
| config_provider = model_cfg.get("provider") |
|
|
| effective_provider = ( |
| config_provider or os.getenv("HERMES_INFERENCE_PROVIDER") or "auto" |
| ) |
| try: |
| active = resolve_provider(effective_provider) |
| except AuthError as exc: |
| warning = format_auth_error(exc) |
| print(f"Warning: {warning} Falling back to auto provider detection.") |
| try: |
| active = resolve_provider("auto") |
| except AuthError: |
| active = None |
|
|
| |
| if active == "openrouter" and get_env_value("OPENAI_BASE_URL"): |
| active = "custom" |
|
|
| from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS |
|
|
| provider_labels = dict(_PROVIDER_LABELS) |
| active_label = provider_labels.get(active, active) if active else "none" |
|
|
| print() |
| print(f" Current model: {current_model}") |
| print(f" Active provider: {active_label}") |
| print() |
|
|
| |
| all_providers = [(p.slug, p.tui_desc) for p in CANONICAL_PROVIDERS] |
|
|
| def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: |
| custom_provider_map = {} |
| for entry in get_compatible_custom_providers(cfg): |
| if not isinstance(entry, dict): |
| continue |
| name = (entry.get("name") or "").strip() |
| base_url = (entry.get("base_url") or "").strip() |
| if not name or not base_url: |
| continue |
| key = "custom:" + name.lower().replace(" ", "-") |
| provider_key = (entry.get("provider_key") or "").strip() |
| if provider_key: |
| try: |
| resolve_provider(provider_key) |
| except AuthError: |
| key = provider_key |
| custom_provider_map[key] = { |
| "name": name, |
| "base_url": base_url, |
| "api_key": entry.get("api_key", ""), |
| "key_env": entry.get("key_env", ""), |
| "model": entry.get("model", ""), |
| "api_mode": entry.get("api_mode", ""), |
| "provider_key": provider_key, |
| } |
| return custom_provider_map |
|
|
| |
| _custom_provider_map = _named_custom_provider_map( |
| config |
| ) |
| for key, provider_info in _custom_provider_map.items(): |
| name = provider_info["name"] |
| base_url = provider_info["base_url"] |
| short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/") |
| saved_model = provider_info.get("model", "") |
| model_hint = f" β {saved_model}" if saved_model else "" |
| all_providers.append((key, f"{name} ({short_url}){model_hint}")) |
|
|
| |
| ordered = [] |
| default_idx = 0 |
| for key, label in all_providers: |
| if active and key == active: |
| ordered.append((key, f"{label} β currently active")) |
| default_idx = len(ordered) - 1 |
| else: |
| ordered.append((key, label)) |
|
|
| ordered.append(("custom", "Custom endpoint (enter URL manually)")) |
| _has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool( |
| config.get("custom_providers") |
| ) |
| if _has_saved_custom_list: |
| ordered.append(("remove-custom", "Remove a saved custom provider")) |
| ordered.append(("aux-config", "Configure auxiliary models...")) |
| ordered.append(("cancel", "Leave unchanged")) |
|
|
| provider_idx = _prompt_provider_choice( |
| [label for _, label in ordered], |
| default=default_idx, |
| ) |
| if provider_idx is None or ordered[provider_idx][0] == "cancel": |
| print("No change.") |
| return |
|
|
| selected_provider = ordered[provider_idx][0] |
|
|
| if selected_provider == "aux-config": |
| _aux_config_menu() |
| return |
|
|
| |
| if selected_provider == "openrouter": |
| _model_flow_openrouter(config, current_model) |
| elif selected_provider == "ai-gateway": |
| _model_flow_ai_gateway(config, current_model) |
| elif selected_provider == "nous": |
| _model_flow_nous(config, current_model, args=args) |
| elif selected_provider == "openai-codex": |
| _model_flow_openai_codex(config, current_model) |
| elif selected_provider == "qwen-oauth": |
| _model_flow_qwen_oauth(config, current_model) |
| elif selected_provider == "google-gemini-cli": |
| _model_flow_google_gemini_cli(config, current_model) |
| elif selected_provider == "copilot-acp": |
| _model_flow_copilot_acp(config, current_model) |
| elif selected_provider == "copilot": |
| _model_flow_copilot(config, current_model) |
| elif selected_provider == "custom": |
| _model_flow_custom(config) |
| elif ( |
| selected_provider.startswith("custom:") |
| or selected_provider in _custom_provider_map |
| ): |
| provider_info = _named_custom_provider_map(load_config()).get(selected_provider) |
| if provider_info is None: |
| print( |
| "Warning: the selected saved custom provider is no longer available. " |
| "It may have been removed from config.yaml. No change." |
| ) |
| return |
| _model_flow_named_custom(config, provider_info) |
| elif selected_provider == "remove-custom": |
| _remove_custom_provider(config) |
| elif selected_provider == "anthropic": |
| _model_flow_anthropic(config, current_model) |
| elif selected_provider == "kimi-coding": |
| _model_flow_kimi(config, current_model) |
| elif selected_provider == "stepfun": |
| _model_flow_stepfun(config, current_model) |
| elif selected_provider == "bedrock": |
| _model_flow_bedrock(config, current_model) |
| elif selected_provider in ( |
| "gemini", |
| "deepseek", |
| "xai", |
| "zai", |
| "kimi-coding-cn", |
| "minimax", |
| "minimax-cn", |
| "kilocode", |
| "opencode-zen", |
| "opencode-go", |
| "alibaba", |
| "huggingface", |
| "xiaomi", |
| "arcee", |
| "nvidia", |
| "ollama-cloud", |
| ): |
| _model_flow_api_key_provider(config, selected_provider, current_model) |
|
|
| |
| |
| |
| |
| if selected_provider not in ( |
| "custom", |
| "cancel", |
| "remove-custom", |
| ) and not selected_provider.startswith("custom:"): |
| _clear_stale_openai_base_url() |
|
|
|
|
| def _clear_stale_openai_base_url(): |
| """Remove OPENAI_BASE_URL from ~/.hermes/.env if the active provider is not 'custom'. |
| |
| After a provider switch, a leftover OPENAI_BASE_URL causes auxiliary |
| clients (compression, vision, delegation) with provider:auto to route |
| requests to the old custom endpoint instead of the newly selected |
| provider. See issue #5161. |
| """ |
| from hermes_cli.config import get_env_value, save_env_value, load_config |
|
|
| cfg = load_config() |
| model_cfg = cfg.get("model", {}) |
| if isinstance(model_cfg, dict): |
| provider = (model_cfg.get("provider") or "").strip().lower() |
| else: |
| provider = "" |
|
|
| if provider == "custom" or not provider: |
| return |
|
|
| stale_url = get_env_value("OPENAI_BASE_URL") |
| if stale_url: |
| save_env_value("OPENAI_BASE_URL", "") |
| print( |
| f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url[:40]}...)" |
| if len(stale_url) > 40 |
| else f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url})" |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| _AUX_TASKS: list[tuple[str, str, str]] = [ |
| ("vision", "Vision", "image/screenshot analysis"), |
| ("compression", "Compression", "context summarization"), |
| ("web_extract", "Web extract", "web page summarization"), |
| ("session_search", "Session search", "past-conversation recall"), |
| ("approval", "Approval", "smart command approval"), |
| ("mcp", "MCP", "MCP tool reasoning"), |
| ("flush_memories", "Flush memories", "memory consolidation"), |
| ("title_generation", "Title generation", "session titles"), |
| ("skills_hub", "Skills hub", "skills search/install"), |
| ] |
|
|
|
|
| def _format_aux_current(task_cfg: dict) -> str: |
| """Render the current aux config for display in the task menu.""" |
| if not isinstance(task_cfg, dict): |
| return "auto" |
| base_url = str(task_cfg.get("base_url") or "").strip() |
| provider = str(task_cfg.get("provider") or "auto").strip() or "auto" |
| model = str(task_cfg.get("model") or "").strip() |
| if base_url: |
| short = base_url.replace("https://", "").replace("http://", "").rstrip("/") |
| return f"custom ({short})" + (f" Β· {model}" if model else "") |
| if provider == "auto": |
| return "auto" + (f" Β· {model}" if model else "") |
| if model: |
| return f"{provider} Β· {model}" |
| return provider |
|
|
|
|
| def _save_aux_choice( |
| task: str, |
| *, |
| provider: str, |
| model: str = "", |
| base_url: str = "", |
| api_key: str = "", |
| ) -> None: |
| """Persist an auxiliary task's provider/model to config.yaml. |
| |
| Only writes the four routing fields β timeout, download_timeout, and any |
| other task-specific settings are preserved untouched. The main model |
| config (``model.default``/``model.provider``) is never modified. |
| """ |
| from hermes_cli.config import load_config, save_config |
|
|
| cfg = load_config() |
| aux = cfg.setdefault("auxiliary", {}) |
| if not isinstance(aux, dict): |
| aux = {} |
| cfg["auxiliary"] = aux |
| entry = aux.setdefault(task, {}) |
| if not isinstance(entry, dict): |
| entry = {} |
| aux[task] = entry |
| entry["provider"] = provider |
| entry["model"] = model or "" |
| entry["base_url"] = base_url or "" |
| entry["api_key"] = api_key or "" |
| save_config(cfg) |
|
|
|
|
| def _reset_aux_to_auto() -> int: |
| """Reset every known aux task back to auto/empty. Returns number reset.""" |
| from hermes_cli.config import load_config, save_config |
|
|
| cfg = load_config() |
| aux = cfg.setdefault("auxiliary", {}) |
| if not isinstance(aux, dict): |
| aux = {} |
| cfg["auxiliary"] = aux |
| count = 0 |
| for task, _name, _desc in _AUX_TASKS: |
| entry = aux.setdefault(task, {}) |
| if not isinstance(entry, dict): |
| entry = {} |
| aux[task] = entry |
| changed = False |
| if entry.get("provider") not in (None, "", "auto"): |
| entry["provider"] = "auto" |
| changed = True |
| for field in ("model", "base_url", "api_key"): |
| if entry.get(field): |
| entry[field] = "" |
| changed = True |
| |
| if changed: |
| count += 1 |
| save_config(cfg) |
| return count |
|
|
|
|
| def _aux_config_menu() -> None: |
| """Top-level auxiliary-model picker β choose a task to configure. |
| |
| Loops until the user picks "Back" so multiple tasks can be configured |
| without returning to the main provider menu. |
| """ |
| from hermes_cli.config import load_config |
|
|
| while True: |
| cfg = load_config() |
| aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {} |
|
|
| print() |
| print(" Auxiliary models β side-task routing") |
| print() |
| print(" Side tasks (vision, compression, web extraction, etc.) default") |
| print(" to your main chat model. \"auto\" means \"use my main model\" β") |
| print(" Hermes only falls back to a lightweight backend (OpenRouter,") |
| print(" Nous Portal) if the main model is unavailable. Override a") |
| print(" task below if you want it pinned to a specific provider/model.") |
| print() |
|
|
| |
| name_col = max(len(name) for _, name, _ in _AUX_TASKS) + 2 |
| desc_col = max(len(desc) for _, _, desc in _AUX_TASKS) + 4 |
| entries: list[tuple[str, str]] = [] |
| for task_key, name, desc in _AUX_TASKS: |
| task_cfg = aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {} |
| current = _format_aux_current(task_cfg) |
| label = f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}" |
| entries.append((task_key, label)) |
| entries.append(("__reset__", "Reset all to auto")) |
| entries.append(("__back__", "Back")) |
|
|
| idx = _prompt_provider_choice( |
| [label for _, label in entries], default=0, |
| ) |
| if idx is None: |
| return |
| key = entries[idx][0] |
| if key == "__back__": |
| return |
| if key == "__reset__": |
| n = _reset_aux_to_auto() |
| if n: |
| print(f"Reset {n} auxiliary task(s) to auto.") |
| else: |
| print("All auxiliary tasks were already set to auto.") |
| print() |
| continue |
| |
| _aux_select_for_task(key) |
|
|
|
|
| def _aux_select_for_task(task: str) -> None: |
| """Pick a provider + model for a single auxiliary task and persist it. |
| |
| Uses ``list_authenticated_providers()`` to only show providers the user |
| has already configured. This avoids re-running OAuth/credential flows |
| inside the aux picker β users set up new providers through the normal |
| ``hermes model`` flow, then route aux tasks to them here. |
| """ |
| from hermes_cli.config import load_config |
| from hermes_cli.model_switch import list_authenticated_providers |
|
|
| cfg = load_config() |
| aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {} |
| task_cfg = aux.get(task, {}) if isinstance(aux.get(task), dict) else {} |
| current_provider = str(task_cfg.get("provider") or "auto").strip() or "auto" |
| current_model = str(task_cfg.get("model") or "").strip() |
| current_base_url = str(task_cfg.get("base_url") or "").strip() |
|
|
| display_name = next((name for key, name, _ in _AUX_TASKS if key == task), task) |
|
|
| |
| try: |
| providers = list_authenticated_providers(current_provider=current_provider) |
| except Exception as exc: |
| print(f"Could not detect authenticated providers: {exc}") |
| providers = [] |
|
|
| entries: list[tuple[str, str, list[str]]] = [] |
| |
| auto_marker = " β current" if current_provider == "auto" and not current_base_url else "" |
| entries.append(("__auto__", f"auto (recommended){auto_marker}", [])) |
|
|
| for p in providers: |
| slug = p.get("slug", "") |
| name = p.get("name") or slug |
| total = p.get("total_models", 0) |
| models = p.get("models") or [] |
| model_hint = f" β {total} models" if total else "" |
| marker = " β current" if slug == current_provider and not current_base_url else "" |
| entries.append((slug, f"{name}{model_hint}{marker}", list(models))) |
|
|
| |
| custom_marker = " β current" if current_base_url else "" |
| entries.append(("__custom__", f"Custom endpoint (direct URL){custom_marker}", [])) |
| entries.append(("__back__", "Back", [])) |
|
|
| print() |
| print(f" Configure {display_name} β current: {_format_aux_current(task_cfg)}") |
| print() |
|
|
| idx = _prompt_provider_choice([label for _, label, _ in entries], default=0) |
| if idx is None: |
| return |
| slug, _label, models = entries[idx] |
|
|
| if slug == "__back__": |
| return |
|
|
| if slug == "__auto__": |
| _save_aux_choice(task, provider="auto", model="", base_url="", api_key="") |
| print(f"{display_name}: reset to auto.") |
| return |
|
|
| if slug == "__custom__": |
| _aux_flow_custom_endpoint(task, task_cfg) |
| return |
|
|
| |
| _aux_flow_provider_model(task, slug, models, current_model) |
|
|
|
|
| def _aux_flow_provider_model( |
| task: str, |
| provider_slug: str, |
| curated_models: list, |
| current_model: str = "", |
| ) -> None: |
| """Prompt for a model under an already-authenticated provider, save to aux.""" |
| from hermes_cli.auth import _prompt_model_selection |
| from hermes_cli.models import get_pricing_for_provider |
|
|
| display_name = next((name for key, name, _ in _AUX_TASKS if key == task), task) |
|
|
| |
| pricing: dict = {} |
| try: |
| pricing = get_pricing_for_provider(provider_slug) or {} |
| except Exception: |
| pricing = {} |
|
|
| model_list = list(curated_models) |
|
|
| |
| |
| |
| if not model_list: |
| print(f"No curated model list for {provider_slug}.") |
| print("Enter a model slug manually (blank = use provider default):") |
| try: |
| val = input("Model: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
| selected = val or "" |
| else: |
| selected = _prompt_model_selection( |
| model_list, current_model=current_model, pricing=pricing, |
| ) |
| if selected is None: |
| print("No change.") |
| return |
|
|
| _save_aux_choice(task, provider=provider_slug, model=selected or "", |
| base_url="", api_key="") |
| if selected: |
| print(f"{display_name}: {provider_slug} Β· {selected}") |
| else: |
| print(f"{display_name}: {provider_slug} (provider default model)") |
|
|
|
|
| def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None: |
| """Prompt for a direct OpenAI-compatible base_url + optional api_key/model.""" |
| import getpass |
|
|
| display_name = next((name for key, name, _ in _AUX_TASKS if key == task), task) |
| current_base_url = str(task_cfg.get("base_url") or "").strip() |
| current_model = str(task_cfg.get("model") or "").strip() |
|
|
| print() |
| print(f" Custom endpoint for {display_name}") |
| print(" Provide an OpenAI-compatible base URL (e.g. http://localhost:11434/v1)") |
| print() |
| try: |
| url_prompt = f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: " |
| url = input(url_prompt).strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
| url = url or current_base_url |
| if not url: |
| print("No URL provided. No change.") |
| return |
| try: |
| model_prompt = f"Model slug (optional) [{current_model}]: " if current_model else "Model slug (optional): " |
| model = input(model_prompt).strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
| model = model or current_model |
| try: |
| api_key = getpass.getpass("API key (optional, blank = use OPENAI_API_KEY): ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
|
|
| _save_aux_choice( |
| task, provider="custom", model=model, base_url=url, api_key=api_key, |
| ) |
| short_url = url.replace("https://", "").replace("http://", "").rstrip("/") |
| print(f"{display_name}: custom ({short_url})" + (f" Β· {model}" if model else "")) |
|
|
|
|
| def _prompt_provider_choice(choices, *, default=0): |
| """Show provider selection menu with curses arrow-key navigation. |
| |
| Falls back to a numbered list when curses is unavailable (e.g. piped |
| stdin, non-TTY environments). Returns the selected index, or None |
| if the user cancels. |
| """ |
| try: |
| from hermes_cli.setup import _curses_prompt_choice |
|
|
| idx = _curses_prompt_choice("Select provider:", choices, default) |
| if idx >= 0: |
| print() |
| return idx |
| except Exception: |
| pass |
|
|
| |
| print("Select provider:") |
| for i, c in enumerate(choices, 1): |
| marker = "β" if i - 1 == default else " " |
| print(f" {marker} {i}. {c}") |
| print() |
| while True: |
| try: |
| val = input(f"Choice [1-{len(choices)}] ({default + 1}): ").strip() |
| if not val: |
| return default |
| idx = int(val) - 1 |
| if 0 <= idx < len(choices): |
| return idx |
| print(f"Please enter 1-{len(choices)}") |
| except ValueError: |
| print("Please enter a number") |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return None |
|
|
|
|
| def _model_flow_openrouter(config, current_model=""): |
| """OpenRouter provider: ensure API key, then pick model.""" |
| from hermes_cli.auth import ( |
| _prompt_model_selection, |
| _save_model_choice, |
| deactivate_provider, |
| ) |
| from hermes_cli.config import get_env_value, save_env_value |
|
|
| api_key = get_env_value("OPENROUTER_API_KEY") |
| if not api_key: |
| print("No OpenRouter API key configured.") |
| print("Get one at: https://openrouter.ai/keys") |
| print() |
| try: |
| import getpass |
|
|
| key = getpass.getpass("OpenRouter API key (or Enter to cancel): ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
| if not key: |
| print("Cancelled.") |
| return |
| save_env_value("OPENROUTER_API_KEY", key) |
| print("API key saved.") |
| print() |
|
|
| from hermes_cli.models import model_ids, get_pricing_for_provider |
|
|
| openrouter_models = model_ids(force_refresh=True) |
|
|
| |
| pricing = get_pricing_for_provider("openrouter", force_refresh=True) |
|
|
| selected = _prompt_model_selection( |
| openrouter_models, current_model=current_model, pricing=pricing |
| ) |
| if selected: |
| _save_model_choice(selected) |
|
|
| |
| from hermes_cli.config import load_config, save_config |
|
|
| cfg = load_config() |
| model = cfg.get("model") |
| if not isinstance(model, dict): |
| model = {"default": model} if model else {} |
| cfg["model"] = model |
| model["provider"] = "openrouter" |
| model["base_url"] = OPENROUTER_BASE_URL |
| model["api_mode"] = "chat_completions" |
| save_config(cfg) |
| deactivate_provider() |
| print(f"Default model set to: {selected} (via OpenRouter)") |
| else: |
| print("No change.") |
|
|
|
|
| def _model_flow_ai_gateway(config, current_model=""): |
| """Vercel AI Gateway provider: ensure API key, then pick model with pricing.""" |
| from hermes_cli.auth import ( |
| _prompt_model_selection, |
| _save_model_choice, |
| deactivate_provider, |
| ) |
| from hermes_cli.config import get_env_value, save_env_value |
|
|
| api_key = get_env_value("AI_GATEWAY_API_KEY") |
| if not api_key: |
| print("No Vercel AI Gateway API key configured.") |
| print("Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway") |
| print("Add a payment method to get $5 in free credits.") |
| print() |
| try: |
| import getpass |
|
|
| key = getpass.getpass("AI Gateway API key (or Enter to cancel): ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
| if not key: |
| print("Cancelled.") |
| return |
| save_env_value("AI_GATEWAY_API_KEY", key) |
| print("API key saved.") |
| print() |
|
|
| from hermes_cli.models import ai_gateway_model_ids, get_pricing_for_provider |
|
|
| models_list = ai_gateway_model_ids(force_refresh=True) |
| pricing = get_pricing_for_provider("ai-gateway", force_refresh=True) |
|
|
| selected = _prompt_model_selection( |
| models_list, current_model=current_model, pricing=pricing |
| ) |
| if selected: |
| _save_model_choice(selected) |
|
|
| from hermes_cli.config import load_config, save_config |
|
|
| cfg = load_config() |
| model = cfg.get("model") |
| if not isinstance(model, dict): |
| model = {"default": model} if model else {} |
| cfg["model"] = model |
| model["provider"] = "ai-gateway" |
| model["base_url"] = AI_GATEWAY_BASE_URL |
| model["api_mode"] = "chat_completions" |
| save_config(cfg) |
| deactivate_provider() |
| print(f"Default model set to: {selected} (via Vercel AI Gateway)") |
| else: |
| print("No change.") |
|
|
|
|
| def _model_flow_nous(config, current_model="", args=None): |
| """Nous Portal provider: ensure logged in, then pick model.""" |
| from hermes_cli.auth import ( |
| get_provider_auth_state, |
| _prompt_model_selection, |
| _save_model_choice, |
| _update_config_for_provider, |
| resolve_nous_runtime_credentials, |
| AuthError, |
| format_auth_error, |
| _login_nous, |
| PROVIDER_REGISTRY, |
| ) |
| from hermes_cli.config import ( |
| get_env_value, |
| load_config, |
| save_config, |
| save_env_value, |
| ) |
| from hermes_cli.nous_subscription import prompt_enable_tool_gateway |
|
|
| state = get_provider_auth_state("nous") |
| if not state or not state.get("access_token"): |
| print("Not logged into Nous Portal. Starting login...") |
| print() |
| try: |
| mock_args = argparse.Namespace( |
| portal_url=getattr(args, "portal_url", None), |
| inference_url=getattr(args, "inference_url", None), |
| client_id=getattr(args, "client_id", None), |
| scope=getattr(args, "scope", None), |
| no_browser=bool(getattr(args, "no_browser", False)), |
| timeout=getattr(args, "timeout", None) or 15.0, |
| ca_bundle=getattr(args, "ca_bundle", None), |
| insecure=bool(getattr(args, "insecure", False)), |
| ) |
| _login_nous(mock_args, PROVIDER_REGISTRY["nous"]) |
| |
| try: |
| _refreshed = load_config() or {} |
| prompt_enable_tool_gateway(_refreshed) |
| except Exception: |
| pass |
| except SystemExit: |
| print("Login cancelled or failed.") |
| return |
| except Exception as exc: |
| print(f"Login failed: {exc}") |
| return |
| |
| return |
|
|
| |
| |
| |
| from hermes_cli.models import ( |
| _PROVIDER_MODELS, |
| get_pricing_for_provider, |
| check_nous_free_tier, |
| partition_nous_models_by_tier, |
| ) |
|
|
| model_ids = _PROVIDER_MODELS.get("nous", []) |
| if not model_ids: |
| print("No curated models available for Nous Portal.") |
| return |
|
|
| |
| try: |
| creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=5 * 60) |
| except Exception as exc: |
| relogin = isinstance(exc, AuthError) and exc.relogin_required |
| msg = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc) |
| if relogin: |
| print(f"Session expired: {msg}") |
| print("Re-authenticating with Nous Portal...\n") |
| try: |
| mock_args = argparse.Namespace( |
| portal_url=None, |
| inference_url=None, |
| client_id=None, |
| scope=None, |
| no_browser=False, |
| timeout=15.0, |
| ca_bundle=None, |
| insecure=False, |
| ) |
| _login_nous(mock_args, PROVIDER_REGISTRY["nous"]) |
| except Exception as login_exc: |
| print(f"Re-login failed: {login_exc}") |
| return |
| print(f"Could not verify credentials: {msg}") |
| return |
|
|
| |
| pricing = get_pricing_for_provider("nous") |
|
|
| |
| free_tier = check_nous_free_tier() |
|
|
| |
| |
| unavailable_models: list[str] = [] |
| if free_tier: |
| model_ids, unavailable_models = partition_nous_models_by_tier( |
| model_ids, pricing, free_tier=True |
| ) |
|
|
| if not model_ids and not unavailable_models: |
| print("No models available for Nous Portal after filtering.") |
| return |
|
|
| |
| _nous_portal_url = "" |
| try: |
| _nous_state = get_provider_auth_state("nous") |
| if _nous_state: |
| _nous_portal_url = _nous_state.get("portal_base_url", "") |
| except Exception: |
| pass |
|
|
| if free_tier and not model_ids: |
| print("No free models currently available.") |
| if unavailable_models: |
| from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL |
|
|
| _url = (_nous_portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/") |
| print(f"Upgrade at {_url} to access paid models.") |
| return |
|
|
| print( |
| f'Showing {len(model_ids)} curated models β use "Enter custom model name" for others.' |
| ) |
|
|
| selected = _prompt_model_selection( |
| model_ids, |
| current_model=current_model, |
| pricing=pricing, |
| unavailable_models=unavailable_models, |
| portal_url=_nous_portal_url, |
| ) |
| if selected: |
| _save_model_choice(selected) |
| |
| inference_url = creds.get("base_url", "") |
| _update_config_for_provider("nous", inference_url) |
| current_model_cfg = config.get("model") |
| if isinstance(current_model_cfg, dict): |
| model_cfg = dict(current_model_cfg) |
| elif isinstance(current_model_cfg, str) and current_model_cfg.strip(): |
| model_cfg = {"default": current_model_cfg.strip()} |
| else: |
| model_cfg = {} |
| model_cfg["provider"] = "nous" |
| model_cfg["default"] = selected |
| if inference_url and inference_url.strip(): |
| model_cfg["base_url"] = inference_url.rstrip("/") |
| else: |
| model_cfg.pop("base_url", None) |
| config["model"] = model_cfg |
| |
| if get_env_value("OPENAI_BASE_URL"): |
| save_env_value("OPENAI_BASE_URL", "") |
| save_env_value("OPENAI_API_KEY", "") |
| save_config(config) |
| print(f"Default model set to: {selected} (via Nous Portal)") |
| |
| prompt_enable_tool_gateway(config) |
| else: |
| print("No change.") |
|
|
|
|
| def _model_flow_openai_codex(config, current_model=""): |
| """OpenAI Codex provider: ensure logged in, then pick model.""" |
| from hermes_cli.auth import ( |
| get_codex_auth_status, |
| _prompt_model_selection, |
| _save_model_choice, |
| _update_config_for_provider, |
| _login_openai_codex, |
| PROVIDER_REGISTRY, |
| DEFAULT_CODEX_BASE_URL, |
| ) |
| from hermes_cli.codex_models import get_codex_model_ids |
|
|
| status = get_codex_auth_status() |
| if not status.get("logged_in"): |
| print("Not logged into OpenAI Codex. Starting login...") |
| print() |
| try: |
| mock_args = argparse.Namespace() |
| _login_openai_codex(mock_args, PROVIDER_REGISTRY["openai-codex"]) |
| except SystemExit: |
| print("Login cancelled or failed.") |
| return |
| except Exception as exc: |
| print(f"Login failed: {exc}") |
| return |
|
|
| _codex_token = None |
| |
| |
| try: |
| _codex_status = get_codex_auth_status() |
| if _codex_status.get("logged_in"): |
| _codex_token = _codex_status.get("api_key") |
| except Exception: |
| pass |
| if not _codex_token: |
| try: |
| from hermes_cli.auth import resolve_codex_runtime_credentials |
|
|
| _codex_creds = resolve_codex_runtime_credentials() |
| _codex_token = _codex_creds.get("api_key") |
| except Exception: |
| pass |
|
|
| codex_models = get_codex_model_ids(access_token=_codex_token) |
|
|
| selected = _prompt_model_selection(codex_models, current_model=current_model) |
| if selected: |
| _save_model_choice(selected) |
| _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) |
| print(f"Default model set to: {selected} (via OpenAI Codex)") |
| else: |
| print("No change.") |
|
|
|
|
| _DEFAULT_QWEN_PORTAL_MODELS = [ |
| "qwen3-coder-plus", |
| "qwen3-coder", |
| ] |
|
|
|
|
| def _model_flow_qwen_oauth(_config, current_model=""): |
| """Qwen OAuth provider: reuse local Qwen CLI login, then pick model.""" |
| from hermes_cli.auth import ( |
| get_qwen_auth_status, |
| resolve_qwen_runtime_credentials, |
| _prompt_model_selection, |
| _save_model_choice, |
| _update_config_for_provider, |
| DEFAULT_QWEN_BASE_URL, |
| ) |
| from hermes_cli.models import fetch_api_models |
|
|
| status = get_qwen_auth_status() |
| if not status.get("logged_in"): |
| print("Not logged into Qwen CLI OAuth.") |
| print("Run: qwen auth qwen-oauth") |
| auth_file = status.get("auth_file") |
| if auth_file: |
| print(f"Expected credentials file: {auth_file}") |
| if status.get("error"): |
| print(f"Error: {status.get('error')}") |
| return |
|
|
| |
| models = None |
| try: |
| creds = resolve_qwen_runtime_credentials(refresh_if_expiring=True) |
| models = fetch_api_models(creds["api_key"], creds["base_url"]) |
| except Exception: |
| pass |
| if not models: |
| models = list(_DEFAULT_QWEN_PORTAL_MODELS) |
|
|
| default = current_model or (models[0] if models else "qwen3-coder-plus") |
| selected = _prompt_model_selection(models, current_model=default) |
| if selected: |
| _save_model_choice(selected) |
| _update_config_for_provider("qwen-oauth", DEFAULT_QWEN_BASE_URL) |
| print(f"Default model set to: {selected} (via Qwen OAuth)") |
| else: |
| print("No change.") |
|
|
|
|
| def _model_flow_google_gemini_cli(_config, current_model=""): |
| """Google Gemini OAuth (PKCE) via Cloud Code Assist β supports free AND paid tiers. |
| |
| Flow: |
| 1. Show upfront warning about Google's ToS stance (per opencode-gemini-auth). |
| 2. If creds missing, run PKCE browser OAuth via agent.google_oauth. |
| 3. Resolve project context (env -> config -> auto-discover -> free tier). |
| 4. Prompt user to pick a model. |
| 5. Save to ~/.hermes/config.yaml. |
| """ |
| from hermes_cli.auth import ( |
| DEFAULT_GEMINI_CLOUDCODE_BASE_URL, |
| get_gemini_oauth_auth_status, |
| resolve_gemini_oauth_runtime_credentials, |
| _prompt_model_selection, |
| _save_model_choice, |
| _update_config_for_provider, |
| ) |
| from hermes_cli.models import _PROVIDER_MODELS |
|
|
| print() |
| print("β Google considers using the Gemini CLI OAuth client with third-party") |
| print(" software a policy violation. Some users have reported account") |
| print(" restrictions. You can use your own API key via 'gemini' provider") |
| print(" for the lowest-risk experience.") |
| print() |
| try: |
| proceed = input("Continue with OAuth login? [y/N]: ").strip().lower() |
| except (EOFError, KeyboardInterrupt): |
| print("Cancelled.") |
| return |
| if proceed not in {"y", "yes"}: |
| print("Cancelled.") |
| return |
|
|
| status = get_gemini_oauth_auth_status() |
| if not status.get("logged_in"): |
| try: |
| from agent.google_oauth import resolve_project_id_from_env, start_oauth_flow |
|
|
| env_project = resolve_project_id_from_env() |
| start_oauth_flow(force_relogin=True, project_id=env_project) |
| except Exception as exc: |
| print(f"OAuth login failed: {exc}") |
| return |
|
|
| |
| try: |
| creds = resolve_gemini_oauth_runtime_credentials(force_refresh=False) |
| project_id = creds.get("project_id", "") |
| if project_id: |
| print(f" Using GCP project: {project_id}") |
| else: |
| print( |
| " No GCP project configured β free tier will be auto-provisioned on first request." |
| ) |
| except Exception as exc: |
| print(f"Failed to resolve Gemini credentials: {exc}") |
| return |
|
|
| models = list(_PROVIDER_MODELS.get("google-gemini-cli") or []) |
| default = current_model or (models[0] if models else "gemini-3-flash-preview") |
| selected = _prompt_model_selection(models, current_model=default) |
| if selected: |
| _save_model_choice(selected) |
| _update_config_for_provider( |
| "google-gemini-cli", DEFAULT_GEMINI_CLOUDCODE_BASE_URL |
| ) |
| print( |
| f"Default model set to: {selected} (via Google Gemini OAuth / Code Assist)" |
| ) |
| else: |
| print("No change.") |
|
|
|
|
| def _model_flow_custom(config): |
| """Custom endpoint: collect URL, API key, and model name. |
| |
| Automatically saves the endpoint to ``custom_providers`` in config.yaml |
| so it appears in the provider menu on subsequent runs. |
| """ |
| from hermes_cli.auth import _save_model_choice, deactivate_provider |
| from hermes_cli.config import get_env_value, load_config, save_config |
|
|
| current_url = get_env_value("OPENAI_BASE_URL") or "" |
| current_key = get_env_value("OPENAI_API_KEY") or "" |
|
|
| print("Custom OpenAI-compatible endpoint configuration:") |
| if current_url: |
| print(f" Current URL: {current_url}") |
| if current_key: |
| print(f" Current key: {current_key[:8]}...") |
| print() |
|
|
| try: |
| base_url = input( |
| f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: " |
| ).strip() |
| import getpass |
|
|
| api_key = getpass.getpass( |
| f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: " |
| ).strip() |
| except (KeyboardInterrupt, EOFError): |
| print("\nCancelled.") |
| return |
|
|
| if not base_url and not current_url: |
| print("No URL provided. Cancelled.") |
| return |
|
|
| |
| effective_url = base_url or current_url |
| if not effective_url.startswith(("http://", "https://")): |
| print(f"Invalid URL: {effective_url} (must start with http:// or https://)") |
| return |
|
|
| effective_key = api_key or current_key |
|
|
| |
| |
| |
| _url_lower = effective_url.rstrip("/").lower() |
| _looks_local = any( |
| h in _url_lower |
| for h in ("localhost", "127.0.0.1", "0.0.0.0", ":11434", ":8080", ":5000") |
| ) |
| if _looks_local and not _url_lower.endswith("/v1"): |
| print() |
| print(f" Hint: Did you mean to add /v1 at the end?") |
| print(f" Most local model servers (Ollama, vLLM, llama.cpp) require it.") |
| print(f" e.g. {effective_url.rstrip('/')}/v1") |
| try: |
| _add_v1 = input(" Add /v1? [Y/n]: ").strip().lower() |
| except (KeyboardInterrupt, EOFError): |
| _add_v1 = "n" |
| if _add_v1 in ("", "y", "yes"): |
| effective_url = effective_url.rstrip("/") + "/v1" |
| if base_url: |
| base_url = effective_url |
| print(f" Updated URL: {effective_url}") |
| print() |
|
|
| from hermes_cli.models import probe_api_models |
|
|
| probe = probe_api_models(effective_key, effective_url) |
| if probe.get("used_fallback") and probe.get("resolved_base_url"): |
| print( |
| f"Warning: endpoint verification worked at {probe['resolved_base_url']}/models, " |
| f"not the exact URL you entered. Saving the working base URL instead." |
| ) |
| effective_url = probe["resolved_base_url"] |
| if base_url: |
| base_url = effective_url |
| elif probe.get("models") is not None: |
| print( |
| f"Verified endpoint via {probe.get('probed_url')} " |
| f"({len(probe.get('models') or [])} model(s) visible)" |
| ) |
| else: |
| print( |
| f"Warning: could not verify this endpoint via {probe.get('probed_url')}. " |
| f"Hermes will still save it." |
| ) |
| if probe.get("suggested_base_url"): |
| suggested = probe["suggested_base_url"] |
| if suggested.endswith("/v1"): |
| print( |
| f" If this server expects /v1 in the path, try base URL: {suggested}" |
| ) |
| else: |
| print(f" If /v1 should not be in the base URL, try: {suggested}") |
|
|
| |
| model_name = "" |
| detected_models = probe.get("models") or [] |
| try: |
| if len(detected_models) == 1: |
| print(f" Detected model: {detected_models[0]}") |
| confirm = input(" Use this model? [Y/n]: ").strip().lower() |
| if confirm in ("", "y", "yes"): |
| model_name = detected_models[0] |
| else: |
| model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip() |
| elif len(detected_models) > 1: |
| print(" Available models:") |
| for i, m in enumerate(detected_models, 1): |
| print(f" {i}. {m}") |
| pick = input( |
| f" Select model [1-{len(detected_models)}] or type name: " |
| ).strip() |
| if pick.isdigit() and 1 <= int(pick) <= len(detected_models): |
| model_name = detected_models[int(pick) - 1] |
| elif pick: |
| model_name = pick |
| else: |
| model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip() |
|
|
| context_length_str = input( |
| "Context length in tokens [leave blank for auto-detect]: " |
| ).strip() |
|
|
| |
| default_name = _auto_provider_name(effective_url) |
| display_name = input(f"Display name [{default_name}]: ").strip() or default_name |
| except (KeyboardInterrupt, EOFError): |
| print("\nCancelled.") |
| return |
|
|
| context_length = None |
| if context_length_str: |
| try: |
| context_length = int( |
| context_length_str.replace(",", "") |
| .replace("k", "000") |
| .replace("K", "000") |
| ) |
| if context_length <= 0: |
| context_length = None |
| except ValueError: |
| print(f"Invalid context length: {context_length_str} β will auto-detect.") |
| context_length = None |
|
|
| if model_name: |
| _save_model_choice(model_name) |
|
|
| |
| cfg = load_config() |
| model = cfg.get("model") |
| if not isinstance(model, dict): |
| model = {"default": model} if model else {} |
| cfg["model"] = model |
| model["provider"] = "custom" |
| model["base_url"] = effective_url |
| if effective_key: |
| model["api_key"] = effective_key |
| model.pop("api_mode", None) |
| save_config(cfg) |
| deactivate_provider() |
|
|
| |
| |
| |
| |
| config["model"] = dict(model) |
|
|
| print(f"Default model set to: {model_name} (via {effective_url})") |
| else: |
| if base_url or api_key: |
| deactivate_provider() |
| |
| |
| _caller_model = config.get("model") |
| if not isinstance(_caller_model, dict): |
| _caller_model = {"default": _caller_model} if _caller_model else {} |
| _caller_model["provider"] = "custom" |
| _caller_model["base_url"] = effective_url |
| if effective_key: |
| _caller_model["api_key"] = effective_key |
| _caller_model.pop("api_mode", None) |
| config["model"] = _caller_model |
| print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.") |
|
|
| |
| _save_custom_provider( |
| effective_url, |
| effective_key, |
| model_name or "", |
| context_length=context_length, |
| name=display_name, |
| ) |
|
|
|
|
| def _auto_provider_name(base_url: str) -> str: |
| """Generate a display name from a custom endpoint URL. |
| |
| Returns a human-friendly label like "Local (localhost:11434)" or |
| "RunPod (xyz.runpod.io)". Used as the default when prompting the |
| user for a display name during custom endpoint setup. |
| """ |
| import re |
|
|
| clean = base_url.replace("https://", "").replace("http://", "").rstrip("/") |
| clean = re.sub(r"/v1/?$", "", clean) |
| name = clean.split("/")[0] |
| if "localhost" in name or "127.0.0.1" in name: |
| name = f"Local ({name})" |
| elif "runpod" in name.lower(): |
| name = f"RunPod ({name})" |
| else: |
| name = name.capitalize() |
| return name |
|
|
|
|
| def _save_custom_provider( |
| base_url, api_key="", model="", context_length=None, name=None |
| ): |
| """Save a custom endpoint to custom_providers in config.yaml. |
| |
| Deduplicates by base_url β if the URL already exists, updates the |
| model name and context_length but doesn't add a duplicate entry. |
| Uses *name* when provided, otherwise auto-generates from the URL. |
| """ |
| from hermes_cli.config import load_config, save_config |
|
|
| cfg = load_config() |
| providers = cfg.get("custom_providers") or [] |
| if not isinstance(providers, list): |
| providers = [] |
|
|
| |
| for entry in providers: |
| if isinstance(entry, dict) and entry.get("base_url", "").rstrip( |
| "/" |
| ) == base_url.rstrip("/"): |
| changed = False |
| if model and entry.get("model") != model: |
| entry["model"] = model |
| changed = True |
| if model and context_length: |
| models_cfg = entry.get("models", {}) |
| if not isinstance(models_cfg, dict): |
| models_cfg = {} |
| models_cfg[model] = {"context_length": context_length} |
| entry["models"] = models_cfg |
| changed = True |
| if changed: |
| cfg["custom_providers"] = providers |
| save_config(cfg) |
| return |
|
|
| |
| if not name: |
| name = _auto_provider_name(base_url) |
|
|
| entry = {"name": name, "base_url": base_url} |
| if api_key: |
| entry["api_key"] = api_key |
| if model: |
| entry["model"] = model |
| if model and context_length: |
| entry["models"] = {model: {"context_length": context_length}} |
|
|
| providers.append(entry) |
| cfg["custom_providers"] = providers |
| save_config(cfg) |
| print(f' πΎ Saved to custom providers as "{name}" (edit in config.yaml)') |
|
|
|
|
| def _remove_custom_provider(config): |
| """Let the user remove a saved custom provider from config.yaml.""" |
| from hermes_cli.config import load_config, save_config |
|
|
| cfg = load_config() |
| providers = cfg.get("custom_providers") or [] |
| if not isinstance(providers, list) or not providers: |
| print("No custom providers configured.") |
| return |
|
|
| print("Remove a custom provider:\n") |
|
|
| choices = [] |
| for entry in providers: |
| if isinstance(entry, dict): |
| name = entry.get("name", "unnamed") |
| url = entry.get("base_url", "") |
| short_url = url.replace("https://", "").replace("http://", "").rstrip("/") |
| choices.append(f"{name} ({short_url})") |
| else: |
| choices.append(str(entry)) |
| choices.append("Cancel") |
|
|
| try: |
| from simple_term_menu import TerminalMenu |
|
|
| menu = TerminalMenu( |
| [f" {c}" for c in choices], |
| cursor_index=0, |
| menu_cursor="-> ", |
| menu_cursor_style=("fg_red", "bold"), |
| menu_highlight_style=("fg_red",), |
| cycle_cursor=True, |
| clear_screen=False, |
| title="Select provider to remove:", |
| ) |
| idx = menu.show() |
| from hermes_cli.curses_ui import flush_stdin |
|
|
| flush_stdin() |
| print() |
| except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): |
| for i, c in enumerate(choices, 1): |
| print(f" {i}. {c}") |
| print() |
| try: |
| val = input(f"Choice [1-{len(choices)}]: ").strip() |
| idx = int(val) - 1 if val else None |
| except (ValueError, KeyboardInterrupt, EOFError): |
| idx = None |
|
|
| if idx is None or idx >= len(providers): |
| print("No change.") |
| return |
|
|
| removed = providers.pop(idx) |
| cfg["custom_providers"] = providers |
| save_config(cfg) |
| removed_name = ( |
| removed.get("name", "unnamed") if isinstance(removed, dict) else str(removed) |
| ) |
| print(f'β
Removed "{removed_name}" from custom providers.') |
|
|
|
|
| def _model_flow_named_custom(config, provider_info): |
| """Handle a named custom provider from config.yaml custom_providers list. |
| |
| Always probes the endpoint's /models API to let the user pick a model. |
| If a model was previously saved, it is pre-selected in the menu. |
| Falls back to the saved model if probing fails. |
| """ |
| from hermes_cli.auth import _save_model_choice, deactivate_provider |
| from hermes_cli.config import load_config, save_config |
| from hermes_cli.models import fetch_api_models |
|
|
| name = provider_info["name"] |
| base_url = provider_info["base_url"] |
| api_key = provider_info.get("api_key", "") |
| key_env = provider_info.get("key_env", "") |
| saved_model = provider_info.get("model", "") |
| provider_key = (provider_info.get("provider_key") or "").strip() |
|
|
| print(f" Provider: {name}") |
| print(f" URL: {base_url}") |
| if saved_model: |
| print(f" Current: {saved_model}") |
| print() |
|
|
| print("Fetching available models...") |
| models = fetch_api_models(api_key, base_url, timeout=8.0) |
|
|
| if models: |
| default_idx = 0 |
| if saved_model and saved_model in models: |
| default_idx = models.index(saved_model) |
|
|
| print(f"Found {len(models)} model(s):\n") |
| try: |
| from simple_term_menu import TerminalMenu |
|
|
| menu_items = [ |
| f" {m} (current)" if m == saved_model else f" {m}" for m in models |
| ] + [" Cancel"] |
| menu = TerminalMenu( |
| menu_items, |
| cursor_index=default_idx, |
| menu_cursor="-> ", |
| menu_cursor_style=("fg_green", "bold"), |
| menu_highlight_style=("fg_green",), |
| cycle_cursor=True, |
| clear_screen=False, |
| title=f"Select model from {name}:", |
| ) |
| idx = menu.show() |
| from hermes_cli.curses_ui import flush_stdin |
|
|
| flush_stdin() |
| print() |
| if idx is None or idx >= len(models): |
| print("Cancelled.") |
| return |
| model_name = models[idx] |
| except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): |
| for i, m in enumerate(models, 1): |
| suffix = " (current)" if m == saved_model else "" |
| print(f" {i}. {m}{suffix}") |
| print(f" {len(models) + 1}. Cancel") |
| print() |
| try: |
| val = input(f"Choice [1-{len(models) + 1}]: ").strip() |
| if not val: |
| print("Cancelled.") |
| return |
| idx = int(val) - 1 |
| if idx < 0 or idx >= len(models): |
| print("Cancelled.") |
| return |
| model_name = models[idx] |
| except (ValueError, KeyboardInterrupt, EOFError): |
| print("\nCancelled.") |
| return |
| elif saved_model: |
| print("Could not fetch models from endpoint.") |
| try: |
| model_name = input(f"Model name [{saved_model}]: ").strip() or saved_model |
| except (KeyboardInterrupt, EOFError): |
| print("\nCancelled.") |
| return |
| else: |
| print("Could not fetch models from endpoint. Enter model name manually.") |
| try: |
| model_name = input("Model name: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print("\nCancelled.") |
| return |
| if not model_name: |
| print("No model specified. Cancelled.") |
| return |
|
|
| |
| _save_model_choice(model_name) |
|
|
| cfg = load_config() |
| model = cfg.get("model") |
| if not isinstance(model, dict): |
| model = {"default": model} if model else {} |
| cfg["model"] = model |
| if provider_key: |
| model["provider"] = provider_key |
| model.pop("base_url", None) |
| model.pop("api_key", None) |
| else: |
| model["provider"] = "custom" |
| model["base_url"] = base_url |
| if api_key: |
| model["api_key"] = api_key |
| |
| custom_api_mode = provider_info.get("api_mode", "") |
| if custom_api_mode: |
| model["api_mode"] = custom_api_mode |
| else: |
| model.pop("api_mode", None) |
| save_config(cfg) |
| deactivate_provider() |
|
|
| |
| if provider_key: |
| cfg = load_config() |
| providers_cfg = cfg.get("providers") |
| if isinstance(providers_cfg, dict): |
| provider_entry = providers_cfg.get(provider_key) |
| if isinstance(provider_entry, dict): |
| provider_entry["default_model"] = model_name |
| if api_key and not str(provider_entry.get("api_key", "") or "").strip(): |
| provider_entry["api_key"] = api_key |
| if key_env and not str(provider_entry.get("key_env", "") or "").strip(): |
| provider_entry["key_env"] = key_env |
| cfg["providers"] = providers_cfg |
| save_config(cfg) |
| else: |
| |
| _save_custom_provider(base_url, api_key, model_name) |
|
|
| print(f"\nβ
Model set to: {model_name}") |
| print(f" Provider: {name} ({base_url})") |
|
|
|
|
| |
| from hermes_cli.models import _PROVIDER_MODELS |
|
|
|
|
| def _current_reasoning_effort(config) -> str: |
| agent_cfg = config.get("agent") |
| if isinstance(agent_cfg, dict): |
| return str(agent_cfg.get("reasoning_effort") or "").strip().lower() |
| return "" |
|
|
|
|
| def _set_reasoning_effort(config, effort: str) -> None: |
| agent_cfg = config.get("agent") |
| if not isinstance(agent_cfg, dict): |
| agent_cfg = {} |
| config["agent"] = agent_cfg |
| agent_cfg["reasoning_effort"] = effort |
|
|
|
|
| def _prompt_reasoning_effort_selection(efforts, current_effort=""): |
| """Prompt for a reasoning effort. Returns effort, 'none', or None to keep current.""" |
| deduped = list( |
| dict.fromkeys( |
| str(effort).strip().lower() for effort in efforts if str(effort).strip() |
| ) |
| ) |
| canonical_order = ("minimal", "low", "medium", "high", "xhigh") |
| ordered = [effort for effort in canonical_order if effort in deduped] |
| ordered.extend(effort for effort in deduped if effort not in canonical_order) |
| if not ordered: |
| return None |
|
|
| def _label(effort): |
| if effort == current_effort: |
| return f"{effort} β currently in use" |
| return effort |
|
|
| disable_label = "Disable reasoning" |
| skip_label = "Skip (keep current)" |
|
|
| if current_effort == "none": |
| default_idx = len(ordered) |
| elif current_effort in ordered: |
| default_idx = ordered.index(current_effort) |
| elif "medium" in ordered: |
| default_idx = ordered.index("medium") |
| else: |
| default_idx = 0 |
|
|
| try: |
| from simple_term_menu import TerminalMenu |
|
|
| choices = [f" {_label(effort)}" for effort in ordered] |
| choices.append(f" {disable_label}") |
| choices.append(f" {skip_label}") |
| menu = TerminalMenu( |
| choices, |
| cursor_index=default_idx, |
| menu_cursor="-> ", |
| menu_cursor_style=("fg_green", "bold"), |
| menu_highlight_style=("fg_green",), |
| cycle_cursor=True, |
| clear_screen=False, |
| title="Select reasoning effort:", |
| ) |
| idx = menu.show() |
| from hermes_cli.curses_ui import flush_stdin |
|
|
| flush_stdin() |
| if idx is None: |
| return None |
| print() |
| if idx < len(ordered): |
| return ordered[idx] |
| if idx == len(ordered): |
| return "none" |
| return None |
| except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): |
| pass |
|
|
| print("Select reasoning effort:") |
| for i, effort in enumerate(ordered, 1): |
| print(f" {i}. {_label(effort)}") |
| n = len(ordered) |
| print(f" {n + 1}. {disable_label}") |
| print(f" {n + 2}. {skip_label}") |
| print() |
|
|
| while True: |
| try: |
| choice = input(f"Choice [1-{n + 2}] (default: keep current): ").strip() |
| if not choice: |
| return None |
| idx = int(choice) |
| if 1 <= idx <= n: |
| return ordered[idx - 1] |
| if idx == n + 1: |
| return "none" |
| if idx == n + 2: |
| return None |
| print(f"Please enter 1-{n + 2}") |
| except ValueError: |
| print("Please enter a number") |
| except (KeyboardInterrupt, EOFError): |
| return None |
|
|
|
|
| def _model_flow_copilot(config, current_model=""): |
| """GitHub Copilot flow using env vars, gh CLI, or OAuth device code.""" |
| from hermes_cli.auth import ( |
| PROVIDER_REGISTRY, |
| _prompt_model_selection, |
| _save_model_choice, |
| deactivate_provider, |
| resolve_api_key_provider_credentials, |
| ) |
| from hermes_cli.config import save_env_value, load_config, save_config |
| from hermes_cli.models import ( |
| fetch_api_models, |
| fetch_github_model_catalog, |
| github_model_reasoning_efforts, |
| copilot_model_api_mode, |
| normalize_copilot_model_id, |
| ) |
|
|
| provider_id = "copilot" |
| pconfig = PROVIDER_REGISTRY[provider_id] |
|
|
| creds = resolve_api_key_provider_credentials(provider_id) |
| api_key = creds.get("api_key", "") |
| source = creds.get("source", "") |
|
|
| if not api_key: |
| print("No GitHub token configured for GitHub Copilot.") |
| print() |
| print(" Supported token types:") |
| print( |
| " β OAuth token (gho_*) via `copilot login` or device code flow" |
| ) |
| print(" β Fine-grained PAT (github_pat_*) with Copilot Requests permission") |
| print(" β GitHub App token (ghu_*) via environment variable") |
| print(" β Classic PAT (ghp_*) NOT supported by Copilot API") |
| print() |
| print(" Options:") |
| print(" 1. Login with GitHub (OAuth device code flow)") |
| print(" 2. Enter a token manually") |
| print(" 3. Cancel") |
| print() |
| try: |
| choice = input(" Choice [1-3]: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
|
|
| if choice == "1": |
| try: |
| from hermes_cli.copilot_auth import copilot_device_code_login |
|
|
| token = copilot_device_code_login() |
| if token: |
| save_env_value("COPILOT_GITHUB_TOKEN", token) |
| print(" Copilot token saved.") |
| print() |
| else: |
| print(" Login cancelled or failed.") |
| return |
| except Exception as exc: |
| print(f" Login failed: {exc}") |
| return |
| elif choice == "2": |
| try: |
| import getpass |
|
|
| new_key = getpass.getpass(" Token (COPILOT_GITHUB_TOKEN): ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
| if not new_key: |
| print(" Cancelled.") |
| return |
| |
| try: |
| from hermes_cli.copilot_auth import validate_copilot_token |
|
|
| valid, msg = validate_copilot_token(new_key) |
| if not valid: |
| print(f" β {msg}") |
| return |
| except ImportError: |
| pass |
| save_env_value("COPILOT_GITHUB_TOKEN", new_key) |
| print(" Token saved.") |
| print() |
| else: |
| print(" Cancelled.") |
| return |
|
|
| creds = resolve_api_key_provider_credentials(provider_id) |
| api_key = creds.get("api_key", "") |
| source = creds.get("source", "") |
| else: |
| if source in ("GITHUB_TOKEN", "GH_TOKEN"): |
| print(f" GitHub token: {api_key[:8]}... β ({source})") |
| elif source == "gh auth token": |
| print(" GitHub token: β (from `gh auth token`)") |
| else: |
| print(" GitHub token: β") |
| print() |
|
|
| effective_base = pconfig.inference_base_url |
|
|
| catalog = fetch_github_model_catalog(api_key) |
| live_models = ( |
| [item.get("id", "") for item in catalog if item.get("id")] |
| if catalog |
| else fetch_api_models(api_key, effective_base) |
| ) |
| normalized_current_model = ( |
| normalize_copilot_model_id( |
| current_model, |
| catalog=catalog, |
| api_key=api_key, |
| ) |
| or current_model |
| ) |
| if live_models: |
| model_list = [model_id for model_id in live_models if model_id] |
| print(f" Found {len(model_list)} model(s) from GitHub Copilot") |
| else: |
| model_list = _PROVIDER_MODELS.get(provider_id, []) |
| if model_list: |
| print( |
| " β Could not auto-detect models from GitHub Copilot β showing defaults." |
| ) |
| print(' Use "Enter custom model name" if you do not see your model.') |
|
|
| if model_list: |
| selected = _prompt_model_selection( |
| model_list, current_model=normalized_current_model |
| ) |
| else: |
| try: |
| selected = input("Model name: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| selected = None |
|
|
| if selected: |
| selected = ( |
| normalize_copilot_model_id( |
| selected, |
| catalog=catalog, |
| api_key=api_key, |
| ) |
| or selected |
| ) |
| initial_cfg = load_config() |
| current_effort = _current_reasoning_effort(initial_cfg) |
| reasoning_efforts = github_model_reasoning_efforts( |
| selected, |
| catalog=catalog, |
| api_key=api_key, |
| ) |
| selected_effort = None |
| if reasoning_efforts: |
| print(f" {selected} supports reasoning controls.") |
| selected_effort = _prompt_reasoning_effort_selection( |
| reasoning_efforts, current_effort=current_effort |
| ) |
|
|
| _save_model_choice(selected) |
|
|
| cfg = load_config() |
| model = cfg.get("model") |
| if not isinstance(model, dict): |
| model = {"default": model} if model else {} |
| cfg["model"] = model |
| model["provider"] = provider_id |
| model["base_url"] = effective_base |
| model["api_mode"] = copilot_model_api_mode( |
| selected, |
| catalog=catalog, |
| api_key=api_key, |
| ) |
| if selected_effort is not None: |
| _set_reasoning_effort(cfg, selected_effort) |
| save_config(cfg) |
| deactivate_provider() |
|
|
| print(f"Default model set to: {selected} (via {pconfig.name})") |
| if reasoning_efforts: |
| if selected_effort == "none": |
| print("Reasoning disabled for this model.") |
| elif selected_effort: |
| print(f"Reasoning effort set to: {selected_effort}") |
| else: |
| print("No change.") |
|
|
|
|
| def _model_flow_copilot_acp(config, current_model=""): |
| """GitHub Copilot ACP flow using the local Copilot CLI.""" |
| from hermes_cli.auth import ( |
| PROVIDER_REGISTRY, |
| _prompt_model_selection, |
| _save_model_choice, |
| deactivate_provider, |
| get_external_process_provider_status, |
| resolve_api_key_provider_credentials, |
| resolve_external_process_provider_credentials, |
| ) |
| from hermes_cli.models import ( |
| fetch_github_model_catalog, |
| normalize_copilot_model_id, |
| ) |
| from hermes_cli.config import load_config, save_config |
|
|
| del config |
|
|
| provider_id = "copilot-acp" |
| pconfig = PROVIDER_REGISTRY[provider_id] |
|
|
| status = get_external_process_provider_status(provider_id) |
| resolved_command = ( |
| status.get("resolved_command") or status.get("command") or "copilot" |
| ) |
| effective_base = status.get("base_url") or pconfig.inference_base_url |
|
|
| print(" GitHub Copilot ACP delegates Hermes turns to `copilot --acp`.") |
| print(" Hermes currently starts its own ACP subprocess for each request.") |
| print(" Hermes uses your selected model as a hint for the Copilot ACP session.") |
| print(f" Command: {resolved_command}") |
| print(f" Backend marker: {effective_base}") |
| print() |
|
|
| try: |
| creds = resolve_external_process_provider_credentials(provider_id) |
| except Exception as exc: |
| print(f" β {exc}") |
| print( |
| " Set HERMES_COPILOT_ACP_COMMAND or COPILOT_CLI_PATH if Copilot CLI is installed elsewhere." |
| ) |
| return |
|
|
| effective_base = creds.get("base_url") or effective_base |
|
|
| catalog_api_key = "" |
| try: |
| catalog_creds = resolve_api_key_provider_credentials("copilot") |
| catalog_api_key = catalog_creds.get("api_key", "") |
| except Exception: |
| pass |
|
|
| catalog = fetch_github_model_catalog(catalog_api_key) |
| normalized_current_model = ( |
| normalize_copilot_model_id( |
| current_model, |
| catalog=catalog, |
| api_key=catalog_api_key, |
| ) |
| or current_model |
| ) |
|
|
| if catalog: |
| model_list = [item.get("id", "") for item in catalog if item.get("id")] |
| print(f" Found {len(model_list)} model(s) from GitHub Copilot") |
| else: |
| model_list = _PROVIDER_MODELS.get("copilot", []) |
| if model_list: |
| print( |
| " β Could not auto-detect models from GitHub Copilot β showing defaults." |
| ) |
| print(' Use "Enter custom model name" if you do not see your model.') |
|
|
| if model_list: |
| selected = _prompt_model_selection( |
| model_list, |
| current_model=normalized_current_model, |
| ) |
| else: |
| try: |
| selected = input("Model name: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| selected = None |
|
|
| if not selected: |
| print("No change.") |
| return |
|
|
| selected = ( |
| normalize_copilot_model_id( |
| selected, |
| catalog=catalog, |
| api_key=catalog_api_key, |
| ) |
| or selected |
| ) |
| _save_model_choice(selected) |
|
|
| cfg = load_config() |
| model = cfg.get("model") |
| if not isinstance(model, dict): |
| model = {"default": model} if model else {} |
| cfg["model"] = model |
| model["provider"] = provider_id |
| model["base_url"] = effective_base |
| model["api_mode"] = "chat_completions" |
| save_config(cfg) |
| deactivate_provider() |
|
|
| print(f"Default model set to: {selected} (via {pconfig.name})") |
|
|
|
|
| def _model_flow_kimi(config, current_model=""): |
| """Kimi / Moonshot model selection with automatic endpoint routing. |
| |
| - sk-kimi-* keys β api.kimi.com/coding/v1 (Kimi Coding Plan) |
| - Other keys β api.moonshot.ai/v1 (legacy Moonshot) |
| |
| No manual base URL prompt β endpoint is determined by key prefix. |
| """ |
| from hermes_cli.auth import ( |
| PROVIDER_REGISTRY, |
| KIMI_CODE_BASE_URL, |
| _prompt_model_selection, |
| _save_model_choice, |
| deactivate_provider, |
| ) |
| from hermes_cli.config import ( |
| get_env_value, |
| save_env_value, |
| load_config, |
| save_config, |
| ) |
|
|
| provider_id = "kimi-coding" |
| pconfig = PROVIDER_REGISTRY[provider_id] |
| key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" |
| base_url_env = pconfig.base_url_env_var or "" |
|
|
| |
| existing_key = "" |
| for ev in pconfig.api_key_env_vars: |
| existing_key = get_env_value(ev) or os.getenv(ev, "") |
| if existing_key: |
| break |
|
|
| if not existing_key: |
| print(f"No {pconfig.name} API key configured.") |
| if key_env: |
| try: |
| import getpass |
|
|
| new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
| if not new_key: |
| print("Cancelled.") |
| return |
| save_env_value(key_env, new_key) |
| existing_key = new_key |
| print("API key saved.") |
| print() |
| else: |
| print(f" {pconfig.name} API key: {existing_key[:8]}... β") |
| print() |
|
|
| |
| is_coding_plan = existing_key.startswith("sk-kimi-") |
| if is_coding_plan: |
| effective_base = KIMI_CODE_BASE_URL |
| print(f" Detected Kimi Coding Plan key β {effective_base}") |
| else: |
| effective_base = pconfig.inference_base_url |
| print(f" Using Moonshot endpoint β {effective_base}") |
| |
| if base_url_env and get_env_value(base_url_env): |
| save_env_value(base_url_env, "") |
| print() |
|
|
| |
| if is_coding_plan: |
| |
| model_list = [ |
| "kimi-k2.6", |
| "kimi-k2.5", |
| "kimi-for-coding", |
| "kimi-k2-thinking", |
| "kimi-k2-thinking-turbo", |
| ] |
| else: |
| |
| model_list = _PROVIDER_MODELS.get("moonshot", []) |
|
|
| if model_list: |
| selected = _prompt_model_selection(model_list, current_model=current_model) |
| else: |
| try: |
| selected = input("Enter model name: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| selected = None |
|
|
| if selected: |
| _save_model_choice(selected) |
|
|
| |
| cfg = load_config() |
| model = cfg.get("model") |
| if not isinstance(model, dict): |
| model = {"default": model} if model else {} |
| cfg["model"] = model |
| model["provider"] = provider_id |
| model["base_url"] = effective_base |
| model.pop("api_mode", None) |
| save_config(cfg) |
| deactivate_provider() |
|
|
| endpoint_label = "Kimi Coding" if is_coding_plan else "Moonshot" |
| print(f"Default model set to: {selected} (via {endpoint_label})") |
| else: |
| print("No change.") |
|
|
|
|
| def _infer_stepfun_region(base_url: str) -> str: |
| """Infer the current StepFun region from the configured endpoint.""" |
| normalized = (base_url or "").strip().lower() |
| if "api.stepfun.com" in normalized: |
| return "china" |
| return "international" |
|
|
|
|
| def _stepfun_base_url_for_region(region: str) -> str: |
| from hermes_cli.auth import ( |
| STEPFUN_STEP_PLAN_CN_BASE_URL, |
| STEPFUN_STEP_PLAN_INTL_BASE_URL, |
| ) |
|
|
| return ( |
| STEPFUN_STEP_PLAN_CN_BASE_URL |
| if region == "china" |
| else STEPFUN_STEP_PLAN_INTL_BASE_URL |
| ) |
|
|
|
|
| def _model_flow_stepfun(config, current_model=""): |
| """StepFun Step Plan flow with region-specific endpoints.""" |
| from hermes_cli.auth import ( |
| PROVIDER_REGISTRY, |
| _prompt_model_selection, |
| _save_model_choice, |
| deactivate_provider, |
| ) |
| from hermes_cli.config import get_env_value, save_env_value, load_config, save_config |
| from hermes_cli.models import fetch_api_models |
|
|
| provider_id = "stepfun" |
| pconfig = PROVIDER_REGISTRY[provider_id] |
| key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" |
| base_url_env = pconfig.base_url_env_var or "" |
|
|
| existing_key = "" |
| for ev in pconfig.api_key_env_vars: |
| existing_key = get_env_value(ev) or os.getenv(ev, "") |
| if existing_key: |
| break |
|
|
| if not existing_key: |
| print(f"No {pconfig.name} API key configured.") |
| if key_env: |
| try: |
| import getpass |
| new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
| if not new_key: |
| print("Cancelled.") |
| return |
| save_env_value(key_env, new_key) |
| existing_key = new_key |
| print("API key saved.") |
| print() |
| else: |
| print(f" {pconfig.name} API key: {existing_key[:8]}... β") |
| print() |
|
|
| current_base = "" |
| if base_url_env: |
| current_base = get_env_value(base_url_env) or os.getenv(base_url_env, "") |
| if not current_base: |
| model_cfg = config.get("model") |
| if isinstance(model_cfg, dict): |
| current_base = str(model_cfg.get("base_url") or "").strip() |
| current_region = _infer_stepfun_region(current_base or pconfig.inference_base_url) |
|
|
| region_choices = [ |
| ("international", f"International ({_stepfun_base_url_for_region('international')})"), |
| ("china", f"China ({_stepfun_base_url_for_region('china')})"), |
| ] |
| ordered_regions = [] |
| for region_key, label in region_choices: |
| if region_key == current_region: |
| ordered_regions.insert(0, (region_key, f"{label} β currently active")) |
| else: |
| ordered_regions.append((region_key, label)) |
| ordered_regions.append(("cancel", "Cancel")) |
|
|
| region_idx = _prompt_provider_choice([label for _, label in ordered_regions]) |
| if region_idx is None or ordered_regions[region_idx][0] == "cancel": |
| print("No change.") |
| return |
|
|
| selected_region = ordered_regions[region_idx][0] |
| effective_base = _stepfun_base_url_for_region(selected_region) |
| if base_url_env: |
| save_env_value(base_url_env, effective_base) |
|
|
| live_models = fetch_api_models(existing_key, effective_base) |
| if live_models: |
| model_list = live_models |
| print(f" Found {len(model_list)} model(s) from {pconfig.name} API") |
| else: |
| model_list = _PROVIDER_MODELS.get(provider_id, []) |
| if model_list: |
| print( |
| f" Could not auto-detect models from {pconfig.name} API β " |
| "showing Step Plan fallback catalog." |
| ) |
|
|
| if model_list: |
| selected = _prompt_model_selection(model_list, current_model=current_model) |
| else: |
| try: |
| selected = input("Model name: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| selected = None |
|
|
| if selected: |
| _save_model_choice(selected) |
|
|
| cfg = load_config() |
| model = cfg.get("model") |
| if not isinstance(model, dict): |
| model = {"default": model} if model else {} |
| cfg["model"] = model |
| model["provider"] = provider_id |
| model["base_url"] = effective_base |
| model.pop("api_mode", None) |
| save_config(cfg) |
| deactivate_provider() |
|
|
| config["model"] = dict(model) |
| print(f"Default model set to: {selected} (via {pconfig.name})") |
| else: |
| print("No change.") |
|
|
|
|
| def _model_flow_bedrock_api_key(config, region, current_model=""): |
| """Bedrock API Key mode β uses the OpenAI-compatible bedrock-mantle endpoint. |
| |
| For developers who don't have an AWS account but received a Bedrock API Key |
| from their AWS admin. Works like any OpenAI-compatible endpoint. |
| """ |
| from hermes_cli.auth import ( |
| _prompt_model_selection, |
| _save_model_choice, |
| deactivate_provider, |
| ) |
| from hermes_cli.config import ( |
| load_config, |
| save_config, |
| get_env_value, |
| save_env_value, |
| ) |
| from hermes_cli.models import _PROVIDER_MODELS |
|
|
| mantle_base_url = f"https://bedrock-mantle.{region}.api.aws/v1" |
|
|
| |
| existing_key = get_env_value("AWS_BEARER_TOKEN_BEDROCK") or "" |
| if existing_key: |
| print(f" Bedrock API Key: {existing_key[:12]}... β") |
| else: |
| print(f" Endpoint: {mantle_base_url}") |
| print() |
| try: |
| import getpass |
|
|
| api_key = getpass.getpass(" Bedrock API Key: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
| if not api_key: |
| print(" Cancelled.") |
| return |
| save_env_value("AWS_BEARER_TOKEN_BEDROCK", api_key) |
| existing_key = api_key |
| print(" β API key saved.") |
| print() |
|
|
| |
| model_list = _PROVIDER_MODELS.get("bedrock", []) |
| print(f" Showing {len(model_list)} curated models") |
|
|
| if model_list: |
| selected = _prompt_model_selection(model_list, current_model=current_model) |
| else: |
| try: |
| selected = input(" Model ID: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| selected = None |
|
|
| if selected: |
| _save_model_choice(selected) |
|
|
| |
| cfg = load_config() |
| model = cfg.get("model") |
| if not isinstance(model, dict): |
| model = {"default": model} if model else {} |
| cfg["model"] = model |
| model["provider"] = "custom" |
| model["base_url"] = mantle_base_url |
| model.pop("api_mode", None) |
|
|
| |
| bedrock_cfg = cfg.get("bedrock", {}) |
| if not isinstance(bedrock_cfg, dict): |
| bedrock_cfg = {} |
| bedrock_cfg["region"] = region |
| cfg["bedrock"] = bedrock_cfg |
|
|
| |
| save_env_value("OPENAI_API_KEY", existing_key) |
| save_env_value("OPENAI_BASE_URL", mantle_base_url) |
|
|
| save_config(cfg) |
| deactivate_provider() |
|
|
| print(f" Default model set to: {selected} (via Bedrock API Key, {region})") |
| print(f" Endpoint: {mantle_base_url}") |
| else: |
| print(" No change.") |
|
|
|
|
| def _model_flow_bedrock(config, current_model=""): |
| """AWS Bedrock provider: verify credentials, pick region, discover models. |
| |
| Uses the native Converse API via boto3 β not the OpenAI-compatible endpoint. |
| Auth is handled by the AWS SDK default credential chain (env vars, profile, |
| instance role), so no API key prompt is needed. |
| """ |
| from hermes_cli.auth import ( |
| _prompt_model_selection, |
| _save_model_choice, |
| deactivate_provider, |
| ) |
| from hermes_cli.config import load_config, save_config |
| from hermes_cli.models import _PROVIDER_MODELS |
|
|
| |
| try: |
| from agent.bedrock_adapter import ( |
| has_aws_credentials, |
| resolve_aws_auth_env_var, |
| resolve_bedrock_region, |
| discover_bedrock_models, |
| ) |
| except ImportError: |
| print(" β boto3 is not installed. Install it with:") |
| print(" pip install boto3") |
| print() |
| return |
|
|
| if not has_aws_credentials(): |
| print(" β No AWS credentials detected via environment variables.") |
| print(" Bedrock will use boto3's default credential chain (IMDS, SSO, etc.)") |
| print() |
|
|
| auth_var = resolve_aws_auth_env_var() |
| if auth_var: |
| print(f" AWS credentials: {auth_var} β") |
| else: |
| print(" AWS credentials: boto3 default chain (instance role / SSO)") |
| print() |
|
|
| |
| current_region = resolve_bedrock_region() |
| try: |
| region_input = input(f" AWS Region [{current_region}]: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
| region = region_input or current_region |
|
|
| |
| print(" Choose authentication method:") |
| print() |
| print(" 1. IAM credential chain (recommended)") |
| print(" Works with EC2 instance roles, SSO, env vars, aws configure") |
| print(" 2. Bedrock API Key") |
| print(" Enter your Bedrock API Key directly β also supports") |
| print(" team scenarios where an admin distributes keys") |
| print() |
| try: |
| auth_choice = input(" Choice [1]: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
|
|
| if auth_choice == "2": |
| _model_flow_bedrock_api_key(config, region, current_model) |
| return |
|
|
| |
| print(f" Discovering models in {region}...") |
| live_models = discover_bedrock_models(region) |
|
|
| if live_models: |
| _EXCLUDE_PREFIXES = ( |
| "stability.", |
| "cohere.embed", |
| "twelvelabs.", |
| "us.stability.", |
| "us.cohere.embed", |
| "us.twelvelabs.", |
| "global.cohere.embed", |
| "global.twelvelabs.", |
| ) |
| _EXCLUDE_SUBSTRINGS = ("safeguard", "voxtral", "palmyra-vision") |
| filtered = [] |
| for m in live_models: |
| mid = m["id"] |
| if any(mid.startswith(p) for p in _EXCLUDE_PREFIXES): |
| continue |
| if any(s in mid.lower() for s in _EXCLUDE_SUBSTRINGS): |
| continue |
| filtered.append(m) |
|
|
| |
| |
| profile_base_ids = set() |
| for m in filtered: |
| mid = m["id"] |
| if mid.startswith(("us.", "global.")): |
| base = mid.split(".", 1)[1] if "." in mid[3:] else mid |
| profile_base_ids.add(base) |
|
|
| deduped = [] |
| for m in filtered: |
| mid = m["id"] |
| if not mid.startswith(("us.", "global.")) and mid in profile_base_ids: |
| continue |
| deduped.append(m) |
|
|
| _RECOMMENDED = [ |
| "us.anthropic.claude-sonnet-4-6", |
| "us.anthropic.claude-opus-4-6", |
| "us.anthropic.claude-haiku-4-5", |
| "us.amazon.nova-pro", |
| "us.amazon.nova-lite", |
| "us.amazon.nova-micro", |
| "deepseek.v3", |
| "us.meta.llama4-maverick", |
| "us.meta.llama4-scout", |
| ] |
|
|
| def _sort_key(m): |
| mid = m["id"] |
| for i, rec in enumerate(_RECOMMENDED): |
| if mid.startswith(rec): |
| return (0, i, mid) |
| if mid.startswith("global."): |
| return (1, 0, mid) |
| return (2, 0, mid) |
|
|
| deduped.sort(key=_sort_key) |
| model_list = [m["id"] for m in deduped] |
| print( |
| f" Found {len(model_list)} text model(s) (filtered from {len(live_models)} total)" |
| ) |
| else: |
| model_list = _PROVIDER_MODELS.get("bedrock", []) |
| if model_list: |
| print( |
| f" Using {len(model_list)} curated models (live discovery unavailable)" |
| ) |
| else: |
| print( |
| " No models found. Check IAM permissions for bedrock:ListFoundationModels." |
| ) |
| return |
|
|
| |
| if model_list: |
| selected = _prompt_model_selection(model_list, current_model=current_model) |
| else: |
| try: |
| selected = input(" Model ID: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| selected = None |
|
|
| if selected: |
| _save_model_choice(selected) |
|
|
| cfg = load_config() |
| model = cfg.get("model") |
| if not isinstance(model, dict): |
| model = {"default": model} if model else {} |
| cfg["model"] = model |
| model["provider"] = "bedrock" |
| model["base_url"] = f"https://bedrock-runtime.{region}.amazonaws.com" |
| model.pop("api_mode", None) |
|
|
| bedrock_cfg = cfg.get("bedrock", {}) |
| if not isinstance(bedrock_cfg, dict): |
| bedrock_cfg = {} |
| bedrock_cfg["region"] = region |
| cfg["bedrock"] = bedrock_cfg |
|
|
| save_config(cfg) |
| deactivate_provider() |
|
|
| print(f" Default model set to: {selected} (via AWS Bedrock, {region})") |
| else: |
| print(" No change.") |
|
|
|
|
| def _model_flow_api_key_provider(config, provider_id, current_model=""): |
| """Generic flow for API-key providers (z.ai, MiniMax, OpenCode, etc.).""" |
| from hermes_cli.auth import ( |
| PROVIDER_REGISTRY, |
| _prompt_model_selection, |
| _save_model_choice, |
| deactivate_provider, |
| ) |
| from hermes_cli.config import ( |
| get_env_value, |
| save_env_value, |
| load_config, |
| save_config, |
| ) |
| from hermes_cli.models import ( |
| fetch_api_models, |
| opencode_model_api_mode, |
| normalize_opencode_model_id, |
| ) |
|
|
| pconfig = PROVIDER_REGISTRY[provider_id] |
| key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" |
| base_url_env = pconfig.base_url_env_var or "" |
|
|
| |
| existing_key = "" |
| for ev in pconfig.api_key_env_vars: |
| existing_key = get_env_value(ev) or os.getenv(ev, "") |
| if existing_key: |
| break |
|
|
| if not existing_key: |
| print(f"No {pconfig.name} API key configured.") |
| if key_env: |
| try: |
| import getpass |
|
|
| new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
| if not new_key: |
| print("Cancelled.") |
| return |
| save_env_value(key_env, new_key) |
| print("API key saved.") |
| print() |
| else: |
| print(f" {pconfig.name} API key: {existing_key[:8]}... β") |
| print() |
|
|
| |
| current_base = "" |
| if base_url_env: |
| current_base = get_env_value(base_url_env) or os.getenv(base_url_env, "") |
| effective_base = current_base or pconfig.inference_base_url |
|
|
| try: |
| override = input(f"Base URL [{effective_base}]: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| override = "" |
| if override and base_url_env: |
| if not override.startswith(("http://", "https://")): |
| print( |
| " Invalid URL β must start with http:// or https://. Keeping current value." |
| ) |
| else: |
| save_env_value(base_url_env, override) |
| effective_base = override |
|
|
| |
| |
| |
| |
| |
| |
| if provider_id == "ollama-cloud": |
| from hermes_cli.models import fetch_ollama_cloud_models |
|
|
| api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "") |
| model_list = fetch_ollama_cloud_models( |
| api_key=api_key_for_probe, base_url=effective_base |
| ) |
| if model_list: |
| print(f" Found {len(model_list)} model(s) from Ollama Cloud") |
| else: |
| curated = _PROVIDER_MODELS.get(provider_id, []) |
|
|
| |
| mdev_models: list = [] |
| try: |
| from agent.models_dev import list_agentic_models |
|
|
| mdev_models = list_agentic_models(provider_id) |
| except Exception: |
| pass |
|
|
| if mdev_models: |
| |
| |
| if curated: |
| seen = {m.lower() for m in mdev_models} |
| merged = list(mdev_models) |
| for m in curated: |
| if m.lower() not in seen: |
| merged.append(m) |
| seen.add(m.lower()) |
| model_list = merged |
| else: |
| model_list = mdev_models |
| print(f" Found {len(model_list)} model(s) from models.dev registry") |
| elif curated and len(curated) >= 8: |
| |
| model_list = curated |
| print( |
| f' Showing {len(model_list)} curated models β use "Enter custom model name" for others.' |
| ) |
| else: |
| api_key_for_probe = existing_key or ( |
| get_env_value(key_env) if key_env else "" |
| ) |
| live_models = fetch_api_models(api_key_for_probe, effective_base) |
| if live_models and len(live_models) >= len(curated): |
| model_list = live_models |
| print(f" Found {len(model_list)} model(s) from {pconfig.name} API") |
| else: |
| model_list = curated |
| if model_list: |
| print( |
| f' Showing {len(model_list)} curated models β use "Enter custom model name" for others.' |
| ) |
| |
|
|
| if provider_id in {"opencode-zen", "opencode-go"}: |
| model_list = [ |
| normalize_opencode_model_id(provider_id, mid) for mid in model_list |
| ] |
| current_model = normalize_opencode_model_id(provider_id, current_model) |
| model_list = list(dict.fromkeys(mid for mid in model_list if mid)) |
|
|
| if model_list: |
| selected = _prompt_model_selection(model_list, current_model=current_model) |
| else: |
| try: |
| selected = input("Model name: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| selected = None |
|
|
| if selected: |
| if provider_id in {"opencode-zen", "opencode-go"}: |
| selected = normalize_opencode_model_id(provider_id, selected) |
|
|
| _save_model_choice(selected) |
|
|
| |
| cfg = load_config() |
| model = cfg.get("model") |
| if not isinstance(model, dict): |
| model = {"default": model} if model else {} |
| cfg["model"] = model |
| model["provider"] = provider_id |
| model["base_url"] = effective_base |
| if provider_id in {"opencode-zen", "opencode-go"}: |
| model["api_mode"] = opencode_model_api_mode(provider_id, selected) |
| else: |
| model.pop("api_mode", None) |
| save_config(cfg) |
| deactivate_provider() |
|
|
| print(f"Default model set to: {selected} (via {pconfig.name})") |
| else: |
| print("No change.") |
|
|
|
|
| def _run_anthropic_oauth_flow(save_env_value): |
| """Run the Claude OAuth setup-token flow. Returns True if credentials were saved.""" |
| from agent.anthropic_adapter import ( |
| run_oauth_setup_token, |
| read_claude_code_credentials, |
| is_claude_code_token_valid, |
| ) |
| from hermes_cli.config import ( |
| save_anthropic_oauth_token, |
| use_anthropic_claude_code_credentials, |
| ) |
|
|
| def _activate_claude_code_credentials_if_available() -> bool: |
| try: |
| creds = read_claude_code_credentials() |
| except Exception: |
| creds = None |
| if creds and ( |
| is_claude_code_token_valid(creds) or bool(creds.get("refreshToken")) |
| ): |
| use_anthropic_claude_code_credentials(save_fn=save_env_value) |
| print(" β Claude Code credentials linked.") |
| from hermes_constants import display_hermes_home as _dhh_fn |
|
|
| print( |
| f" Hermes will use Claude's credential store directly instead of copying a setup-token into {_dhh_fn()}/.env." |
| ) |
| return True |
| return False |
|
|
| try: |
| print() |
| print(" Running 'claude setup-token' β follow the prompts below.") |
| print(" A browser window will open for you to authorize access.") |
| print() |
| token = run_oauth_setup_token() |
| if token: |
| if _activate_claude_code_credentials_if_available(): |
| return True |
| save_anthropic_oauth_token(token, save_fn=save_env_value) |
| print(" β OAuth credentials saved.") |
| return True |
|
|
| |
| print() |
| print(" If the setup-token was displayed above, paste it here:") |
| print() |
| try: |
| import getpass |
|
|
| manual_token = getpass.getpass( |
| " Paste setup-token (or Enter to cancel): " |
| ).strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return False |
| if manual_token: |
| save_anthropic_oauth_token(manual_token, save_fn=save_env_value) |
| print(" β Setup-token saved.") |
| return True |
|
|
| print(" β Could not detect saved credentials.") |
| return False |
|
|
| except FileNotFoundError: |
| |
| print() |
| print(" The 'claude' CLI is required for OAuth login.") |
| print() |
| print(" To install and authenticate:") |
| print() |
| print(" 1. Install Claude Code: npm install -g @anthropic-ai/claude-code") |
| print(" 2. Run: claude setup-token") |
| print(" 3. Follow the browser prompts to authorize") |
| print(" 4. Re-run: hermes model") |
| print() |
| print(" Or paste an existing setup-token now (sk-ant-oat-...):") |
| print() |
| try: |
| import getpass |
|
|
| token = getpass.getpass(" Setup-token (or Enter to cancel): ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return False |
| if token: |
| save_anthropic_oauth_token(token, save_fn=save_env_value) |
| print(" β Setup-token saved.") |
| return True |
| print(" Cancelled β install Claude Code and try again.") |
| return False |
|
|
|
|
| def _model_flow_anthropic(config, current_model=""): |
| """Flow for Anthropic provider β OAuth subscription, API key, or Claude Code creds.""" |
| from hermes_cli.auth import ( |
| _prompt_model_selection, |
| _save_model_choice, |
| deactivate_provider, |
| ) |
| from hermes_cli.config import ( |
| save_env_value, |
| load_config, |
| save_config, |
| save_anthropic_api_key, |
| ) |
| from hermes_cli.models import _PROVIDER_MODELS |
|
|
| |
| from hermes_cli.auth import get_anthropic_key |
|
|
| existing_key = get_anthropic_key() |
| cc_available = False |
| try: |
| from agent.anthropic_adapter import ( |
| read_claude_code_credentials, |
| is_claude_code_token_valid, |
| ) |
|
|
| cc_creds = read_claude_code_credentials() |
| if cc_creds and is_claude_code_token_valid(cc_creds): |
| cc_available = True |
| except Exception: |
| pass |
|
|
| has_creds = bool(existing_key) or cc_available |
| needs_auth = not has_creds |
|
|
| if has_creds: |
| |
| if existing_key: |
| print(f" Anthropic credentials: {existing_key[:12]}... β") |
| elif cc_available: |
| print(" Claude Code credentials: β (auto-detected)") |
| print() |
| print(" 1. Use existing credentials") |
| print(" 2. Reauthenticate (new OAuth login)") |
| print(" 3. Cancel") |
| print() |
| try: |
| choice = input(" Choice [1/2/3]: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| choice = "1" |
|
|
| if choice == "2": |
| needs_auth = True |
| elif choice == "3": |
| return |
| |
|
|
| if needs_auth: |
| |
| print() |
| print(" Choose authentication method:") |
| print() |
| print(" 1. Claude Pro/Max subscription (OAuth login)") |
| print(" 2. Anthropic API key (pay-per-token)") |
| print(" 3. Cancel") |
| print() |
| try: |
| choice = input(" Choice [1/2/3]: ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
|
|
| if choice == "1": |
| if not _run_anthropic_oauth_flow(save_env_value): |
| return |
|
|
| elif choice == "2": |
| print() |
| print(" Get an API key at: https://platform.claude.com/settings/keys") |
| print() |
| try: |
| import getpass |
|
|
| api_key = getpass.getpass(" API key (sk-ant-...): ").strip() |
| except (KeyboardInterrupt, EOFError): |
| print() |
| return |
| if not api_key: |
| print(" Cancelled.") |
| return |
| save_anthropic_api_key(api_key, save_fn=save_env_value) |
| print(" β API key saved.") |
|
|
| else: |
| print(" No change.") |
| return |
| print() |
|
|
| |
| model_list = _PROVIDER_MODELS.get("anthropic", []) |
| if model_list: |
| selected = _prompt_model_selection(model_list, current_model=current_model) |
| else: |
| try: |
| selected = input("Model name (e.g., claude-sonnet-4-20250514): ").strip() |
| except (KeyboardInterrupt, EOFError): |
| selected = None |
|
|
| if selected: |
| _save_model_choice(selected) |
|
|
| |
| |
| |
| |
| cfg = load_config() |
| model = cfg.get("model") |
| if not isinstance(model, dict): |
| model = {"default": model} if model else {} |
| cfg["model"] = model |
| model["provider"] = "anthropic" |
| model.pop("base_url", None) |
| save_config(cfg) |
| deactivate_provider() |
|
|
| print(f"Default model set to: {selected} (via Anthropic)") |
| else: |
| print("No change.") |
|
|
|
|
| def cmd_login(args): |
| """Authenticate Hermes CLI with a provider.""" |
| from hermes_cli.auth import login_command |
|
|
| login_command(args) |
|
|
|
|
| def cmd_logout(args): |
| """Clear provider authentication.""" |
| from hermes_cli.auth import logout_command |
|
|
| logout_command(args) |
|
|
|
|
| def cmd_auth(args): |
| """Manage pooled credentials.""" |
| from hermes_cli.auth_commands import auth_command |
|
|
| auth_command(args) |
|
|
|
|
| def cmd_status(args): |
| """Show status of all components.""" |
| from hermes_cli.status import show_status |
|
|
| show_status(args) |
|
|
|
|
| def cmd_cron(args): |
| """Cron job management.""" |
| from hermes_cli.cron import cron_command |
|
|
| cron_command(args) |
|
|
|
|
| def cmd_webhook(args): |
| """Webhook subscription management.""" |
| from hermes_cli.webhook import webhook_command |
|
|
| webhook_command(args) |
|
|
|
|
| def cmd_hooks(args): |
| """Shell-hook inspection and management.""" |
| from hermes_cli.hooks import hooks_command |
| hooks_command(args) |
|
|
|
|
| def cmd_doctor(args): |
| """Check configuration and dependencies.""" |
| from hermes_cli.doctor import run_doctor |
|
|
| run_doctor(args) |
|
|
|
|
| def cmd_dump(args): |
| """Dump setup summary for support/debugging.""" |
| from hermes_cli.dump import run_dump |
|
|
| run_dump(args) |
|
|
|
|
| def cmd_debug(args): |
| """Debug tools (share report, etc.).""" |
| from hermes_cli.debug import run_debug |
|
|
| run_debug(args) |
|
|
|
|
| def cmd_config(args): |
| """Configuration management.""" |
| from hermes_cli.config import config_command |
|
|
| config_command(args) |
|
|
|
|
| def cmd_backup(args): |
| """Back up Hermes home directory to a zip file.""" |
| if getattr(args, "quick", False): |
| from hermes_cli.backup import run_quick_backup |
|
|
| run_quick_backup(args) |
| else: |
| from hermes_cli.backup import run_backup |
|
|
| run_backup(args) |
|
|
|
|
| def cmd_import(args): |
| """Restore a Hermes backup from a zip file.""" |
| from hermes_cli.backup import run_import |
|
|
| run_import(args) |
|
|
|
|
| def cmd_version(args): |
| """Show version.""" |
| print(f"Hermes Agent v{__version__} ({__release_date__})") |
| print(f"Project: {PROJECT_ROOT}") |
|
|
| |
| print(f"Python: {sys.version.split()[0]}") |
|
|
| |
| try: |
| import openai |
|
|
| print(f"OpenAI SDK: {openai.__version__}") |
| except ImportError: |
| print("OpenAI SDK: Not installed") |
|
|
| |
| try: |
| from hermes_cli.banner import check_for_updates |
| from hermes_cli.config import recommended_update_command |
|
|
| behind = check_for_updates() |
| if behind and behind > 0: |
| commits_word = "commit" if behind == 1 else "commits" |
| print( |
| f"Update available: {behind} {commits_word} behind β " |
| f"run '{recommended_update_command()}'" |
| ) |
| elif behind == 0: |
| print("Up to date") |
| except Exception: |
| pass |
|
|
|
|
| def cmd_uninstall(args): |
| """Uninstall Hermes Agent.""" |
| _require_tty("uninstall") |
| from hermes_cli.uninstall import run_uninstall |
|
|
| run_uninstall(args) |
|
|
|
|
| def _clear_bytecode_cache(root: Path) -> int: |
| """Remove all __pycache__ directories under *root*. |
| |
| Stale .pyc files can cause ImportError after code updates when Python |
| loads a cached bytecode file that references names that no longer exist |
| (or don't yet exist) in the updated source. Clearing them forces Python |
| to recompile from the .py source on next import. |
| |
| Returns the number of directories removed. |
| """ |
| removed = 0 |
| for dirpath, dirnames, _ in os.walk(root): |
| |
| dirnames[:] = [ |
| d |
| for d in dirnames |
| if d not in ("venv", ".venv", "node_modules", ".git", ".worktrees") |
| ] |
| if os.path.basename(dirpath) == "__pycache__": |
| try: |
| shutil.rmtree(dirpath) |
| removed += 1 |
| except OSError: |
| pass |
| dirnames.clear() |
| return removed |
|
|
|
|
| def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) -> str: |
| """File-based IPC prompt for gateway mode. |
| |
| Writes a prompt marker file so the gateway can forward the question to the |
| user, then polls for a response file. Falls back to *default* on timeout. |
| |
| Used by ``hermes update --gateway`` so interactive prompts (stash restore, |
| config migration) are forwarded to the messenger instead of being silently |
| skipped. |
| """ |
| import json as _json |
| import uuid as _uuid |
| from hermes_constants import get_hermes_home |
|
|
| home = get_hermes_home() |
| prompt_path = home / ".update_prompt.json" |
| response_path = home / ".update_response" |
|
|
| |
| response_path.unlink(missing_ok=True) |
|
|
| payload = { |
| "prompt": prompt_text, |
| "default": default, |
| "id": str(_uuid.uuid4()), |
| } |
| tmp = prompt_path.with_suffix(".tmp") |
| tmp.write_text(_json.dumps(payload)) |
| tmp.replace(prompt_path) |
|
|
| |
| deadline = _time.monotonic() + timeout |
| while _time.monotonic() < deadline: |
| if response_path.exists(): |
| try: |
| answer = response_path.read_text().strip() |
| response_path.unlink(missing_ok=True) |
| prompt_path.unlink(missing_ok=True) |
| return answer if answer else default |
| except (OSError, ValueError): |
| pass |
| _time.sleep(0.5) |
|
|
| |
| prompt_path.unlink(missing_ok=True) |
| response_path.unlink(missing_ok=True) |
| print(f" (no response after {int(timeout)}s, using default: {default!r})") |
| return default |
|
|
|
|
| def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: |
| """Build the web UI frontend if npm is available. |
| |
| Args: |
| web_dir: Path to the ``web/`` source directory. |
| fatal: If True, print error guidance and return False on failure |
| instead of a soft warning (used by ``hermes web``). |
| |
| Returns True if the build succeeded or was skipped (no package.json). |
| """ |
| if not (web_dir / "package.json").exists(): |
| return True |
|
|
| npm = shutil.which("npm") |
| if not npm: |
| if fatal: |
| print("Web UI frontend not built and npm is not available.") |
| print("Install Node.js, then run: cd web && npm install && npm run build") |
| return not fatal |
| print("β Building web UI...") |
| r1 = subprocess.run([npm, "install", "--silent"], cwd=web_dir, capture_output=True) |
| if r1.returncode != 0: |
| print( |
| f" {'β' if fatal else 'β '} Web UI npm install failed" |
| + ("" if fatal else " (hermes web will not be available)") |
| ) |
| if fatal: |
| print(" Run manually: cd web && npm install && npm run build") |
| return False |
| r2 = subprocess.run([npm, "run", "build"], cwd=web_dir, capture_output=True) |
| if r2.returncode != 0: |
| print( |
| f" {'β' if fatal else 'β '} Web UI build failed" |
| + ("" if fatal else " (hermes web will not be available)") |
| ) |
| if fatal: |
| print(" Run manually: cd web && npm install && npm run build") |
| return False |
| print(" β Web UI built") |
| return True |
|
|
|
|
| def _update_via_zip(args): |
| """Update Hermes Agent by downloading a ZIP archive. |
| |
| Used on Windows when git file I/O is broken (antivirus, NTFS filter |
| drivers causing 'Invalid argument' errors on file creation). |
| """ |
| import tempfile |
| import zipfile |
| from urllib.request import urlretrieve |
|
|
| branch = "main" |
| zip_url = ( |
| f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip" |
| ) |
|
|
| print("β Downloading latest version...") |
| try: |
| tmp_dir = tempfile.mkdtemp(prefix="hermes-update-") |
| zip_path = os.path.join(tmp_dir, f"hermes-agent-{branch}.zip") |
| urlretrieve(zip_url, zip_path) |
|
|
| print("β Extracting...") |
| with zipfile.ZipFile(zip_path, "r") as zf: |
| |
| tmp_dir_real = os.path.realpath(tmp_dir) |
| for member in zf.infolist(): |
| member_path = os.path.realpath(os.path.join(tmp_dir, member.filename)) |
| if ( |
| not member_path.startswith(tmp_dir_real + os.sep) |
| and member_path != tmp_dir_real |
| ): |
| raise ValueError( |
| f"Zip-slip detected: {member.filename} escapes extraction directory" |
| ) |
| zf.extractall(tmp_dir) |
|
|
| |
| extracted = os.path.join(tmp_dir, f"hermes-agent-{branch}") |
| if not os.path.isdir(extracted): |
| |
| for d in os.listdir(tmp_dir): |
| candidate = os.path.join(tmp_dir, d) |
| if os.path.isdir(candidate) and d != "__MACOSX": |
| extracted = candidate |
| break |
|
|
| |
| preserve = {"venv", "node_modules", ".git", ".env"} |
| update_count = 0 |
| for item in os.listdir(extracted): |
| if item in preserve: |
| continue |
| src = os.path.join(extracted, item) |
| dst = os.path.join(str(PROJECT_ROOT), item) |
| if os.path.isdir(src): |
| if os.path.exists(dst): |
| shutil.rmtree(dst) |
| shutil.copytree(src, dst) |
| else: |
| shutil.copy2(src, dst) |
| update_count += 1 |
|
|
| print(f"β Updated {update_count} items from ZIP") |
|
|
| |
| shutil.rmtree(tmp_dir, ignore_errors=True) |
|
|
| except Exception as e: |
| print(f"β ZIP update failed: {e}") |
| sys.exit(1) |
|
|
| |
| removed = _clear_bytecode_cache(PROJECT_ROOT) |
| if removed: |
| print( |
| f" β Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}" |
| ) |
|
|
| |
| |
| |
| print("β Updating Python dependencies...") |
|
|
| uv_bin = shutil.which("uv") |
| if uv_bin: |
| uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} |
| _install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env) |
| else: |
| |
| |
| |
| |
| pip_cmd = [sys.executable, "-m", "pip"] |
| try: |
| subprocess.run( |
| pip_cmd + ["--version"], |
| cwd=PROJECT_ROOT, |
| check=True, |
| capture_output=True, |
| ) |
| except subprocess.CalledProcessError: |
| subprocess.run( |
| [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], |
| cwd=PROJECT_ROOT, |
| check=True, |
| ) |
| _install_python_dependencies_with_optional_fallback(pip_cmd) |
|
|
| _update_node_dependencies() |
| _build_web_ui(PROJECT_ROOT / "web") |
|
|
| |
| try: |
| from tools.skills_sync import sync_skills |
|
|
| print("β Syncing bundled skills...") |
| result = sync_skills(quiet=True) |
| if result["copied"]: |
| print(f" + {len(result['copied'])} new: {', '.join(result['copied'])}") |
| if result.get("updated"): |
| print( |
| f" β {len(result['updated'])} updated: {', '.join(result['updated'])}" |
| ) |
| if result.get("user_modified"): |
| print(f" ~ {len(result['user_modified'])} user-modified (kept)") |
| if result.get("cleaned"): |
| print(f" β {len(result['cleaned'])} removed from manifest") |
| if not result["copied"] and not result.get("updated"): |
| print(" β Skills are up to date") |
| except Exception: |
| pass |
|
|
| print() |
| print("β Update complete!") |
|
|
|
|
| def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]: |
| status = subprocess.run( |
| git_cmd + ["status", "--porcelain"], |
| cwd=cwd, |
| capture_output=True, |
| text=True, |
| check=True, |
| ) |
| if not status.stdout.strip(): |
| return None |
|
|
| |
| |
| |
| |
| unmerged = subprocess.run( |
| git_cmd + ["ls-files", "--unmerged"], |
| cwd=cwd, |
| capture_output=True, |
| text=True, |
| ) |
| if unmerged.stdout.strip(): |
| print("β Clearing unmerged index entries from a previous conflict...") |
| subprocess.run(git_cmd + ["reset"], cwd=cwd, capture_output=True) |
|
|
| from datetime import datetime, timezone |
|
|
| stash_name = datetime.now(timezone.utc).strftime( |
| "hermes-update-autostash-%Y%m%d-%H%M%S" |
| ) |
| print("β Local changes detected β stashing before update...") |
| subprocess.run( |
| git_cmd + ["stash", "push", "--include-untracked", "-m", stash_name], |
| cwd=cwd, |
| check=True, |
| ) |
| stash_ref = subprocess.run( |
| git_cmd + ["rev-parse", "--verify", "refs/stash"], |
| cwd=cwd, |
| capture_output=True, |
| text=True, |
| check=True, |
| ).stdout.strip() |
| return stash_ref |
|
|
|
|
| def _resolve_stash_selector( |
| git_cmd: list[str], cwd: Path, stash_ref: str |
| ) -> Optional[str]: |
| stash_list = subprocess.run( |
| git_cmd + ["stash", "list", "--format=%gd %H"], |
| cwd=cwd, |
| capture_output=True, |
| text=True, |
| check=True, |
| ) |
| for line in stash_list.stdout.splitlines(): |
| selector, _, commit = line.partition(" ") |
| if commit.strip() == stash_ref: |
| return selector.strip() |
| return None |
|
|
|
|
| def _print_stash_cleanup_guidance( |
| stash_ref: str, stash_selector: Optional[str] = None |
| ) -> None: |
| print( |
| " Check `git status` first so you don't accidentally reapply the same change twice." |
| ) |
| print(" Find the saved entry with: git stash list --format='%gd %H %s'") |
| if stash_selector: |
| print(f" Remove it with: git stash drop {stash_selector}") |
| else: |
| print( |
| f" Look for commit {stash_ref}, then drop its selector with: git stash drop stash@{{N}}" |
| ) |
|
|
|
|
| def _restore_stashed_changes( |
| git_cmd: list[str], |
| cwd: Path, |
| stash_ref: str, |
| prompt_user: bool = False, |
| input_fn=None, |
| ) -> bool: |
| if prompt_user: |
| print() |
| print("β Local changes were stashed before updating.") |
| print( |
| " Restoring them may reapply local customizations onto the updated codebase." |
| ) |
| print(" Review the result afterward if Hermes behaves unexpectedly.") |
| print("Restore local changes now? [Y/n]") |
| if input_fn is not None: |
| response = input_fn("Restore local changes now? [Y/n]", "y") |
| else: |
| response = input().strip().lower() |
| if response not in ("", "y", "yes"): |
| print("Skipped restoring local changes.") |
| print("Your changes are still preserved in git stash.") |
| print(f"Restore manually with: git stash apply {stash_ref}") |
| return False |
|
|
| print("β Restoring local changes...") |
| restore = subprocess.run( |
| git_cmd + ["stash", "apply", stash_ref], |
| cwd=cwd, |
| capture_output=True, |
| text=True, |
| ) |
|
|
| |
| unmerged = subprocess.run( |
| git_cmd + ["diff", "--name-only", "--diff-filter=U"], |
| cwd=cwd, |
| capture_output=True, |
| text=True, |
| ) |
| has_conflicts = bool(unmerged.stdout.strip()) |
|
|
| if restore.returncode != 0 or has_conflicts: |
| print("β Update pulled new code, but restoring local changes hit conflicts.") |
| if restore.stdout.strip(): |
| print(restore.stdout.strip()) |
| if restore.stderr.strip(): |
| print(restore.stderr.strip()) |
|
|
| |
| conflicted_files = unmerged.stdout.strip() |
| if conflicted_files: |
| print("\nConflicted files:") |
| for f in conflicted_files.splitlines(): |
| print(f" β’ {f}") |
|
|
| print("\nYour stashed changes are preserved β nothing is lost.") |
| print(f" Stash ref: {stash_ref}") |
|
|
| |
| |
| |
| subprocess.run( |
| git_cmd + ["reset", "--hard", "HEAD"], |
| cwd=cwd, |
| capture_output=True, |
| ) |
| print("Working tree reset to clean state.") |
| print(f"Restore your changes later with: git stash apply {stash_ref}") |
| |
| |
| |
| return False |
|
|
| stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref) |
| if stash_selector is None: |
| print( |
| "β Local changes were restored, but Hermes couldn't find the stash entry to drop." |
| ) |
| print( |
| " The stash was left in place. You can remove it manually after checking the result." |
| ) |
| _print_stash_cleanup_guidance(stash_ref) |
| else: |
| drop = subprocess.run( |
| git_cmd + ["stash", "drop", stash_selector], |
| cwd=cwd, |
| capture_output=True, |
| text=True, |
| ) |
| if drop.returncode != 0: |
| print( |
| "β Local changes were restored, but Hermes couldn't drop the saved stash entry." |
| ) |
| if drop.stdout.strip(): |
| print(drop.stdout.strip()) |
| if drop.stderr.strip(): |
| print(drop.stderr.strip()) |
| print( |
| " The stash was left in place. You can remove it manually after checking the result." |
| ) |
| _print_stash_cleanup_guidance(stash_ref, stash_selector) |
|
|
| print("β Local changes were restored on top of the updated codebase.") |
| print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.") |
| return True |
|
|
|
|
| |
| |
| |
|
|
| OFFICIAL_REPO_URLS = { |
| "https://github.com/NousResearch/hermes-agent.git", |
| "git@github.com:NousResearch/hermes-agent.git", |
| "https://github.com/NousResearch/hermes-agent", |
| "git@github.com:NousResearch/hermes-agent", |
| } |
| OFFICIAL_REPO_URL = "https://github.com/NousResearch/hermes-agent.git" |
| SKIP_UPSTREAM_PROMPT_FILE = ".skip_upstream_prompt" |
|
|
|
|
| def _get_origin_url(git_cmd: list[str], cwd: Path) -> Optional[str]: |
| """Get the URL of the origin remote, or None if not set.""" |
| try: |
| result = subprocess.run( |
| git_cmd + ["remote", "get-url", "origin"], |
| cwd=cwd, |
| capture_output=True, |
| text=True, |
| ) |
| if result.returncode == 0: |
| return result.stdout.strip() |
| except Exception: |
| pass |
| return None |
|
|
|
|
| def _is_fork(origin_url: Optional[str]) -> bool: |
| """Check if the origin remote points to a fork (not the official repo).""" |
| if not origin_url: |
| return False |
| |
| normalized = origin_url.rstrip("/") |
| if normalized.endswith(".git"): |
| normalized = normalized[:-4] |
| for official in OFFICIAL_REPO_URLS: |
| official_normalized = official.rstrip("/") |
| if official_normalized.endswith(".git"): |
| official_normalized = official_normalized[:-4] |
| if normalized == official_normalized: |
| return False |
| return True |
|
|
|
|
| def _has_upstream_remote(git_cmd: list[str], cwd: Path) -> bool: |
| """Check if an 'upstream' remote already exists.""" |
| try: |
| result = subprocess.run( |
| git_cmd + ["remote", "get-url", "upstream"], |
| cwd=cwd, |
| capture_output=True, |
| text=True, |
| ) |
| return result.returncode == 0 |
| except Exception: |
| return False |
|
|
|
|
| def _add_upstream_remote(git_cmd: list[str], cwd: Path) -> bool: |
| """Add the official repo as the 'upstream' remote. Returns True on success.""" |
| try: |
| result = subprocess.run( |
| git_cmd + ["remote", "add", "upstream", OFFICIAL_REPO_URL], |
| cwd=cwd, |
| capture_output=True, |
| text=True, |
| ) |
| return result.returncode == 0 |
| except Exception: |
| return False |
|
|
|
|
| def _count_commits_between(git_cmd: list[str], cwd: Path, base: str, head: str) -> int: |
| """Count commits on `head` that are not on `base`. Returns -1 on error.""" |
| try: |
| result = subprocess.run( |
| git_cmd + ["rev-list", "--count", f"{base}..{head}"], |
| cwd=cwd, |
| capture_output=True, |
| text=True, |
| ) |
| if result.returncode == 0: |
| return int(result.stdout.strip()) |
| except Exception: |
| pass |
| return -1 |
|
|
|
|
| def _should_skip_upstream_prompt() -> bool: |
| """Check if user previously declined to add upstream.""" |
| from hermes_constants import get_hermes_home |
|
|
| return (get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).exists() |
|
|
|
|
| def _mark_skip_upstream_prompt(): |
| """Create marker file to skip future upstream prompts.""" |
| try: |
| from hermes_constants import get_hermes_home |
|
|
| (get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).touch() |
| except Exception: |
| pass |
|
|
|
|
| def _sync_fork_with_upstream(git_cmd: list[str], cwd: Path) -> bool: |
| """Attempt to push updated main to origin (sync fork). |
| |
| Returns True if push succeeded, False otherwise. |
| """ |
| try: |
| result = subprocess.run( |
| git_cmd + ["push", "origin", "main", "--force-with-lease"], |
| cwd=cwd, |
| capture_output=True, |
| text=True, |
| ) |
| return result.returncode == 0 |
| except Exception: |
| return False |
|
|
|
|
| def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: |
| """Check if fork is behind upstream and sync if safe. |
| |
| This implements the fork upstream sync logic: |
| - If upstream remote doesn't exist, ask user if they want to add it |
| - Compare origin/main with upstream/main |
| - If origin/main is strictly behind upstream/main, pull from upstream |
| - Try to sync fork back to origin if possible |
| """ |
| has_upstream = _has_upstream_remote(git_cmd, cwd) |
|
|
| if not has_upstream: |
| |
| if _should_skip_upstream_prompt(): |
| return |
|
|
| |
| print() |
| print("βΉ Your fork is not tracking the official Hermes repository.") |
| print(" This means you may miss updates from NousResearch/hermes-agent.") |
| print() |
| try: |
| response = ( |
| input("Add official repo as 'upstream' remote? [Y/n]: ").strip().lower() |
| ) |
| except (EOFError, KeyboardInterrupt): |
| print() |
| response = "n" |
|
|
| if response in ("", "y", "yes"): |
| print("β Adding upstream remote...") |
| if _add_upstream_remote(git_cmd, cwd): |
| print( |
| " β Added upstream: https://github.com/NousResearch/hermes-agent.git" |
| ) |
| has_upstream = True |
| else: |
| print(" β Failed to add upstream remote. Skipping upstream sync.") |
| return |
| else: |
| print( |
| " Skipped. Run 'git remote add upstream https://github.com/NousResearch/hermes-agent.git' to add later." |
| ) |
| _mark_skip_upstream_prompt() |
| return |
|
|
| |
| print() |
| print("β Fetching upstream...") |
| try: |
| subprocess.run( |
| git_cmd + ["fetch", "upstream", "--quiet"], |
| cwd=cwd, |
| capture_output=True, |
| check=True, |
| ) |
| except subprocess.CalledProcessError: |
| print(" β Failed to fetch upstream. Skipping upstream sync.") |
| return |
|
|
| |
| origin_ahead = _count_commits_between(git_cmd, cwd, "upstream/main", "origin/main") |
| upstream_ahead = _count_commits_between( |
| git_cmd, cwd, "origin/main", "upstream/main" |
| ) |
|
|
| if origin_ahead < 0 or upstream_ahead < 0: |
| print(" β Could not compare branches. Skipping upstream sync.") |
| return |
|
|
| |
| if origin_ahead > 0: |
| print() |
| print(f"βΉ Your fork has {origin_ahead} commit(s) not on upstream.") |
| print(" Skipping upstream sync to preserve your changes.") |
| print(" If you want to merge upstream changes, run:") |
| print(" git pull upstream main") |
| return |
|
|
| |
| if upstream_ahead == 0: |
| print(" β Fork is up to date with upstream") |
| return |
|
|
| |
| print() |
| print(f"β Fork is {upstream_ahead} commit(s) behind upstream") |
| print("β Pulling from upstream...") |
|
|
| try: |
| subprocess.run( |
| git_cmd + ["pull", "--ff-only", "upstream", "main"], |
| cwd=cwd, |
| check=True, |
| ) |
| except subprocess.CalledProcessError: |
| print( |
| " β Failed to pull from upstream. You may need to resolve conflicts manually." |
| ) |
| return |
|
|
| print(" β Updated from upstream") |
|
|
| |
| print("β Syncing fork...") |
| if _sync_fork_with_upstream(git_cmd, cwd): |
| print(" β Fork synced with upstream") |
| else: |
| print( |
| " βΉ Got updates from upstream but couldn't push to fork (no write access?)" |
| ) |
| print(" Your local repo is updated, but your fork on GitHub may be behind.") |
|
|
|
|
| def _invalidate_update_cache(): |
| """Delete the update-check cache for ALL profiles so no banner |
| reports a stale "commits behind" count after a successful update. |
| |
| The git repo is shared across profiles β when one profile runs |
| ``hermes update``, every profile is now current. |
| """ |
| homes = [] |
| |
| from hermes_constants import get_default_hermes_root |
|
|
| default_home = get_default_hermes_root() |
| homes.append(default_home) |
| |
| profiles_root = default_home / "profiles" |
| if profiles_root.is_dir(): |
| for entry in profiles_root.iterdir(): |
| if entry.is_dir(): |
| homes.append(entry) |
| for home in homes: |
| try: |
| cache_file = home / ".update_check" |
| if cache_file.exists(): |
| cache_file.unlink() |
| except Exception: |
| pass |
|
|
|
|
| def _load_installable_optional_extras() -> list[str]: |
| """Return the optional extras referenced by the ``all`` group. |
| |
| Only extras that ``[all]`` actually pulls in are retried individually. |
| Extras outside ``[all]`` (e.g. ``rl``, ``yc-bench``) are intentionally |
| excluded β they have heavy or platform-specific deps that most users |
| never installed. |
| """ |
| try: |
| import tomllib |
|
|
| with (PROJECT_ROOT / "pyproject.toml").open("rb") as handle: |
| project = tomllib.load(handle).get("project", {}) |
| except Exception: |
| return [] |
|
|
| optional_deps = project.get("optional-dependencies", {}) |
| if not isinstance(optional_deps, dict): |
| return [] |
|
|
| |
| |
| all_refs = optional_deps.get("all", []) |
| referenced: list[str] = [] |
| for ref in all_refs: |
| if "[" in ref and "]" in ref: |
| name = ref.split("[", 1)[1].split("]", 1)[0] |
| if name in optional_deps: |
| referenced.append(name) |
|
|
| return referenced |
|
|
|
|
| def _install_python_dependencies_with_optional_fallback( |
| install_cmd_prefix: list[str], |
| *, |
| env: dict[str, str] | None = None, |
| ) -> None: |
| """Install base deps plus as many optional extras as the environment supports.""" |
| try: |
| subprocess.run( |
| install_cmd_prefix + ["install", "-e", ".[all]", "--quiet"], |
| cwd=PROJECT_ROOT, |
| check=True, |
| env=env, |
| ) |
| return |
| except subprocess.CalledProcessError: |
| print( |
| " β Optional extras failed, reinstalling base dependencies and retrying extras individually..." |
| ) |
|
|
| subprocess.run( |
| install_cmd_prefix + ["install", "-e", ".", "--quiet"], |
| cwd=PROJECT_ROOT, |
| check=True, |
| env=env, |
| ) |
|
|
| failed_extras: list[str] = [] |
| installed_extras: list[str] = [] |
| for extra in _load_installable_optional_extras(): |
| try: |
| subprocess.run( |
| install_cmd_prefix + ["install", "-e", f".[{extra}]", "--quiet"], |
| cwd=PROJECT_ROOT, |
| check=True, |
| env=env, |
| ) |
| installed_extras.append(extra) |
| except subprocess.CalledProcessError: |
| failed_extras.append(extra) |
|
|
| if installed_extras: |
| print( |
| f" β Reinstalled optional extras individually: {', '.join(installed_extras)}" |
| ) |
| if failed_extras: |
| print( |
| f" β Skipped optional extras that still failed: {', '.join(failed_extras)}" |
| ) |
|
|
|
|
| def _update_node_dependencies() -> None: |
| npm = shutil.which("npm") |
| if not npm: |
| return |
|
|
| paths = ( |
| ("repo root", PROJECT_ROOT), |
| ("ui-tui", PROJECT_ROOT / "ui-tui"), |
| ) |
| if not any((path / "package.json").exists() for _, path in paths): |
| return |
|
|
| print("β Updating Node.js dependencies...") |
| for label, path in paths: |
| if not (path / "package.json").exists(): |
| continue |
|
|
| result = subprocess.run( |
| [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], |
| cwd=path, |
| capture_output=True, |
| text=True, |
| check=False, |
| ) |
| if result.returncode == 0: |
| print(f" β {label}") |
| continue |
|
|
| print(f" β npm install failed in {label}") |
| stderr = (result.stderr or "").strip() |
| if stderr: |
| print(f" {stderr.splitlines()[-1]}") |
|
|
|
|
| class _UpdateOutputStream: |
| """Stream wrapper used during ``hermes update`` to survive terminal loss. |
| |
| Wraps the process's original stdout/stderr so that: |
| |
| * Every write is also mirrored to an append-only log file |
| (``~/.hermes/logs/update.log``) that users can inspect after the |
| terminal disconnects. |
| * Writes to the original stream that fail with ``BrokenPipeError`` / |
| ``OSError`` / ``ValueError`` (closed file) no longer cascade into |
| process exit β the update keeps going, only the on-screen output |
| stops. |
| |
| Combined with ``SIGHUP -> SIG_IGN`` installed by |
| ``_install_hangup_protection``, this makes ``hermes update`` safe to |
| run in a plain SSH session that might disconnect mid-install. |
| """ |
|
|
| def __init__(self, original, log_file): |
| self._original = original |
| self._log = log_file |
| self._original_broken = False |
|
|
| def write(self, data): |
| |
| if self._log is not None: |
| try: |
| self._log.write(data) |
| except Exception: |
| |
| pass |
|
|
| if self._original_broken: |
| return len(data) if isinstance(data, (str, bytes)) else 0 |
|
|
| try: |
| return self._original.write(data) |
| except (BrokenPipeError, OSError, ValueError): |
| |
| |
| self._original_broken = True |
| return len(data) if isinstance(data, (str, bytes)) else 0 |
|
|
| def flush(self): |
| if self._log is not None: |
| try: |
| self._log.flush() |
| except Exception: |
| pass |
| if self._original_broken: |
| return |
| try: |
| self._original.flush() |
| except (BrokenPipeError, OSError, ValueError): |
| self._original_broken = True |
|
|
| def isatty(self): |
| if self._original_broken: |
| return False |
| try: |
| return self._original.isatty() |
| except Exception: |
| return False |
|
|
| def fileno(self): |
| |
| |
| return self._original.fileno() |
|
|
| def __getattr__(self, name): |
| return getattr(self._original, name) |
|
|
|
|
| def _install_hangup_protection(gateway_mode: bool = False): |
| """Protect ``cmd_update`` from SIGHUP and broken terminal pipes. |
| |
| Users commonly run ``hermes update`` in an SSH session or a terminal |
| that may close mid-install. Without protection, ``SIGHUP`` from the |
| terminal kills the Python process during ``pip install`` and leaves |
| the venv half-installed; the documented workaround ("use screen / |
| tmux") shouldn't be required for something as routine as an update. |
| |
| Protections installed: |
| |
| 1. ``SIGHUP`` is set to ``SIG_IGN``. POSIX preserves ``SIG_IGN`` |
| across ``exec()``, so pip and git subprocesses also stop dying on |
| hangup. |
| 2. ``sys.stdout`` / ``sys.stderr`` are wrapped to mirror output to |
| ``~/.hermes/logs/update.log`` and to silently absorb |
| ``BrokenPipeError`` when the terminal vanishes. |
| |
| ``SIGINT`` (Ctrl-C) and ``SIGTERM`` (systemd shutdown) are |
| **intentionally left alone** β those are legitimate cancellation |
| signals the user or OS sent on purpose. |
| |
| In gateway mode (``hermes update --gateway``) the update is already |
| spawned detached from a terminal, so this function is a no-op. |
| |
| Returns a dict that ``cmd_update`` can pass to |
| ``_finalize_update_output`` on exit. Returning a dict rather than a |
| tuple keeps the call site forward-compatible with future additions. |
| """ |
| state = { |
| "prev_stdout": sys.stdout, |
| "prev_stderr": sys.stderr, |
| "log_file": None, |
| "installed": False, |
| } |
|
|
| if gateway_mode: |
| return state |
|
|
| import signal as _signal |
|
|
| |
| if hasattr(_signal, "SIGHUP"): |
| try: |
| _signal.signal(_signal.SIGHUP, _signal.SIG_IGN) |
| except (ValueError, OSError): |
| |
| |
| pass |
|
|
| |
| |
| try: |
| |
| |
| from hermes_cli.config import get_hermes_home as _get_hermes_home |
|
|
| logs_dir = _get_hermes_home() / "logs" |
| logs_dir.mkdir(parents=True, exist_ok=True) |
| log_path = logs_dir / "update.log" |
| log_file = open(log_path, "a", buffering=1, encoding="utf-8") |
|
|
| import datetime as _dt |
|
|
| log_file.write( |
| f"\n=== hermes update started " |
| f"{_dt.datetime.now().isoformat(timespec='seconds')} ===\n" |
| ) |
|
|
| state["log_file"] = log_file |
| sys.stdout = _UpdateOutputStream(state["prev_stdout"], log_file) |
| sys.stderr = _UpdateOutputStream(state["prev_stderr"], log_file) |
| state["installed"] = True |
| except Exception: |
| |
| |
| state["log_file"] = None |
|
|
| return state |
|
|
|
|
| def _finalize_update_output(state): |
| """Restore stdio and close the update.log handle opened by ``_install_hangup_protection``.""" |
| if not state: |
| return |
| if state.get("installed"): |
| try: |
| sys.stdout = state.get("prev_stdout", sys.stdout) |
| except Exception: |
| pass |
| try: |
| sys.stderr = state.get("prev_stderr", sys.stderr) |
| except Exception: |
| pass |
| log_file = state.get("log_file") |
| if log_file is not None: |
| try: |
| log_file.flush() |
| log_file.close() |
| except Exception: |
| pass |
|
|
|
|
| def cmd_update(args): |
| """Update Hermes Agent to the latest version. |
| |
| Thin wrapper around ``_cmd_update_impl``: installs hangup protection, |
| runs the update, then restores stdio on the way out (even on |
| ``sys.exit`` or unhandled exceptions). |
| """ |
| from hermes_cli.config import is_managed, managed_error |
|
|
| if is_managed(): |
| managed_error("update Hermes Agent") |
| return |
|
|
| gateway_mode = getattr(args, "gateway", False) |
|
|
| |
| |
| |
| _update_io_state = _install_hangup_protection(gateway_mode=gateway_mode) |
| try: |
| _cmd_update_impl(args, gateway_mode=gateway_mode) |
| finally: |
| _finalize_update_output(_update_io_state) |
|
|
|
|
| def _cmd_update_impl(args, gateway_mode: bool): |
| """Body of ``cmd_update`` β kept separate so the wrapper can always |
| restore stdio even on ``sys.exit``.""" |
| |
| gw_input_fn = ( |
| (lambda prompt, default="": _gateway_prompt(prompt, default)) |
| if gateway_mode |
| else None |
| ) |
|
|
| print("β Updating Hermes Agent...") |
| print() |
|
|
| |
| |
| use_zip_update = False |
| git_dir = PROJECT_ROOT / ".git" |
|
|
| if not git_dir.exists(): |
| if sys.platform == "win32": |
| use_zip_update = True |
| else: |
| print("β Not a git repository. Please reinstall:") |
| print( |
| " curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash" |
| ) |
| sys.exit(1) |
|
|
| |
| |
| if sys.platform == "win32" and git_dir.exists(): |
| subprocess.run( |
| [ |
| "git", |
| "-c", |
| "windows.appendAtomically=false", |
| "config", |
| "windows.appendAtomically", |
| "false", |
| ], |
| cwd=PROJECT_ROOT, |
| check=False, |
| capture_output=True, |
| ) |
|
|
| |
| git_cmd = ["git"] |
| if sys.platform == "win32": |
| git_cmd = ["git", "-c", "windows.appendAtomically=false"] |
|
|
| |
| origin_url = _get_origin_url(git_cmd, PROJECT_ROOT) |
| is_fork = _is_fork(origin_url) |
|
|
| if is_fork: |
| print("β Updating from fork:") |
| print(f" {origin_url}") |
| print() |
|
|
| if use_zip_update: |
| |
| _update_via_zip(args) |
| return |
|
|
| |
| try: |
|
|
| print("β Fetching updates...") |
| fetch_result = subprocess.run( |
| git_cmd + ["fetch", "origin"], |
| cwd=PROJECT_ROOT, |
| capture_output=True, |
| text=True, |
| ) |
| if fetch_result.returncode != 0: |
| stderr = fetch_result.stderr.strip() |
| if "Could not resolve host" in stderr or "unable to access" in stderr: |
| print("β Network error β cannot reach the remote repository.") |
| print(f" {stderr.splitlines()[0]}" if stderr else "") |
| elif ( |
| "Authentication failed" in stderr or "could not read Username" in stderr |
| ): |
| print( |
| "β Authentication failed β check your git credentials or SSH key." |
| ) |
| else: |
| print(f"β Failed to fetch updates from origin.") |
| if stderr: |
| print(f" {stderr.splitlines()[0]}") |
| sys.exit(1) |
|
|
| |
| result = subprocess.run( |
| git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"], |
| cwd=PROJECT_ROOT, |
| capture_output=True, |
| text=True, |
| check=True, |
| ) |
| current_branch = result.stdout.strip() |
|
|
| |
| branch = "main" |
|
|
| |
| if current_branch != "main": |
| label = ( |
| "detached HEAD" |
| if current_branch == "HEAD" |
| else f"branch '{current_branch}'" |
| ) |
| print(f" β Currently on {label} β switching to main for update...") |
| |
| auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) |
| subprocess.run( |
| git_cmd + ["checkout", "main"], |
| cwd=PROJECT_ROOT, |
| capture_output=True, |
| text=True, |
| check=True, |
| ) |
| else: |
| auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) |
|
|
| prompt_for_restore = auto_stash_ref is not None and ( |
| gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty()) |
| ) |
|
|
| |
| result = subprocess.run( |
| git_cmd + ["rev-list", f"HEAD..origin/{branch}", "--count"], |
| cwd=PROJECT_ROOT, |
| capture_output=True, |
| text=True, |
| check=True, |
| ) |
| commit_count = int(result.stdout.strip()) |
|
|
| if commit_count == 0: |
| _invalidate_update_cache() |
| |
| if auto_stash_ref is not None: |
| _restore_stashed_changes( |
| git_cmd, |
| PROJECT_ROOT, |
| auto_stash_ref, |
| prompt_user=prompt_for_restore, |
| input_fn=gw_input_fn, |
| ) |
| if current_branch not in ("main", "HEAD"): |
| subprocess.run( |
| git_cmd + ["checkout", current_branch], |
| cwd=PROJECT_ROOT, |
| capture_output=True, |
| text=True, |
| check=False, |
| ) |
| print("β Already up to date!") |
| return |
|
|
| print(f"β Found {commit_count} new commit(s)") |
|
|
| print("β Pulling updates...") |
| update_succeeded = False |
| try: |
| pull_result = subprocess.run( |
| git_cmd + ["pull", "--ff-only", "origin", branch], |
| cwd=PROJECT_ROOT, |
| capture_output=True, |
| text=True, |
| ) |
| if pull_result.returncode != 0: |
| |
| |
| |
| print( |
| " β Fast-forward not possible (history diverged), resetting to match remote..." |
| ) |
| reset_result = subprocess.run( |
| git_cmd + ["reset", "--hard", f"origin/{branch}"], |
| cwd=PROJECT_ROOT, |
| capture_output=True, |
| text=True, |
| ) |
| if reset_result.returncode != 0: |
| print(f"β Failed to reset to origin/{branch}.") |
| if reset_result.stderr.strip(): |
| print(f" {reset_result.stderr.strip()}") |
| print( |
| " Try manually: git fetch origin && git reset --hard origin/main" |
| ) |
| sys.exit(1) |
| update_succeeded = True |
| finally: |
| if auto_stash_ref is not None: |
| |
| |
| if not update_succeeded: |
| print( |
| f" βΉοΈ Local changes preserved in stash (ref: {auto_stash_ref})" |
| ) |
| print(f" Restore manually with: git stash apply") |
| else: |
| _restore_stashed_changes( |
| git_cmd, |
| PROJECT_ROOT, |
| auto_stash_ref, |
| prompt_user=prompt_for_restore, |
| input_fn=gw_input_fn, |
| ) |
|
|
| _invalidate_update_cache() |
|
|
| |
| |
| |
| removed = _clear_bytecode_cache(PROJECT_ROOT) |
| if removed: |
| print( |
| f" β Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}" |
| ) |
|
|
| |
| if is_fork and branch == "main": |
| _sync_with_upstream_if_needed(git_cmd, PROJECT_ROOT) |
|
|
| |
| |
| |
| print("β Updating Python dependencies...") |
| uv_bin = shutil.which("uv") |
| if uv_bin: |
| uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} |
| _install_python_dependencies_with_optional_fallback( |
| [uv_bin, "pip"], env=uv_env |
| ) |
| else: |
| |
| |
| |
| |
| pip_cmd = [sys.executable, "-m", "pip"] |
| try: |
| subprocess.run( |
| pip_cmd + ["--version"], |
| cwd=PROJECT_ROOT, |
| check=True, |
| capture_output=True, |
| ) |
| except subprocess.CalledProcessError: |
| subprocess.run( |
| [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], |
| cwd=PROJECT_ROOT, |
| check=True, |
| ) |
| _install_python_dependencies_with_optional_fallback(pip_cmd) |
|
|
| _update_node_dependencies() |
| _build_web_ui(PROJECT_ROOT / "web") |
|
|
| print() |
| print("β Code updated!") |
|
|
| |
| |
| |
| |
| try: |
| import importlib |
| import hermes_constants as _hc |
|
|
| importlib.reload(_hc) |
| except Exception: |
| pass |
|
|
| |
| try: |
| from tools.skills_sync import sync_skills |
|
|
| print() |
| print("β Syncing bundled skills...") |
| result = sync_skills(quiet=True) |
| if result["copied"]: |
| print(f" + {len(result['copied'])} new: {', '.join(result['copied'])}") |
| if result.get("updated"): |
| print( |
| f" β {len(result['updated'])} updated: {', '.join(result['updated'])}" |
| ) |
| if result.get("user_modified"): |
| print(f" ~ {len(result['user_modified'])} user-modified (kept)") |
| if result.get("cleaned"): |
| print(f" β {len(result['cleaned'])} removed from manifest") |
| if not result["copied"] and not result.get("updated"): |
| print(" β Skills are up to date") |
| except Exception as e: |
| logger.debug("Skills sync during update failed: %s", e) |
|
|
| |
| try: |
| from hermes_cli.profiles import ( |
| list_profiles, |
| get_active_profile_name, |
| seed_profile_skills, |
| ) |
|
|
| active = get_active_profile_name() |
| other_profiles = [p for p in list_profiles() if p.name != active] |
| if other_profiles: |
| print() |
| print("β Syncing bundled skills to other profiles...") |
| for p in other_profiles: |
| try: |
| r = seed_profile_skills(p.path, quiet=True) |
| if r: |
| copied = len(r.get("copied", [])) |
| updated = len(r.get("updated", [])) |
| modified = len(r.get("user_modified", [])) |
| parts = [] |
| if copied: |
| parts.append(f"+{copied} new") |
| if updated: |
| parts.append(f"β{updated} updated") |
| if modified: |
| parts.append(f"~{modified} user-modified") |
| status = ", ".join(parts) if parts else "up to date" |
| else: |
| status = "sync failed" |
| print(f" {p.name}: {status}") |
| except Exception as pe: |
| print(f" {p.name}: error ({pe})") |
| except Exception: |
| pass |
|
|
| |
| try: |
| from plugins.memory.honcho.cli import sync_honcho_profiles_quiet |
|
|
| synced = sync_honcho_profiles_quiet() |
| if synced: |
| print(f"\n-> Honcho: synced {synced} profile(s)") |
| except Exception: |
| pass |
|
|
| |
| print() |
| print("β Checking configuration for new options...") |
|
|
| from hermes_cli.config import ( |
| get_missing_env_vars, |
| get_missing_config_fields, |
| check_config_version, |
| migrate_config, |
| ) |
|
|
| missing_env = get_missing_env_vars(required_only=True) |
| missing_config = get_missing_config_fields() |
| current_ver, latest_ver = check_config_version() |
|
|
| needs_migration = missing_env or missing_config or current_ver < latest_ver |
|
|
| if needs_migration: |
| print() |
| if missing_env: |
| print( |
| f" β οΈ {len(missing_env)} new required setting(s) need configuration" |
| ) |
| if missing_config: |
| print(f" βΉοΈ {len(missing_config)} new config option(s) available") |
|
|
| print() |
| if gateway_mode: |
| response = ( |
| _gateway_prompt( |
| "Would you like to configure new options now? [Y/n]", "n" |
| ) |
| .strip() |
| .lower() |
| ) |
| elif not (sys.stdin.isatty() and sys.stdout.isatty()): |
| print(" βΉ Non-interactive session β skipping config migration prompt.") |
| print( |
| " Run 'hermes config migrate' later to apply any new config/env options." |
| ) |
| response = "n" |
| else: |
| try: |
| response = ( |
| input("Would you like to configure them now? [Y/n]: ") |
| .strip() |
| .lower() |
| ) |
| except EOFError: |
| response = "n" |
|
|
| if response in ("", "y", "yes"): |
| print() |
| |
| |
| results = migrate_config(interactive=not gateway_mode, quiet=False) |
|
|
| if results["env_added"] or results["config_added"]: |
| print() |
| print("β Configuration updated!") |
| if gateway_mode and missing_env: |
| print(" βΉ API keys require manual entry: hermes config migrate") |
| else: |
| print() |
| print("Skipped. Run 'hermes config migrate' later to configure.") |
| else: |
| print(" β Configuration is up to date") |
|
|
| print() |
| print("β Update complete!") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if gateway_mode: |
| _exit_code_path = get_hermes_home() / ".update_exit_code" |
| try: |
| _exit_code_path.write_text("0") |
| except OSError: |
| pass |
|
|
| |
| |
| |
| try: |
| from hermes_cli.gateway import ( |
| is_macos, |
| supports_systemd_services, |
| _ensure_user_systemd_env, |
| find_gateway_pids, |
| _get_service_pids, |
| _graceful_restart_via_sigusr1, |
| ) |
| import signal as _signal |
|
|
| |
| |
| |
| |
| |
| |
| try: |
| from hermes_constants import ( |
| DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT as _DEFAULT_DRAIN, |
| ) |
| except Exception: |
| _DEFAULT_DRAIN = 60.0 |
| _cfg_drain = None |
| try: |
| from hermes_cli.config import load_config |
| _cfg_agent = (load_config().get("agent") or {}) |
| _cfg_drain = _cfg_agent.get("restart_drain_timeout") |
| except Exception: |
| pass |
| try: |
| _drain_budget = float(_cfg_drain) if _cfg_drain is not None else float(_DEFAULT_DRAIN) |
| except (TypeError, ValueError): |
| _drain_budget = float(_DEFAULT_DRAIN) |
| |
| |
| _drain_budget = max(_drain_budget, 30.0) + 15.0 |
|
|
| restarted_services = [] |
| killed_pids = set() |
|
|
| |
| |
| if supports_systemd_services(): |
| try: |
| _ensure_user_systemd_env() |
| except Exception: |
| pass |
|
|
| for scope, scope_cmd in [ |
| ("user", ["systemctl", "--user"]), |
| ("system", ["systemctl"]), |
| ]: |
| try: |
| result = subprocess.run( |
| scope_cmd |
| + [ |
| "list-units", |
| "hermes-gateway*", |
| "--plain", |
| "--no-legend", |
| "--no-pager", |
| ], |
| capture_output=True, |
| text=True, |
| timeout=10, |
| ) |
| for line in result.stdout.strip().splitlines(): |
| parts = line.split() |
| if not parts: |
| continue |
| unit = parts[ |
| 0 |
| ] |
| if not unit.endswith(".service"): |
| continue |
| svc_name = unit.removesuffix(".service") |
| |
| check = subprocess.run( |
| scope_cmd + ["is-active", svc_name], |
| capture_output=True, |
| text=True, |
| timeout=5, |
| ) |
| if check.stdout.strip() != "active": |
| continue |
|
|
| |
| |
| |
| |
| |
| |
| _main_pid = 0 |
| try: |
| _show = subprocess.run( |
| scope_cmd + [ |
| "show", svc_name, |
| "--property=MainPID", "--value", |
| ], |
| capture_output=True, text=True, timeout=5, |
| ) |
| _main_pid = int((_show.stdout or "").strip() or 0) |
| except (ValueError, subprocess.TimeoutExpired, FileNotFoundError): |
| _main_pid = 0 |
|
|
| _graceful_ok = False |
| if _main_pid > 0: |
| print( |
| f" β {svc_name}: draining (up to {int(_drain_budget)}s)..." |
| ) |
| _graceful_ok = _graceful_restart_via_sigusr1( |
| _main_pid, drain_timeout=_drain_budget, |
| ) |
|
|
| if _graceful_ok: |
| |
| |
| |
| _time.sleep(3) |
| verify = subprocess.run( |
| scope_cmd + ["is-active", svc_name], |
| capture_output=True, text=True, timeout=5, |
| ) |
| if verify.stdout.strip() == "active": |
| restarted_services.append(svc_name) |
| continue |
| |
| |
| |
| |
| print( |
| f" β {svc_name} drained but didn't relaunch β forcing restart" |
| ) |
|
|
| |
| |
| |
| |
| |
| restart = subprocess.run( |
| scope_cmd + ["restart", svc_name], |
| capture_output=True, |
| text=True, |
| timeout=15, |
| ) |
| if restart.returncode == 0: |
| |
| |
| |
| _time.sleep(3) |
| verify = subprocess.run( |
| scope_cmd + ["is-active", svc_name], |
| capture_output=True, |
| text=True, |
| timeout=5, |
| ) |
| if verify.stdout.strip() == "active": |
| restarted_services.append(svc_name) |
| else: |
| |
| |
| |
| print( |
| f" β {svc_name} died after restart, retrying..." |
| ) |
| retry = subprocess.run( |
| scope_cmd + ["restart", svc_name], |
| capture_output=True, |
| text=True, |
| timeout=15, |
| ) |
| _time.sleep(3) |
| verify2 = subprocess.run( |
| scope_cmd + ["is-active", svc_name], |
| capture_output=True, |
| text=True, |
| timeout=5, |
| ) |
| if verify2.stdout.strip() == "active": |
| restarted_services.append(svc_name) |
| print(f" β {svc_name} recovered on retry") |
| else: |
| print( |
| f" β {svc_name} failed to stay running after restart.\n" |
| f" Check logs: journalctl --user -u {svc_name} --since '2 min ago'\n" |
| f" Restart manually: systemctl {'--user ' if scope == 'user' else ''}restart {svc_name}" |
| ) |
| else: |
| print( |
| f" β Failed to restart {svc_name}: {restart.stderr.strip()}" |
| ) |
| except (FileNotFoundError, subprocess.TimeoutExpired): |
| pass |
|
|
| |
| if is_macos(): |
| try: |
| from hermes_cli.gateway import ( |
| launchd_restart, |
| get_launchd_label, |
| get_launchd_plist_path, |
| ) |
|
|
| plist_path = get_launchd_plist_path() |
| if plist_path.exists(): |
| check = subprocess.run( |
| ["launchctl", "list", get_launchd_label()], |
| capture_output=True, |
| text=True, |
| timeout=5, |
| ) |
| if check.returncode == 0: |
| try: |
| launchd_restart() |
| restarted_services.append(get_launchd_label()) |
| except subprocess.CalledProcessError as e: |
| stderr = (getattr(e, "stderr", "") or "").strip() |
| print(f" β Gateway restart failed: {stderr}") |
| except (FileNotFoundError, subprocess.TimeoutExpired, ImportError): |
| pass |
|
|
| |
| |
| |
| |
| service_pids = _get_service_pids() |
| manual_pids = find_gateway_pids( |
| exclude_pids=service_pids, all_profiles=True |
| ) |
| for pid in manual_pids: |
| try: |
| os.kill(pid, _signal.SIGTERM) |
| killed_pids.add(pid) |
| except (ProcessLookupError, PermissionError): |
| pass |
|
|
| if restarted_services or killed_pids: |
| print() |
| for svc in restarted_services: |
| print(f" β Restarted {svc}") |
| if killed_pids: |
| print(f" β Stopped {len(killed_pids)} manual gateway process(es)") |
| print(" Restart manually: hermes gateway run") |
| |
| if len(killed_pids) > 1: |
| print( |
| " (or: hermes -p <profile> gateway run for each profile)" |
| ) |
|
|
| if not restarted_services and not killed_pids: |
| |
| pass |
|
|
| except Exception as e: |
| logger.debug("Gateway restart during update failed: %s", e) |
|
|
| |
| |
| |
| |
| |
| try: |
| from hermes_cli.gateway import ( |
| has_legacy_hermes_units, |
| _find_legacy_hermes_units, |
| supports_systemd_services, |
| ) |
|
|
| if supports_systemd_services() and has_legacy_hermes_units(): |
| print() |
| print("β Legacy Hermes gateway unit(s) detected:") |
| for name, path, is_sys in _find_legacy_hermes_units(): |
| scope = "system" if is_sys else "user" |
| print(f" {path} ({scope} scope)") |
| print() |
| print(" These pre-rename units (hermes.service) fight the current") |
| print(" hermes-gateway.service for the bot token and cause SIGTERM") |
| print(" flap loops. Remove them with:") |
| print() |
| print(" hermes gateway migrate-legacy") |
| print() |
| print(" (add `sudo` if any are in system scope)") |
| except Exception as e: |
| logger.debug("Legacy unit check during update failed: %s", e) |
|
|
| print() |
| print("Tip: You can now select a provider and model:") |
| print(" hermes model # Select provider and model") |
|
|
| except subprocess.CalledProcessError as e: |
| if sys.platform == "win32": |
| print(f"β Git update failed: {e}") |
| print("β Falling back to ZIP download...") |
| print() |
| _update_via_zip(args) |
| else: |
| print(f"β Update failed: {e}") |
| sys.exit(1) |
|
|
|
|
| def _coalesce_session_name_args(argv: list) -> list: |
| """Join unquoted multi-word session names after -c/--continue and -r/--resume. |
| |
| When a user types ``hermes -c Pokemon Agent Dev`` without quoting the |
| session name, argparse sees three separate tokens. This function merges |
| them into a single argument so argparse receives |
| ``['-c', 'Pokemon Agent Dev']`` instead. |
| |
| Tokens are collected after the flag until we hit another flag (``-*``) |
| or a known top-level subcommand. |
| """ |
| _SUBCOMMANDS = { |
| "chat", |
| "model", |
| "gateway", |
| "setup", |
| "whatsapp", |
| "login", |
| "logout", |
| "auth", |
| "status", |
| "cron", |
| "doctor", |
| "config", |
| "pairing", |
| "skills", |
| "tools", |
| "mcp", |
| "sessions", |
| "insights", |
| "version", |
| "update", |
| "uninstall", |
| "profile", |
| "dashboard", |
| "honcho", |
| "claw", |
| "plugins", |
| "acp", |
| "webhook", |
| "memory", |
| "dump", |
| "debug", |
| "backup", |
| "import", |
| "completion", |
| "logs", |
| } |
| _SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"} |
|
|
| result = [] |
| i = 0 |
| while i < len(argv): |
| token = argv[i] |
| if token in _SESSION_FLAGS: |
| result.append(token) |
| i += 1 |
| |
| parts: list = [] |
| while ( |
| i < len(argv) |
| and not argv[i].startswith("-") |
| and argv[i] not in _SUBCOMMANDS |
| ): |
| parts.append(argv[i]) |
| i += 1 |
| if parts: |
| result.append(" ".join(parts)) |
| else: |
| result.append(token) |
| i += 1 |
| return result |
|
|
|
|
| def cmd_profile(args): |
| """Profile management β create, delete, list, switch, alias.""" |
| from hermes_cli.profiles import ( |
| list_profiles, |
| create_profile, |
| delete_profile, |
| seed_profile_skills, |
| set_active_profile, |
| get_active_profile_name, |
| check_alias_collision, |
| create_wrapper_script, |
| remove_wrapper_script, |
| _is_wrapper_dir_in_path, |
| _get_wrapper_dir, |
| ) |
| from hermes_constants import display_hermes_home |
|
|
| action = getattr(args, "profile_action", None) |
|
|
| if action is None: |
| |
| profile_name = get_active_profile_name() |
| dhh = display_hermes_home() |
| print(f"\nActive profile: {profile_name}") |
| print(f"Path: {dhh}") |
|
|
| profiles = list_profiles() |
| for p in profiles: |
| if p.name == profile_name or (profile_name == "default" and p.is_default): |
| if p.model: |
| print( |
| f"Model: {p.model}" |
| + (f" ({p.provider})" if p.provider else "") |
| ) |
| print( |
| f"Gateway: {'running' if p.gateway_running else 'stopped'}" |
| ) |
| print(f"Skills: {p.skill_count} installed") |
| if p.alias_path: |
| print(f"Alias: {p.name} β hermes -p {p.name}") |
| break |
| print() |
| return |
|
|
| if action == "list": |
| profiles = list_profiles() |
| active = get_active_profile_name() |
|
|
| if not profiles: |
| print("No profiles found.") |
| return |
|
|
| |
| print(f"\n {'Profile':<16} {'Model':<28} {'Gateway':<12} {'Alias'}") |
| print(f" {'β' * 15} {'β' * 27} {'β' * 11} {'β' * 12}") |
|
|
| for p in profiles: |
| marker = ( |
| " β" |
| if (p.name == active or (active == "default" and p.is_default)) |
| else " " |
| ) |
| name = p.name |
| model = (p.model or "β")[:26] |
| gw = "running" if p.gateway_running else "stopped" |
| alias = p.name if p.alias_path else "β" |
| if p.is_default: |
| alias = "β" |
| print(f"{marker}{name:<15} {model:<28} {gw:<12} {alias}") |
| print() |
|
|
| elif action == "use": |
| name = args.profile_name |
| try: |
| set_active_profile(name) |
| if name == "default": |
| print(f"Switched to: default (~/.hermes)") |
| else: |
| print(f"Switched to: {name}") |
| except (ValueError, FileNotFoundError) as e: |
| print(f"Error: {e}") |
| sys.exit(1) |
|
|
| elif action == "create": |
| name = args.profile_name |
| clone = getattr(args, "clone", False) |
| clone_all = getattr(args, "clone_all", False) |
| no_alias = getattr(args, "no_alias", False) |
|
|
| try: |
| clone_from = getattr(args, "clone_from", None) |
|
|
| profile_dir = create_profile( |
| name=name, |
| clone_from=clone_from, |
| clone_all=clone_all, |
| clone_config=clone, |
| no_alias=no_alias, |
| ) |
| print(f"\nProfile '{name}' created at {profile_dir}") |
|
|
| if clone or clone_all: |
| source_label = ( |
| getattr(args, "clone_from", None) or get_active_profile_name() |
| ) |
| if clone_all: |
| print(f"Full copy from {source_label}.") |
| else: |
| print(f"Cloned config, .env, SOUL.md from {source_label}.") |
|
|
| |
| if clone or clone_all: |
| try: |
| from plugins.memory.honcho.cli import clone_honcho_for_profile |
|
|
| if clone_honcho_for_profile(name): |
| print(f"Honcho config cloned (peer: {name})") |
| except Exception: |
| pass |
|
|
| |
| if not clone_all: |
| result = seed_profile_skills(profile_dir) |
| if result: |
| copied = len(result.get("copied", [])) |
| print(f"{copied} bundled skills synced.") |
| else: |
| print( |
| "β Skills could not be seeded. Run `{} update` to retry.".format( |
| name |
| ) |
| ) |
|
|
| |
| if not no_alias: |
| collision = check_alias_collision(name) |
| if collision: |
| print(f"\nβ Cannot create alias '{name}' β {collision}") |
| print( |
| f" Choose a custom alias: hermes profile alias {name} --name <custom>" |
| ) |
| print(f" Or access via flag: hermes -p {name} chat") |
| else: |
| wrapper_path = create_wrapper_script(name) |
| if wrapper_path: |
| print(f"Wrapper created: {wrapper_path}") |
| if not _is_wrapper_dir_in_path(): |
| print(f"\nβ {_get_wrapper_dir()} is not in your PATH.") |
| print( |
| f" Add to your shell config (~/.bashrc or ~/.zshrc):" |
| ) |
| print(f' export PATH="$HOME/.local/bin:$PATH"') |
|
|
| |
| try: |
| profile_dir_display = "~/" + str(profile_dir.relative_to(Path.home())) |
| except ValueError: |
| profile_dir_display = str(profile_dir) |
|
|
| |
| print(f"\nNext steps:") |
| print(f" {name} setup Configure API keys and model") |
| print(f" {name} chat Start chatting") |
| print(f" {name} gateway start Start the messaging gateway") |
| if clone or clone_all: |
| print(f"\n Edit {profile_dir_display}/.env for different API keys") |
| print(f" Edit {profile_dir_display}/SOUL.md for different personality") |
| else: |
| print( |
| f"\n β This profile has no API keys yet. Run '{name} setup' first," |
| ) |
| print(f" or it will inherit keys from your shell environment.") |
| print(f" Edit {profile_dir_display}/SOUL.md to customize personality") |
| print() |
|
|
| except (ValueError, FileExistsError, FileNotFoundError) as e: |
| print(f"Error: {e}") |
| sys.exit(1) |
|
|
| elif action == "delete": |
| name = args.profile_name |
| yes = getattr(args, "yes", False) |
| try: |
| delete_profile(name, yes=yes) |
| except (ValueError, FileNotFoundError) as e: |
| print(f"Error: {e}") |
| sys.exit(1) |
|
|
| elif action == "show": |
| name = args.profile_name |
| from hermes_cli.profiles import ( |
| get_profile_dir, |
| profile_exists, |
| _read_config_model, |
| _check_gateway_running, |
| _count_skills, |
| ) |
|
|
| if not profile_exists(name): |
| print(f"Error: Profile '{name}' does not exist.") |
| sys.exit(1) |
| profile_dir = get_profile_dir(name) |
| model, provider = _read_config_model(profile_dir) |
| gw = _check_gateway_running(profile_dir) |
| skills = _count_skills(profile_dir) |
| wrapper = _get_wrapper_dir() / name |
|
|
| print(f"\nProfile: {name}") |
| print(f"Path: {profile_dir}") |
| if model: |
| print(f"Model: {model}" + (f" ({provider})" if provider else "")) |
| print(f"Gateway: {'running' if gw else 'stopped'}") |
| print(f"Skills: {skills}") |
| print( |
| f".env: {'exists' if (profile_dir / '.env').exists() else 'not configured'}" |
| ) |
| print( |
| f"SOUL.md: {'exists' if (profile_dir / 'SOUL.md').exists() else 'not configured'}" |
| ) |
| if wrapper.exists(): |
| print(f"Alias: {wrapper}") |
| print() |
|
|
| elif action == "alias": |
| name = args.profile_name |
| remove = getattr(args, "remove", False) |
| custom_name = getattr(args, "alias_name", None) |
|
|
| from hermes_cli.profiles import profile_exists |
|
|
| if not profile_exists(name): |
| print(f"Error: Profile '{name}' does not exist.") |
| sys.exit(1) |
|
|
| alias_name = custom_name or name |
|
|
| if remove: |
| if remove_wrapper_script(alias_name): |
| print(f"β Removed alias '{alias_name}'") |
| else: |
| print(f"No alias '{alias_name}' found to remove.") |
| else: |
| collision = check_alias_collision(alias_name) |
| if collision: |
| print(f"Error: {collision}") |
| sys.exit(1) |
| wrapper_path = create_wrapper_script(alias_name) |
| if wrapper_path: |
| |
| if custom_name: |
| wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {name} "$@"\n') |
| print(f"β Alias created: {wrapper_path}") |
| if not _is_wrapper_dir_in_path(): |
| print(f"β {_get_wrapper_dir()} is not in your PATH.") |
|
|
| elif action == "rename": |
| from hermes_cli.profiles import rename_profile |
|
|
| try: |
| new_dir = rename_profile(args.old_name, args.new_name) |
| print(f"\nProfile renamed: {args.old_name} β {args.new_name}") |
| print(f"Path: {new_dir}\n") |
| except (ValueError, FileExistsError, FileNotFoundError) as e: |
| print(f"Error: {e}") |
| sys.exit(1) |
|
|
| elif action == "export": |
| from hermes_cli.profiles import export_profile |
|
|
| name = args.profile_name |
| output = args.output or f"{name}.tar.gz" |
| try: |
| result_path = export_profile(name, output) |
| print(f"β Exported '{name}' to {result_path}") |
| except (ValueError, FileNotFoundError) as e: |
| print(f"Error: {e}") |
| sys.exit(1) |
|
|
| elif action == "import": |
| from hermes_cli.profiles import import_profile |
|
|
| try: |
| profile_dir = import_profile( |
| args.archive, name=getattr(args, "import_name", None) |
| ) |
| name = profile_dir.name |
| print(f"β Imported profile '{name}' at {profile_dir}") |
|
|
| |
| collision = check_alias_collision(name) |
| if not collision: |
| wrapper_path = create_wrapper_script(name) |
| if wrapper_path: |
| print(f" Wrapper created: {wrapper_path}") |
| print() |
| except (ValueError, FileExistsError, FileNotFoundError) as e: |
| print(f"Error: {e}") |
| sys.exit(1) |
|
|
|
|
| def cmd_dashboard(args): |
| """Start the web UI server.""" |
| try: |
| import fastapi |
| import uvicorn |
| except ImportError: |
| print("Web UI dependencies not installed.") |
| print(f"Install them with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'") |
| sys.exit(1) |
|
|
| if "HERMES_WEB_DIST" not in os.environ: |
| if not _build_web_ui(PROJECT_ROOT / "web", fatal=True): |
| sys.exit(1) |
|
|
| from hermes_cli.web_server import start_server |
|
|
| start_server( |
| host=args.host, |
| port=args.port, |
| open_browser=not args.no_open, |
| allow_public=getattr(args, "insecure", False), |
| ) |
|
|
|
|
| def cmd_completion(args, parser=None): |
| """Print shell completion script.""" |
| from hermes_cli.completion import generate_bash, generate_zsh, generate_fish |
|
|
| shell = getattr(args, "shell", "bash") |
| if shell == "zsh": |
| print(generate_zsh(parser)) |
| elif shell == "fish": |
| print(generate_fish(parser)) |
| else: |
| print(generate_bash(parser)) |
|
|
|
|
| def cmd_logs(args): |
| """View and filter Hermes log files.""" |
| from hermes_cli.logs import tail_log, list_logs |
|
|
| log_name = getattr(args, "log_name", "agent") or "agent" |
|
|
| if log_name == "list": |
| list_logs() |
| return |
|
|
| tail_log( |
| log_name, |
| num_lines=getattr(args, "lines", 50), |
| follow=getattr(args, "follow", False), |
| level=getattr(args, "level", None), |
| session=getattr(args, "session", None), |
| since=getattr(args, "since", None), |
| component=getattr(args, "component", None), |
| ) |
|
|
|
|
| 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 -c Resume the most recent session |
| hermes -c "my project" Resume a session by name (latest in lineage) |
| hermes --resume <session_id> Resume a specific session by ID |
| hermes setup Run setup wizard |
| hermes logout Clear stored authentication |
| hermes auth add <provider> Add a pooled credential |
| hermes auth list List pooled credentials |
| hermes auth remove <p> <t> Remove pooled credential by index, id, or label |
| hermes auth reset <provider> Clear exhaustion status for a provider |
| hermes model Select default model |
| hermes config View configuration |
| hermes config edit Edit config in $EDITOR |
| hermes config set model gpt-4 Set a config value |
| hermes gateway Run messaging gateway |
| hermes -s hermes-agent-dev,github-auth |
| hermes -w Start in isolated git worktree |
| hermes gateway install Install gateway background service |
| hermes sessions list List past sessions |
| hermes sessions browse Interactive session picker |
| hermes sessions rename ID T Rename/title a session |
| hermes logs View agent.log (last 50 lines) |
| hermes logs -f Follow agent.log in real time |
| hermes logs errors View errors.log |
| hermes logs --since 1h Lines from the last hour |
| hermes debug share Upload debug report for support |
| hermes update Update to latest version |
| |
| For more help on a command: |
| hermes <command> --help |
| """, |
| ) |
|
|
| parser.add_argument( |
| "--version", "-V", action="store_true", help="Show version and exit" |
| ) |
| parser.add_argument( |
| "--resume", |
| "-r", |
| metavar="SESSION", |
| default=None, |
| help="Resume a previous session by ID or title", |
| ) |
| parser.add_argument( |
| "--continue", |
| "-c", |
| dest="continue_last", |
| nargs="?", |
| const=True, |
| default=None, |
| metavar="SESSION_NAME", |
| help="Resume a session by name, or the most recent if no name given", |
| ) |
| parser.add_argument( |
| "--worktree", |
| "-w", |
| action="store_true", |
| default=False, |
| help="Run in an isolated git worktree (for parallel agents)", |
| ) |
| parser.add_argument( |
| "--accept-hooks", |
| action="store_true", |
| default=False, |
| help=( |
| "Auto-approve any unseen shell hooks declared in config.yaml " |
| "without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or " |
| "hooks_auto_accept: true in config.yaml. Use on CI / headless " |
| "runs that can't prompt." |
| ), |
| ) |
| parser.add_argument( |
| "--skills", |
| "-s", |
| action="append", |
| default=None, |
| help="Preload one or more skills for the session (repeat flag or comma-separate)", |
| ) |
| parser.add_argument( |
| "--yolo", |
| action="store_true", |
| default=False, |
| help="Bypass all dangerous command approval prompts (use at your own risk)", |
| ) |
| parser.add_argument( |
| "--pass-session-id", |
| action="store_true", |
| default=False, |
| help="Include the session ID in the agent's system prompt", |
| ) |
| parser.add_argument( |
| "--ignore-user-config", |
| action="store_true", |
| default=False, |
| help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)", |
| ) |
| parser.add_argument( |
| "--ignore-rules", |
| action="store_true", |
| default=False, |
| help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills", |
| ) |
| parser.add_argument( |
| "--tui", |
| action="store_true", |
| default=False, |
| help="Launch the modern TUI instead of the classic REPL", |
| ) |
| parser.add_argument( |
| "--dev", |
| dest="tui_dev", |
| action="store_true", |
| default=False, |
| help="With --tui: run TypeScript sources via tsx (skip dist build)", |
| ) |
|
|
| subparsers = parser.add_subparsers(dest="command", help="Command to run") |
|
|
| |
| |
| |
| 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( |
| "--image", help="Optional local image path to attach to a single query" |
| ) |
| 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( |
| "-s", |
| "--skills", |
| action="append", |
| default=argparse.SUPPRESS, |
| help="Preload one or more skills for the session (repeat flag or comma-separate)", |
| ) |
| chat_parser.add_argument( |
| "--provider", |
| choices=[ |
| "auto", |
| "openrouter", |
| "nous", |
| "openai-codex", |
| "copilot-acp", |
| "copilot", |
| "anthropic", |
| "gemini", |
| "xai", |
| "ollama-cloud", |
| "huggingface", |
| "zai", |
| "kimi-coding", |
| "kimi-coding-cn", |
| "stepfun", |
| "minimax", |
| "minimax-cn", |
| "kilocode", |
| "xiaomi", |
| "arcee", |
| "nvidia", |
| ], |
| default=None, |
| help="Inference provider (default: auto)", |
| ) |
| chat_parser.add_argument( |
| "-v", "--verbose", action="store_true", help="Verbose output" |
| ) |
| chat_parser.add_argument( |
| "-Q", |
| "--quiet", |
| action="store_true", |
| help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.", |
| ) |
| chat_parser.add_argument( |
| "--resume", |
| "-r", |
| metavar="SESSION_ID", |
| default=argparse.SUPPRESS, |
| help="Resume a previous session by ID (shown on exit)", |
| ) |
| chat_parser.add_argument( |
| "--continue", |
| "-c", |
| dest="continue_last", |
| nargs="?", |
| const=True, |
| default=argparse.SUPPRESS, |
| metavar="SESSION_NAME", |
| help="Resume a session by name, or the most recent if no name given", |
| ) |
| chat_parser.add_argument( |
| "--worktree", |
| "-w", |
| action="store_true", |
| default=argparse.SUPPRESS, |
| help="Run in an isolated git worktree (for parallel agents on the same repo)", |
| ) |
| chat_parser.add_argument( |
| "--accept-hooks", |
| action="store_true", |
| default=argparse.SUPPRESS, |
| help=( |
| "Auto-approve any unseen shell hooks declared in config.yaml " |
| "without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and " |
| "hooks_auto_accept: in config.yaml)." |
| ), |
| ) |
| chat_parser.add_argument( |
| "--checkpoints", |
| action="store_true", |
| default=False, |
| help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)", |
| ) |
| chat_parser.add_argument( |
| "--max-turns", |
| type=int, |
| default=None, |
| metavar="N", |
| help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)", |
| ) |
| chat_parser.add_argument( |
| "--yolo", |
| action="store_true", |
| default=argparse.SUPPRESS, |
| help="Bypass all dangerous command approval prompts (use at your own risk)", |
| ) |
| chat_parser.add_argument( |
| "--pass-session-id", |
| action="store_true", |
| default=argparse.SUPPRESS, |
| help="Include the session ID in the agent's system prompt", |
| ) |
| chat_parser.add_argument( |
| "--ignore-user-config", |
| action="store_true", |
| default=argparse.SUPPRESS, |
| help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.", |
| ) |
| chat_parser.add_argument( |
| "--ignore-rules", |
| action="store_true", |
| default=argparse.SUPPRESS, |
| help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.", |
| ) |
| chat_parser.add_argument( |
| "--source", |
| default=None, |
| help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.", |
| ) |
| chat_parser.add_argument( |
| "--tui", |
| action="store_true", |
| default=False, |
| help="Launch the modern TUI instead of the classic REPL", |
| ) |
| chat_parser.add_argument( |
| "--dev", |
| dest="tui_dev", |
| action="store_true", |
| default=False, |
| help="With --tui: run TypeScript sources via tsx (skip dist build)", |
| ) |
| chat_parser.set_defaults(func=cmd_chat) |
|
|
| |
| |
| |
| model_parser = subparsers.add_parser( |
| "model", |
| help="Select default model and provider", |
| description="Interactively select your inference provider and default model", |
| ) |
| model_parser.add_argument( |
| "--portal-url", |
| help="Portal base URL for Nous login (default: production portal)", |
| ) |
| model_parser.add_argument( |
| "--inference-url", |
| help="Inference API base URL for Nous login (default: production inference API)", |
| ) |
| model_parser.add_argument( |
| "--client-id", |
| default=None, |
| help="OAuth client id to use for Nous login (default: hermes-cli)", |
| ) |
| model_parser.add_argument( |
| "--scope", default=None, help="OAuth scope to request for Nous login" |
| ) |
| model_parser.add_argument( |
| "--no-browser", |
| action="store_true", |
| help="Do not attempt to open the browser automatically during Nous login", |
| ) |
| model_parser.add_argument( |
| "--timeout", |
| type=float, |
| default=15.0, |
| help="HTTP request timeout in seconds for Nous login (default: 15)", |
| ) |
| model_parser.add_argument( |
| "--ca-bundle", help="Path to CA bundle PEM file for Nous TLS verification" |
| ) |
| model_parser.add_argument( |
| "--insecure", |
| action="store_true", |
| help="Disable TLS verification for Nous login (testing only)", |
| ) |
| model_parser.set_defaults(func=cmd_model) |
|
|
| |
| |
| |
| 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 = gateway_subparsers.add_parser( |
| "run", help="Run gateway in foreground (recommended for WSL, Docker, Termux)" |
| ) |
| gateway_run.add_argument( |
| "-v", |
| "--verbose", |
| action="count", |
| default=0, |
| help="Increase stderr log verbosity (-v=INFO, -vv=DEBUG)", |
| ) |
| gateway_run.add_argument( |
| "-q", "--quiet", action="store_true", help="Suppress all stderr log output" |
| ) |
| gateway_run.add_argument( |
| "--replace", |
| action="store_true", |
| help="Replace any existing gateway instance (useful for systemd)", |
| ) |
| _add_accept_hooks_flag(gateway_run) |
| _add_accept_hooks_flag(gateway_parser) |
|
|
| |
| gateway_start = gateway_subparsers.add_parser( |
| "start", help="Start the installed systemd/launchd background service" |
| ) |
| gateway_start.add_argument( |
| "--system", |
| action="store_true", |
| help="Target the Linux system-level gateway service", |
| ) |
| gateway_start.add_argument( |
| "--all", |
| action="store_true", |
| help="Kill ALL stale gateway processes across all profiles before starting", |
| ) |
|
|
| |
| gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service") |
| gateway_stop.add_argument( |
| "--system", |
| action="store_true", |
| help="Target the Linux system-level gateway service", |
| ) |
| gateway_stop.add_argument( |
| "--all", |
| action="store_true", |
| help="Stop ALL gateway processes across all profiles", |
| ) |
|
|
| |
| gateway_restart = gateway_subparsers.add_parser( |
| "restart", help="Restart gateway service" |
| ) |
| gateway_restart.add_argument( |
| "--system", |
| action="store_true", |
| help="Target the Linux system-level gateway service", |
| ) |
| gateway_restart.add_argument( |
| "--all", |
| action="store_true", |
| help="Kill ALL gateway processes across all profiles before restarting", |
| ) |
|
|
| |
| gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status") |
| gateway_status.add_argument("--deep", action="store_true", help="Deep status check") |
| gateway_status.add_argument( |
| "-l", |
| "--full", |
| action="store_true", |
| help="Show full, untruncated service/log output where supported", |
| ) |
| gateway_status.add_argument( |
| "--system", |
| action="store_true", |
| help="Target the Linux system-level gateway service", |
| ) |
|
|
| |
| gateway_install = gateway_subparsers.add_parser( |
| "install", help="Install gateway as a systemd/launchd background service" |
| ) |
| gateway_install.add_argument("--force", action="store_true", help="Force reinstall") |
| gateway_install.add_argument( |
| "--system", |
| action="store_true", |
| help="Install as a Linux system-level service (starts at boot)", |
| ) |
| gateway_install.add_argument( |
| "--run-as-user", |
| dest="run_as_user", |
| help="User account the Linux system service should run as", |
| ) |
|
|
| |
| gateway_uninstall = gateway_subparsers.add_parser( |
| "uninstall", help="Uninstall gateway service" |
| ) |
| gateway_uninstall.add_argument( |
| "--system", |
| action="store_true", |
| help="Target the Linux system-level gateway service", |
| ) |
|
|
| |
| gateway_subparsers.add_parser("setup", help="Configure messaging platforms") |
|
|
| |
| gateway_migrate_legacy = gateway_subparsers.add_parser( |
| "migrate-legacy", |
| help="Remove legacy hermes.service units from pre-rename installs", |
| description=( |
| "Stop, disable, and remove legacy Hermes gateway unit files " |
| "(e.g. hermes.service) left over from older installs. Profile " |
| "units (hermes-gateway-<profile>.service) and unrelated " |
| "third-party services are never touched." |
| ), |
| ) |
| gateway_migrate_legacy.add_argument( |
| "--dry-run", |
| dest="dry_run", |
| action="store_true", |
| help="List what would be removed without doing it", |
| ) |
| gateway_migrate_legacy.add_argument( |
| "-y", |
| "--yes", |
| dest="yes", |
| action="store_true", |
| help="Skip the confirmation prompt", |
| ) |
|
|
| gateway_parser.set_defaults(func=cmd_gateway) |
|
|
| |
| |
| |
| setup_parser = subparsers.add_parser( |
| "setup", |
| help="Interactive setup wizard", |
| description="Configure Hermes Agent with an interactive wizard. " |
| "Run a specific section: hermes setup model|tts|terminal|gateway|tools|agent", |
| ) |
| setup_parser.add_argument( |
| "section", |
| nargs="?", |
| choices=["model", "tts", "terminal", "gateway", "tools", "agent"], |
| default=None, |
| help="Run a specific setup section instead of the full wizard", |
| ) |
| setup_parser.add_argument( |
| "--non-interactive", |
| action="store_true", |
| help="Non-interactive mode (use defaults/env vars)", |
| ) |
| setup_parser.add_argument( |
| "--reset", action="store_true", help="Reset configuration to defaults" |
| ) |
| setup_parser.set_defaults(func=cmd_setup) |
|
|
| |
| |
| |
| whatsapp_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_parser = subparsers.add_parser( |
| "login", |
| help="Authenticate with an inference provider", |
| description="Run OAuth device authorization flow for Hermes CLI", |
| ) |
| login_parser.add_argument( |
| "--provider", |
| choices=["nous", "openai-codex"], |
| default=None, |
| help="Provider to authenticate with (default: nous)", |
| ) |
| login_parser.add_argument( |
| "--portal-url", help="Portal base URL (default: production portal)" |
| ) |
| login_parser.add_argument( |
| "--inference-url", |
| help="Inference API base URL (default: production inference API)", |
| ) |
| login_parser.add_argument( |
| "--client-id", default=None, help="OAuth client id to use (default: hermes-cli)" |
| ) |
| login_parser.add_argument("--scope", default=None, help="OAuth scope to request") |
| login_parser.add_argument( |
| "--no-browser", |
| action="store_true", |
| help="Do not attempt to open the browser automatically", |
| ) |
| login_parser.add_argument( |
| "--timeout", |
| type=float, |
| default=15.0, |
| help="HTTP request timeout in seconds (default: 15)", |
| ) |
| login_parser.add_argument( |
| "--ca-bundle", help="Path to CA bundle PEM file for TLS verification" |
| ) |
| login_parser.add_argument( |
| "--insecure", |
| action="store_true", |
| help="Disable TLS verification (testing only)", |
| ) |
| login_parser.set_defaults(func=cmd_login) |
|
|
| |
| |
| |
| logout_parser = subparsers.add_parser( |
| "logout", |
| help="Clear authentication for an inference provider", |
| description="Remove stored credentials and reset provider config", |
| ) |
| logout_parser.add_argument( |
| "--provider", |
| choices=["nous", "openai-codex"], |
| default=None, |
| help="Provider to log out from (default: active provider)", |
| ) |
| logout_parser.set_defaults(func=cmd_logout) |
|
|
| auth_parser = subparsers.add_parser( |
| "auth", |
| help="Manage pooled provider credentials", |
| ) |
| auth_subparsers = auth_parser.add_subparsers(dest="auth_action") |
| auth_add = auth_subparsers.add_parser("add", help="Add a pooled credential") |
| auth_add.add_argument( |
| "provider", |
| help="Provider id (for example: anthropic, openai-codex, openrouter)", |
| ) |
| auth_add.add_argument( |
| "--type", |
| dest="auth_type", |
| choices=["oauth", "api-key", "api_key"], |
| help="Credential type to add", |
| ) |
| auth_add.add_argument("--label", help="Optional display label") |
| auth_add.add_argument( |
| "--api-key", help="API key value (otherwise prompted securely)" |
| ) |
| auth_add.add_argument("--portal-url", help="Nous portal base URL") |
| auth_add.add_argument("--inference-url", help="Nous inference base URL") |
| auth_add.add_argument("--client-id", help="OAuth client id") |
| auth_add.add_argument("--scope", help="OAuth scope override") |
| auth_add.add_argument( |
| "--no-browser", |
| action="store_true", |
| help="Do not auto-open a browser for OAuth login", |
| ) |
| auth_add.add_argument( |
| "--timeout", type=float, help="OAuth/network timeout in seconds" |
| ) |
| auth_add.add_argument( |
| "--insecure", |
| action="store_true", |
| help="Disable TLS verification for OAuth login", |
| ) |
| auth_add.add_argument("--ca-bundle", help="Custom CA bundle for OAuth login") |
| auth_list = auth_subparsers.add_parser("list", help="List pooled credentials") |
| auth_list.add_argument("provider", nargs="?", help="Optional provider filter") |
| auth_remove = auth_subparsers.add_parser( |
| "remove", help="Remove a pooled credential by index, id, or label" |
| ) |
| auth_remove.add_argument("provider", help="Provider id") |
| auth_remove.add_argument( |
| "target", help="Credential index, entry id, or exact label" |
| ) |
| auth_reset = auth_subparsers.add_parser( |
| "reset", help="Clear exhaustion status for all credentials for a provider" |
| ) |
| auth_reset.add_argument("provider", help="Provider id") |
| auth_parser.set_defaults(func=cmd_auth) |
|
|
| |
| |
| |
| 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_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_subparsers.add_parser("list", help="List scheduled jobs") |
| cron_list.add_argument("--all", action="store_true", help="Include disabled jobs") |
|
|
| |
| cron_create = cron_subparsers.add_parser( |
| "create", aliases=["add"], help="Create a scheduled job" |
| ) |
| cron_create.add_argument( |
| "schedule", help="Schedule like '30m', 'every 2h', or '0 9 * * *'" |
| ) |
| cron_create.add_argument( |
| "prompt", nargs="?", help="Optional self-contained prompt or task instruction" |
| ) |
| cron_create.add_argument("--name", help="Optional human-friendly job name") |
| cron_create.add_argument( |
| "--deliver", |
| help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id", |
| ) |
| cron_create.add_argument("--repeat", type=int, help="Optional repeat count") |
| cron_create.add_argument( |
| "--skill", |
| dest="skills", |
| action="append", |
| help="Attach a skill. Repeat to add multiple skills.", |
| ) |
| cron_create.add_argument( |
| "--script", |
| help="Path to a Python script whose stdout is injected into the prompt each run", |
| ) |
|
|
| |
| cron_edit = cron_subparsers.add_parser( |
| "edit", help="Edit an existing scheduled job" |
| ) |
| cron_edit.add_argument("job_id", help="Job ID to edit") |
| cron_edit.add_argument("--schedule", help="New schedule") |
| cron_edit.add_argument("--prompt", help="New prompt/task instruction") |
| cron_edit.add_argument("--name", help="New job name") |
| cron_edit.add_argument("--deliver", help="New delivery target") |
| cron_edit.add_argument("--repeat", type=int, help="New repeat count") |
| cron_edit.add_argument( |
| "--skill", |
| dest="skills", |
| action="append", |
| help="Replace the job's skills with this set. Repeat to attach multiple skills.", |
| ) |
| cron_edit.add_argument( |
| "--add-skill", |
| dest="add_skills", |
| action="append", |
| help="Append a skill without replacing the existing list. Repeatable.", |
| ) |
| cron_edit.add_argument( |
| "--remove-skill", |
| dest="remove_skills", |
| action="append", |
| help="Remove a specific attached skill. Repeatable.", |
| ) |
| cron_edit.add_argument( |
| "--clear-skills", |
| action="store_true", |
| help="Remove all attached skills from the job", |
| ) |
| cron_edit.add_argument( |
| "--script", |
| help="Path to a Python script whose stdout is injected into the prompt each run. Pass empty string to clear.", |
| ) |
|
|
| |
| cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job") |
| cron_pause.add_argument("job_id", help="Job ID to pause") |
|
|
| cron_resume = cron_subparsers.add_parser("resume", help="Resume a paused job") |
| cron_resume.add_argument("job_id", help="Job ID to resume") |
|
|
| cron_run = cron_subparsers.add_parser( |
| "run", help="Run a job on the next scheduler tick" |
| ) |
| cron_run.add_argument("job_id", help="Job ID to trigger") |
| _add_accept_hooks_flag(cron_run) |
|
|
| cron_remove = cron_subparsers.add_parser( |
| "remove", aliases=["rm", "delete"], help="Remove a scheduled job" |
| ) |
| cron_remove.add_argument("job_id", help="Job ID to remove") |
|
|
| |
| cron_subparsers.add_parser("status", help="Check if cron scheduler is running") |
|
|
| |
| cron_tick = cron_subparsers.add_parser("tick", help="Run due jobs once and exit") |
| _add_accept_hooks_flag(cron_tick) |
| _add_accept_hooks_flag(cron_parser) |
| cron_parser.set_defaults(func=cmd_cron) |
|
|
| |
| |
| |
| webhook_parser = subparsers.add_parser( |
| "webhook", |
| help="Manage dynamic webhook subscriptions", |
| description="Create, list, and remove webhook subscriptions for event-driven agent activation", |
| ) |
| webhook_subparsers = webhook_parser.add_subparsers(dest="webhook_action") |
|
|
| wh_sub = webhook_subparsers.add_parser( |
| "subscribe", aliases=["add"], help="Create a webhook subscription" |
| ) |
| wh_sub.add_argument("name", help="Route name (used in URL: /webhooks/<name>)") |
| wh_sub.add_argument( |
| "--prompt", default="", help="Prompt template with {dot.notation} payload refs" |
| ) |
| wh_sub.add_argument( |
| "--events", default="", help="Comma-separated event types to accept" |
| ) |
| wh_sub.add_argument("--description", default="", help="What this subscription does") |
| wh_sub.add_argument( |
| "--skills", default="", help="Comma-separated skill names to load" |
| ) |
| wh_sub.add_argument( |
| "--deliver", |
| default="log", |
| help="Delivery target: log, telegram, discord, slack, etc.", |
| ) |
| wh_sub.add_argument( |
| "--deliver-chat-id", |
| default="", |
| help="Target chat ID for cross-platform delivery", |
| ) |
| wh_sub.add_argument( |
| "--secret", default="", help="HMAC secret (auto-generated if omitted)" |
| ) |
| wh_sub.add_argument( |
| "--deliver-only", |
| action="store_true", |
| help="Skip the agent β deliver the rendered prompt directly as the " |
| "message. Zero LLM cost. Requires --deliver to be a real target " |
| "(not 'log').", |
| ) |
|
|
| webhook_subparsers.add_parser( |
| "list", aliases=["ls"], help="List all dynamic subscriptions" |
| ) |
|
|
| wh_rm = webhook_subparsers.add_parser( |
| "remove", aliases=["rm"], help="Remove a subscription" |
| ) |
| wh_rm.add_argument("name", help="Subscription name to remove") |
|
|
| wh_test = webhook_subparsers.add_parser( |
| "test", help="Send a test POST to a webhook route" |
| ) |
| wh_test.add_argument("name", help="Subscription name to test") |
| wh_test.add_argument( |
| "--payload", default="", help="JSON payload to send (default: test payload)" |
| ) |
|
|
| webhook_parser.set_defaults(func=cmd_webhook) |
|
|
| |
| |
| |
| hooks_parser = subparsers.add_parser( |
| "hooks", |
| help="Inspect and manage shell-script hooks", |
| description=( |
| "Inspect shell-script hooks declared in ~/.hermes/config.yaml, " |
| "test them against synthetic payloads, and manage the first-use " |
| "consent allowlist at ~/.hermes/shell-hooks-allowlist.json." |
| ), |
| ) |
| hooks_subparsers = hooks_parser.add_subparsers(dest="hooks_action") |
|
|
| hooks_subparsers.add_parser( |
| "list", aliases=["ls"], |
| help="List configured hooks with matcher, timeout, and consent status", |
| ) |
|
|
| _hk_test = hooks_subparsers.add_parser( |
| "test", |
| help="Fire every hook matching <event> against a synthetic payload", |
| ) |
| _hk_test.add_argument( |
| "event", |
| help="Hook event name (e.g. pre_tool_call, pre_llm_call, subagent_stop)", |
| ) |
| _hk_test.add_argument( |
| "--for-tool", dest="for_tool", default=None, |
| help=( |
| "Only fire hooks whose matcher matches this tool name " |
| "(used for pre_tool_call / post_tool_call)" |
| ), |
| ) |
| _hk_test.add_argument( |
| "--payload-file", dest="payload_file", default=None, |
| help=( |
| "Path to a JSON file whose contents are merged into the " |
| "synthetic payload before execution" |
| ), |
| ) |
|
|
| _hk_revoke = hooks_subparsers.add_parser( |
| "revoke", aliases=["remove", "rm"], |
| help="Remove a command's allowlist entries (takes effect on next restart)", |
| ) |
| _hk_revoke.add_argument( |
| "command", |
| help="The exact command string to revoke (as declared in config.yaml)", |
| ) |
|
|
| hooks_subparsers.add_parser( |
| "doctor", |
| help=( |
| "Check each configured hook: exec bit, allowlist, mtime drift, " |
| "JSON validity, and synthetic run timing" |
| ), |
| ) |
|
|
| hooks_parser.set_defaults(func=cmd_hooks) |
|
|
| |
| |
| |
| 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) |
|
|
| |
| |
| |
| dump_parser = subparsers.add_parser( |
| "dump", |
| help="Dump setup summary for support/debugging", |
| description="Output a compact, plain-text summary of your Hermes setup " |
| "that can be copy-pasted into Discord/GitHub for support context", |
| ) |
| dump_parser.add_argument( |
| "--show-keys", |
| action="store_true", |
| help="Show redacted API key prefixes (first/last 4 chars) instead of just set/not set", |
| ) |
| dump_parser.set_defaults(func=cmd_dump) |
|
|
| |
| |
| |
| debug_parser = subparsers.add_parser( |
| "debug", |
| help="Debug tools β upload logs and system info for support", |
| description="Debug utilities for Hermes Agent. Use 'hermes debug share' to " |
| "upload a debug report (system info + recent logs) to a paste " |
| "service and get a shareable URL.", |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| epilog="""\ |
| Examples: |
| hermes debug share Upload debug report and print URL |
| hermes debug share --lines 500 Include more log lines |
| hermes debug share --expire 30 Keep paste for 30 days |
| hermes debug share --local Print report locally (no upload) |
| hermes debug delete <url> Delete a previously uploaded paste |
| """, |
| ) |
| debug_sub = debug_parser.add_subparsers(dest="debug_command") |
| share_parser = debug_sub.add_parser( |
| "share", |
| help="Upload debug report to a paste service and print a shareable URL", |
| ) |
| share_parser.add_argument( |
| "--lines", |
| type=int, |
| default=200, |
| help="Number of log lines to include per log file (default: 200)", |
| ) |
| share_parser.add_argument( |
| "--expire", |
| type=int, |
| default=7, |
| help="Paste expiry in days (default: 7)", |
| ) |
| share_parser.add_argument( |
| "--local", |
| action="store_true", |
| help="Print the report locally instead of uploading", |
| ) |
| delete_parser = debug_sub.add_parser( |
| "delete", |
| help="Delete a paste uploaded by 'hermes debug share'", |
| ) |
| delete_parser.add_argument( |
| "urls", |
| nargs="*", |
| default=[], |
| help="One or more paste URLs to delete (e.g. https://paste.rs/abc123)", |
| ) |
| debug_parser.set_defaults(func=cmd_debug) |
|
|
| |
| |
| |
| backup_parser = subparsers.add_parser( |
| "backup", |
| help="Back up Hermes home directory to a zip file", |
| description="Create a zip archive of your entire Hermes configuration, " |
| "skills, sessions, and data (excludes the hermes-agent codebase). " |
| "Use --quick for a fast snapshot of just critical state files.", |
| ) |
| backup_parser.add_argument( |
| "-o", |
| "--output", |
| help="Output path for the zip file (default: ~/hermes-backup-<timestamp>.zip)", |
| ) |
| backup_parser.add_argument( |
| "-q", |
| "--quick", |
| action="store_true", |
| help="Quick snapshot: only critical state files (config, state.db, .env, auth, cron)", |
| ) |
| backup_parser.add_argument( |
| "-l", "--label", help="Label for the snapshot (only used with --quick)" |
| ) |
| backup_parser.set_defaults(func=cmd_backup) |
|
|
| |
| |
| |
| import_parser = subparsers.add_parser( |
| "import", |
| help="Restore a Hermes backup from a zip file", |
| description="Extract a previously created Hermes backup into your " |
| "Hermes home directory, restoring configuration, skills, " |
| "sessions, and data", |
| ) |
| import_parser.add_argument("zipfile", help="Path to the backup zip file") |
| import_parser.add_argument( |
| "--force", |
| "-f", |
| action="store_true", |
| help="Overwrite existing files without confirmation", |
| ) |
| import_parser.set_defaults(func=cmd_import) |
|
|
| |
| |
| |
| 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_subparsers.add_parser("show", help="Show current configuration") |
|
|
| |
| config_subparsers.add_parser("edit", help="Open config file in editor") |
|
|
| |
| 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_subparsers.add_parser("path", help="Print config file path") |
|
|
| |
| config_subparsers.add_parser("env-path", help="Print .env file path") |
|
|
| |
| config_subparsers.add_parser("check", help="Check for missing/outdated config") |
|
|
| |
| config_subparsers.add_parser("migrate", help="Update config with new options") |
|
|
| config_parser.set_defaults(func=cmd_config) |
|
|
| |
| |
| |
| pairing_parser = subparsers.add_parser( |
| "pairing", |
| help="Manage DM pairing codes for user authorization", |
| description="Approve or revoke user access via pairing codes", |
| ) |
| pairing_sub = pairing_parser.add_subparsers(dest="pairing_action") |
|
|
| pairing_sub.add_parser("list", help="Show pending + approved users") |
|
|
| pairing_approve_parser = pairing_sub.add_parser( |
| "approve", help="Approve a pairing code" |
| ) |
| pairing_approve_parser.add_argument( |
| "platform", help="Platform name (telegram, discord, slack, whatsapp)" |
| ) |
| pairing_approve_parser.add_argument("code", help="Pairing code to approve") |
|
|
| pairing_revoke_parser = pairing_sub.add_parser("revoke", help="Revoke user access") |
| pairing_revoke_parser.add_argument("platform", help="Platform name") |
| pairing_revoke_parser.add_argument("user_id", help="User ID to revoke") |
|
|
| pairing_sub.add_parser("clear-pending", help="Clear all pending codes") |
|
|
| def cmd_pairing(args): |
| from hermes_cli.pairing import pairing_command |
|
|
| pairing_command(args) |
|
|
| pairing_parser.set_defaults(func=cmd_pairing) |
|
|
| |
| |
| |
| skills_parser = subparsers.add_parser( |
| "skills", |
| help="Search, install, configure, and manage skills", |
| description="Search, install, inspect, audit, configure, and manage skills from skills.sh, well-known agent skill endpoints, GitHub, ClawHub, and other registries.", |
| ) |
| skills_subparsers = skills_parser.add_subparsers(dest="skills_action") |
|
|
| skills_browse = skills_subparsers.add_parser( |
| "browse", help="Browse all available skills (paginated)" |
| ) |
| skills_browse.add_argument( |
| "--page", type=int, default=1, help="Page number (default: 1)" |
| ) |
| skills_browse.add_argument( |
| "--size", type=int, default=20, help="Results per page (default: 20)" |
| ) |
| skills_browse.add_argument( |
| "--source", |
| default="all", |
| choices=[ |
| "all", |
| "official", |
| "skills-sh", |
| "well-known", |
| "github", |
| "clawhub", |
| "lobehub", |
| ], |
| help="Filter by source (default: all)", |
| ) |
|
|
| skills_search = skills_subparsers.add_parser( |
| "search", help="Search skill registries" |
| ) |
| skills_search.add_argument("query", help="Search query") |
| skills_search.add_argument( |
| "--source", |
| default="all", |
| choices=[ |
| "all", |
| "official", |
| "skills-sh", |
| "well-known", |
| "github", |
| "clawhub", |
| "lobehub", |
| ], |
| ) |
| skills_search.add_argument("--limit", type=int, default=10, help="Max results") |
|
|
| skills_install = skills_subparsers.add_parser("install", help="Install a skill") |
| skills_install.add_argument( |
| "identifier", help="Skill identifier (e.g. openai/skills/skill-creator)" |
| ) |
| skills_install.add_argument( |
| "--category", default="", help="Category folder to install into" |
| ) |
| skills_install.add_argument( |
| "--force", action="store_true", help="Install despite blocked scan verdict" |
| ) |
| skills_install.add_argument( |
| "--yes", |
| "-y", |
| action="store_true", |
| help="Skip confirmation prompt (needed in TUI mode)", |
| ) |
|
|
| skills_inspect = skills_subparsers.add_parser( |
| "inspect", help="Preview a skill without installing" |
| ) |
| skills_inspect.add_argument("identifier", help="Skill identifier") |
|
|
| skills_list = skills_subparsers.add_parser("list", help="List installed skills") |
| skills_list.add_argument( |
| "--source", default="all", choices=["all", "hub", "builtin", "local"] |
| ) |
|
|
| skills_check = skills_subparsers.add_parser( |
| "check", help="Check installed hub skills for updates" |
| ) |
| skills_check.add_argument( |
| "name", nargs="?", help="Specific skill to check (default: all)" |
| ) |
|
|
| skills_update = skills_subparsers.add_parser( |
| "update", help="Update installed hub skills" |
| ) |
| skills_update.add_argument( |
| "name", |
| nargs="?", |
| help="Specific skill to update (default: all outdated skills)", |
| ) |
|
|
| skills_audit = skills_subparsers.add_parser( |
| "audit", help="Re-scan installed hub skills" |
| ) |
| skills_audit.add_argument( |
| "name", nargs="?", help="Specific skill to audit (default: all)" |
| ) |
|
|
| skills_uninstall = skills_subparsers.add_parser( |
| "uninstall", help="Remove a hub-installed skill" |
| ) |
| skills_uninstall.add_argument("name", help="Skill name to remove") |
|
|
| skills_reset = skills_subparsers.add_parser( |
| "reset", |
| help="Reset a bundled skill β clears 'user-modified' tracking so updates work again", |
| description=( |
| "Clear a bundled skill's entry from the sync manifest (~/.hermes/skills/.bundled_manifest) " |
| "so future 'hermes update' runs stop marking it as user-modified. Pass --restore to also " |
| "replace the current copy with the bundled version." |
| ), |
| ) |
| skills_reset.add_argument( |
| "name", help="Skill name to reset (e.g. google-workspace)" |
| ) |
| skills_reset.add_argument( |
| "--restore", |
| action="store_true", |
| help="Also delete the current copy and re-copy the bundled version", |
| ) |
| skills_reset.add_argument( |
| "--yes", |
| "-y", |
| action="store_true", |
| help="Skip confirmation prompt when using --restore", |
| ) |
|
|
| skills_publish = skills_subparsers.add_parser( |
| "publish", help="Publish a skill to a registry" |
| ) |
| skills_publish.add_argument("skill_path", help="Path to skill directory") |
| skills_publish.add_argument( |
| "--to", default="github", choices=["github", "clawhub"], help="Target registry" |
| ) |
| skills_publish.add_argument( |
| "--repo", default="", help="Target GitHub repo (e.g. openai/skills)" |
| ) |
|
|
| skills_snapshot = skills_subparsers.add_parser( |
| "snapshot", help="Export/import skill configurations" |
| ) |
| snapshot_subparsers = skills_snapshot.add_subparsers(dest="snapshot_action") |
| snap_export = snapshot_subparsers.add_parser( |
| "export", help="Export installed skills to a file" |
| ) |
| snap_export.add_argument("output", help="Output JSON file path (use - for stdout)") |
| snap_import = snapshot_subparsers.add_parser( |
| "import", help="Import and install skills from a file" |
| ) |
| snap_import.add_argument("input", help="Input JSON file path") |
| snap_import.add_argument( |
| "--force", action="store_true", help="Force install despite caution verdict" |
| ) |
|
|
| skills_tap = skills_subparsers.add_parser("tap", help="Manage skill sources") |
| tap_subparsers = skills_tap.add_subparsers(dest="tap_action") |
| tap_subparsers.add_parser("list", help="List configured taps") |
| tap_add = tap_subparsers.add_parser("add", help="Add a GitHub repo as skill source") |
| tap_add.add_argument("repo", help="GitHub repo (e.g. owner/repo)") |
| tap_rm = tap_subparsers.add_parser("remove", help="Remove a tap") |
| tap_rm.add_argument("name", help="Tap name to remove") |
|
|
| |
| skills_subparsers.add_parser( |
| "config", |
| help="Interactive skill configuration β enable/disable individual skills", |
| ) |
|
|
| def cmd_skills(args): |
| |
| if getattr(args, "skills_action", None) == "config": |
| _require_tty("skills config") |
| from hermes_cli.skills_config import skills_command as skills_config_command |
|
|
| skills_config_command(args) |
| else: |
| from hermes_cli.skills_hub import skills_command |
|
|
| skills_command(args) |
|
|
| skills_parser.set_defaults(func=cmd_skills) |
|
|
| |
| |
| |
| plugins_parser = subparsers.add_parser( |
| "plugins", |
| help="Manage plugins β install, update, remove, list", |
| description="Install plugins from Git repositories, update, remove, or list them.", |
| ) |
| plugins_subparsers = plugins_parser.add_subparsers(dest="plugins_action") |
|
|
| plugins_install = plugins_subparsers.add_parser( |
| "install", help="Install a plugin from a Git URL or owner/repo" |
| ) |
| plugins_install.add_argument( |
| "identifier", |
| help="Git URL or owner/repo shorthand (e.g. anpicasso/hermes-plugin-chrome-profiles)", |
| ) |
| plugins_install.add_argument( |
| "--force", |
| "-f", |
| action="store_true", |
| help="Remove existing plugin and reinstall", |
| ) |
| _install_enable_group = plugins_install.add_mutually_exclusive_group() |
| _install_enable_group.add_argument( |
| "--enable", |
| action="store_true", |
| help="Auto-enable the plugin after install (skip confirmation prompt)", |
| ) |
| _install_enable_group.add_argument( |
| "--no-enable", |
| action="store_true", |
| help="Install disabled (skip confirmation prompt); enable later with `hermes plugins enable <name>`", |
| ) |
|
|
| plugins_update = plugins_subparsers.add_parser( |
| "update", help="Pull latest changes for an installed plugin" |
| ) |
| plugins_update.add_argument("name", help="Plugin name to update") |
|
|
| plugins_remove = plugins_subparsers.add_parser( |
| "remove", aliases=["rm", "uninstall"], help="Remove an installed plugin" |
| ) |
| plugins_remove.add_argument("name", help="Plugin directory name to remove") |
|
|
| plugins_subparsers.add_parser("list", aliases=["ls"], help="List installed plugins") |
|
|
| plugins_enable = plugins_subparsers.add_parser( |
| "enable", help="Enable a disabled plugin" |
| ) |
| plugins_enable.add_argument("name", help="Plugin name to enable") |
|
|
| plugins_disable = plugins_subparsers.add_parser( |
| "disable", help="Disable a plugin without removing it" |
| ) |
| plugins_disable.add_argument("name", help="Plugin name to disable") |
|
|
| def cmd_plugins(args): |
| from hermes_cli.plugins_cmd import plugins_command |
|
|
| plugins_command(args) |
|
|
| plugins_parser.set_defaults(func=cmd_plugins) |
|
|
| |
| |
| |
| |
| |
| try: |
| from plugins.memory import discover_plugin_cli_commands |
|
|
| for cmd_info in discover_plugin_cli_commands(): |
| plugin_parser = subparsers.add_parser( |
| cmd_info["name"], |
| help=cmd_info["help"], |
| description=cmd_info.get("description", ""), |
| formatter_class=__import__("argparse").RawDescriptionHelpFormatter, |
| ) |
| cmd_info["setup_fn"](plugin_parser) |
| except Exception as _exc: |
| logging.getLogger(__name__).debug("Plugin CLI discovery failed: %s", _exc) |
|
|
| |
| |
| |
| memory_parser = subparsers.add_parser( |
| "memory", |
| help="Configure external memory provider", |
| description=( |
| "Set up and manage external memory provider plugins.\n\n" |
| "Available providers: honcho, openviking, mem0, hindsight,\n" |
| "holographic, retaindb, byterover.\n\n" |
| "Only one external provider can be active at a time.\n" |
| "Built-in memory (MEMORY.md/USER.md) is always active." |
| ), |
| ) |
| memory_sub = memory_parser.add_subparsers(dest="memory_command") |
| memory_sub.add_parser( |
| "setup", help="Interactive provider selection and configuration" |
| ) |
| memory_sub.add_parser("status", help="Show current memory provider config") |
| memory_sub.add_parser("off", help="Disable external provider (built-in only)") |
| _reset_parser = memory_sub.add_parser( |
| "reset", |
| help="Erase all built-in memory (MEMORY.md and USER.md)", |
| ) |
| _reset_parser.add_argument( |
| "--yes", |
| "-y", |
| action="store_true", |
| help="Skip confirmation prompt", |
| ) |
| _reset_parser.add_argument( |
| "--target", |
| choices=["all", "memory", "user"], |
| default="all", |
| help="Which store to reset: 'all' (default), 'memory', or 'user'", |
| ) |
|
|
| def cmd_memory(args): |
| sub = getattr(args, "memory_command", None) |
| if sub == "off": |
| from hermes_cli.config import load_config, save_config |
|
|
| config = load_config() |
| if not isinstance(config.get("memory"), dict): |
| config["memory"] = {} |
| config["memory"]["provider"] = "" |
| save_config(config) |
| print("\n β Memory provider: built-in only") |
| print(" Saved to config.yaml\n") |
| elif sub == "reset": |
| from hermes_constants import get_hermes_home, display_hermes_home |
|
|
| mem_dir = get_hermes_home() / "memories" |
| target = getattr(args, "target", "all") |
| files_to_reset = [] |
| if target in ("all", "memory"): |
| files_to_reset.append(("MEMORY.md", "agent notes")) |
| if target in ("all", "user"): |
| files_to_reset.append(("USER.md", "user profile")) |
|
|
| |
| existing = [ |
| (f, desc) for f, desc in files_to_reset if (mem_dir / f).exists() |
| ] |
| if not existing: |
| print( |
| f"\n Nothing to reset β no memory files found in {display_hermes_home()}/memories/\n" |
| ) |
| return |
|
|
| print(f"\n This will permanently erase the following memory files:") |
| for f, desc in existing: |
| path = mem_dir / f |
| size = path.stat().st_size |
| print(f" β {f} ({desc}) β {size:,} bytes") |
|
|
| if not getattr(args, "yes", False): |
| try: |
| answer = input("\n Type 'yes' to confirm: ").strip().lower() |
| except (EOFError, KeyboardInterrupt): |
| print("\n Cancelled.\n") |
| return |
| if answer != "yes": |
| print(" Cancelled.\n") |
| return |
|
|
| for f, desc in existing: |
| (mem_dir / f).unlink() |
| print(f" β Deleted {f} ({desc})") |
|
|
| print( |
| f"\n Memory reset complete. New sessions will start with a blank slate." |
| ) |
| print(f" Files were in: {display_hermes_home()}/memories/\n") |
| else: |
| from hermes_cli.memory_setup import memory_command |
|
|
| memory_command(args) |
|
|
| memory_parser.set_defaults(func=cmd_memory) |
|
|
| |
| |
| |
| tools_parser = subparsers.add_parser( |
| "tools", |
| help="Configure which tools are enabled per platform", |
| description=( |
| "Enable, disable, or list tools for CLI, Telegram, Discord, etc.\n\n" |
| "Built-in toolsets use plain names (e.g. web, memory).\n" |
| "MCP tools use server:tool notation (e.g. github:create_issue).\n\n" |
| "Run 'hermes tools' with no subcommand for the interactive configuration UI." |
| ), |
| ) |
| tools_parser.add_argument( |
| "--summary", |
| action="store_true", |
| help="Print a summary of enabled tools per platform and exit", |
| ) |
| tools_sub = tools_parser.add_subparsers(dest="tools_action") |
|
|
| |
| tools_list_p = tools_sub.add_parser( |
| "list", |
| help="Show all tools and their enabled/disabled status", |
| ) |
| tools_list_p.add_argument( |
| "--platform", |
| default="cli", |
| help="Platform to show (default: cli)", |
| ) |
|
|
| |
| tools_disable_p = tools_sub.add_parser( |
| "disable", |
| help="Disable toolsets or MCP tools", |
| ) |
| tools_disable_p.add_argument( |
| "names", |
| nargs="+", |
| metavar="NAME", |
| help="Toolset name (e.g. web) or MCP tool in server:tool form", |
| ) |
| tools_disable_p.add_argument( |
| "--platform", |
| default="cli", |
| help="Platform to apply to (default: cli)", |
| ) |
|
|
| |
| tools_enable_p = tools_sub.add_parser( |
| "enable", |
| help="Enable toolsets or MCP tools", |
| ) |
| tools_enable_p.add_argument( |
| "names", |
| nargs="+", |
| metavar="NAME", |
| help="Toolset name or MCP tool in server:tool form", |
| ) |
| tools_enable_p.add_argument( |
| "--platform", |
| default="cli", |
| help="Platform to apply to (default: cli)", |
| ) |
|
|
| def cmd_tools(args): |
| action = getattr(args, "tools_action", None) |
| if action in ("list", "disable", "enable"): |
| from hermes_cli.tools_config import tools_disable_enable_command |
|
|
| tools_disable_enable_command(args) |
| else: |
| _require_tty("tools") |
| from hermes_cli.tools_config import tools_command |
|
|
| tools_command(args) |
|
|
| tools_parser.set_defaults(func=cmd_tools) |
| |
| |
| |
| mcp_parser = subparsers.add_parser( |
| "mcp", |
| help="Manage MCP servers and run Hermes as an MCP server", |
| description=( |
| "Manage MCP server connections and run Hermes as an MCP server.\n\n" |
| "MCP servers provide additional tools via the Model Context Protocol.\n" |
| "Use 'hermes mcp add' to connect to a new server, or\n" |
| "'hermes mcp serve' to expose Hermes conversations over MCP." |
| ), |
| ) |
| mcp_sub = mcp_parser.add_subparsers(dest="mcp_action") |
|
|
| mcp_serve_p = mcp_sub.add_parser( |
| "serve", |
| help="Run Hermes as an MCP server (expose conversations to other agents)", |
| ) |
| mcp_serve_p.add_argument( |
| "-v", |
| "--verbose", |
| action="store_true", |
| help="Enable verbose logging on stderr", |
| ) |
| _add_accept_hooks_flag(mcp_serve_p) |
|
|
| mcp_add_p = mcp_sub.add_parser( |
| "add", help="Add an MCP server (discovery-first install)" |
| ) |
| mcp_add_p.add_argument("name", help="Server name (used as config key)") |
| mcp_add_p.add_argument("--url", help="HTTP/SSE endpoint URL") |
| mcp_add_p.add_argument("--command", help="Stdio command (e.g. npx)") |
| mcp_add_p.add_argument( |
| "--args", nargs="*", default=[], help="Arguments for stdio command" |
| ) |
| mcp_add_p.add_argument("--auth", choices=["oauth", "header"], help="Auth method") |
| mcp_add_p.add_argument("--preset", help="Known MCP preset name") |
| mcp_add_p.add_argument( |
| "--env", |
| nargs="*", |
| default=[], |
| help="Environment variables for stdio servers (KEY=VALUE)", |
| ) |
|
|
| mcp_rm_p = mcp_sub.add_parser("remove", aliases=["rm"], help="Remove an MCP server") |
| mcp_rm_p.add_argument("name", help="Server name to remove") |
|
|
| mcp_sub.add_parser("list", aliases=["ls"], help="List configured MCP servers") |
|
|
| mcp_test_p = mcp_sub.add_parser("test", help="Test MCP server connection") |
| mcp_test_p.add_argument("name", help="Server name to test") |
|
|
| mcp_cfg_p = mcp_sub.add_parser( |
| "configure", aliases=["config"], help="Toggle tool selection" |
| ) |
| mcp_cfg_p.add_argument("name", help="Server name to configure") |
|
|
| mcp_login_p = mcp_sub.add_parser( |
| "login", |
| help="Force re-authentication for an OAuth-based MCP server", |
| ) |
| mcp_login_p.add_argument("name", help="Server name to re-authenticate") |
|
|
| _add_accept_hooks_flag(mcp_parser) |
|
|
| def cmd_mcp(args): |
| from hermes_cli.mcp_config import mcp_command |
|
|
| mcp_command(args) |
|
|
| mcp_parser.set_defaults(func=cmd_mcp) |
|
|
| |
| |
| |
| sessions_parser = subparsers.add_parser( |
| "sessions", |
| help="Manage session history (list, rename, export, prune, delete)", |
| description="View and manage the SQLite session store", |
| ) |
| sessions_subparsers = sessions_parser.add_subparsers(dest="sessions_action") |
|
|
| sessions_list = sessions_subparsers.add_parser("list", help="List recent sessions") |
| sessions_list.add_argument( |
| "--source", help="Filter by source (cli, telegram, discord, etc.)" |
| ) |
| sessions_list.add_argument( |
| "--limit", type=int, default=20, help="Max sessions to show" |
| ) |
|
|
| sessions_export = sessions_subparsers.add_parser( |
| "export", help="Export sessions to a JSONL file" |
| ) |
| sessions_export.add_argument( |
| "output", help="Output JSONL file path (use - for stdout)" |
| ) |
| sessions_export.add_argument("--source", help="Filter by source") |
| sessions_export.add_argument("--session-id", help="Export a specific session") |
|
|
| sessions_delete = sessions_subparsers.add_parser( |
| "delete", help="Delete a specific session" |
| ) |
| sessions_delete.add_argument("session_id", help="Session ID to delete") |
| sessions_delete.add_argument( |
| "--yes", "-y", action="store_true", help="Skip confirmation" |
| ) |
|
|
| sessions_prune = sessions_subparsers.add_parser("prune", help="Delete old sessions") |
| sessions_prune.add_argument( |
| "--older-than", |
| type=int, |
| default=90, |
| help="Delete sessions older than N days (default: 90)", |
| ) |
| sessions_prune.add_argument("--source", help="Only prune sessions from this source") |
| sessions_prune.add_argument( |
| "--yes", "-y", action="store_true", help="Skip confirmation" |
| ) |
|
|
| sessions_subparsers.add_parser("stats", help="Show session store statistics") |
|
|
| sessions_rename = sessions_subparsers.add_parser( |
| "rename", help="Set or change a session's title" |
| ) |
| sessions_rename.add_argument("session_id", help="Session ID to rename") |
| sessions_rename.add_argument("title", nargs="+", help="New title for the session") |
|
|
| sessions_browse = sessions_subparsers.add_parser( |
| "browse", |
| help="Interactive session picker β browse, search, and resume sessions", |
| ) |
| sessions_browse.add_argument( |
| "--source", help="Filter by source (cli, telegram, discord, etc.)" |
| ) |
| sessions_browse.add_argument( |
| "--limit", type=int, default=50, help="Max sessions to load (default: 50)" |
| ) |
|
|
| def _confirm_prompt(prompt: str) -> bool: |
| """Prompt for y/N confirmation, safe against non-TTY environments.""" |
| try: |
| return input(prompt).strip().lower() in ("y", "yes") |
| except (EOFError, KeyboardInterrupt): |
| return False |
|
|
| def cmd_sessions(args): |
| import json as _json |
|
|
| try: |
| from hermes_state import SessionDB |
|
|
| db = SessionDB() |
| except Exception as e: |
| print(f"Error: Could not open session database: {e}") |
| return |
|
|
| action = args.sessions_action |
|
|
| |
| _source = getattr(args, "source", None) |
| _exclude = None if _source else ["tool"] |
|
|
| if action == "list": |
| sessions = db.list_sessions_rich( |
| source=args.source, exclude_sources=_exclude, limit=args.limit |
| ) |
| if not sessions: |
| print("No sessions found.") |
| return |
| has_titles = any(s.get("title") for s in sessions) |
| if has_titles: |
| print(f"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}") |
| print("β" * 110) |
| else: |
| print(f"{'Preview':<50} {'Last Active':<13} {'Src':<6} {'ID'}") |
| print("β" * 95) |
| for s in sessions: |
| last_active = _relative_time(s.get("last_active")) |
| preview = ( |
| s.get("preview", "")[:38] |
| if has_titles |
| else s.get("preview", "")[:48] |
| ) |
| if has_titles: |
| title = (s.get("title") or "β")[:30] |
| sid = s["id"] |
| print(f"{title:<32} {preview:<40} {last_active:<13} {sid}") |
| else: |
| sid = s["id"] |
| print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}") |
|
|
| elif action == "export": |
| if args.session_id: |
| resolved_session_id = db.resolve_session_id(args.session_id) |
| if not resolved_session_id: |
| print(f"Session '{args.session_id}' not found.") |
| return |
| data = db.export_session(resolved_session_id) |
| if not data: |
| print(f"Session '{args.session_id}' not found.") |
| return |
| line = _json.dumps(data, ensure_ascii=False) + "\n" |
| if args.output == "-": |
|
|
| sys.stdout.write(line) |
| else: |
| with open(args.output, "w", encoding="utf-8") as f: |
| f.write(line) |
| print(f"Exported 1 session to {args.output}") |
| else: |
| sessions = db.export_all(source=args.source) |
| if args.output == "-": |
|
|
| for s in sessions: |
| sys.stdout.write(_json.dumps(s, ensure_ascii=False) + "\n") |
| else: |
| with open(args.output, "w", encoding="utf-8") as f: |
| for s in sessions: |
| f.write(_json.dumps(s, ensure_ascii=False) + "\n") |
| print(f"Exported {len(sessions)} sessions to {args.output}") |
|
|
| elif action == "delete": |
| resolved_session_id = db.resolve_session_id(args.session_id) |
| if not resolved_session_id: |
| print(f"Session '{args.session_id}' not found.") |
| return |
| if not args.yes: |
| if not _confirm_prompt( |
| f"Delete session '{resolved_session_id}' and all its messages? [y/N] " |
| ): |
| print("Cancelled.") |
| return |
| if db.delete_session(resolved_session_id): |
| print(f"Deleted session '{resolved_session_id}'.") |
| else: |
| print(f"Session '{args.session_id}' not found.") |
|
|
| elif action == "prune": |
| days = args.older_than |
| source_msg = f" from '{args.source}'" if args.source else "" |
| if not args.yes: |
| if not _confirm_prompt( |
| f"Delete all ended sessions older than {days} days{source_msg}? [y/N] " |
| ): |
| print("Cancelled.") |
| return |
| count = db.prune_sessions(older_than_days=days, source=args.source) |
| print(f"Pruned {count} session(s).") |
|
|
| elif action == "rename": |
| resolved_session_id = db.resolve_session_id(args.session_id) |
| if not resolved_session_id: |
| print(f"Session '{args.session_id}' not found.") |
| return |
| title = " ".join(args.title) |
| try: |
| if db.set_session_title(resolved_session_id, title): |
| print(f"Session '{resolved_session_id}' renamed to: {title}") |
| else: |
| print(f"Session '{args.session_id}' not found.") |
| except ValueError as e: |
| print(f"Error: {e}") |
|
|
| elif action == "browse": |
| limit = getattr(args, "limit", 50) or 50 |
| source = getattr(args, "source", None) |
| _browse_exclude = None if source else ["tool"] |
| sessions = db.list_sessions_rich( |
| source=source, exclude_sources=_browse_exclude, limit=limit |
| ) |
| db.close() |
| if not sessions: |
| print("No sessions found.") |
| return |
|
|
| selected_id = _session_browse_picker(sessions) |
| if not selected_id: |
| print("Cancelled.") |
| return |
|
|
| |
| print(f"Resuming session: {selected_id}") |
| hermes_bin = shutil.which("hermes") |
| if hermes_bin: |
| os.execvp(hermes_bin, ["hermes", "--resume", selected_id]) |
| else: |
| |
| os.execvp( |
| sys.executable, |
| [sys.executable, "-m", "hermes_cli.main", "--resume", selected_id], |
| ) |
| return |
|
|
| elif action == "stats": |
| total = db.session_count() |
| msgs = db.message_count() |
| print(f"Total sessions: {total}") |
| print(f"Total messages: {msgs}") |
| for src in ["cli", "telegram", "discord", "whatsapp", "slack"]: |
| c = db.session_count(source=src) |
| if c > 0: |
| print(f" {src}: {c} sessions") |
| db_path = db.db_path |
| if db_path.exists(): |
| size_mb = os.path.getsize(db_path) / (1024 * 1024) |
| print(f"Database size: {size_mb:.1f} MB") |
|
|
| else: |
| sessions_parser.print_help() |
|
|
| db.close() |
|
|
| sessions_parser.set_defaults(func=cmd_sessions) |
|
|
| |
| |
| |
| insights_parser = subparsers.add_parser( |
| "insights", |
| help="Show usage insights and analytics", |
| description="Analyze session history to show token usage, costs, tool patterns, and activity trends", |
| ) |
| insights_parser.add_argument( |
| "--days", type=int, default=30, help="Number of days to analyze (default: 30)" |
| ) |
| insights_parser.add_argument( |
| "--source", help="Filter by platform (cli, telegram, discord, etc.)" |
| ) |
|
|
| def cmd_insights(args): |
| try: |
| from hermes_state import SessionDB |
| from agent.insights import InsightsEngine |
|
|
| db = SessionDB() |
| engine = InsightsEngine(db) |
| report = engine.generate(days=args.days, source=args.source) |
| print(engine.format_terminal(report)) |
| db.close() |
| except Exception as e: |
| print(f"Error generating insights: {e}") |
|
|
| insights_parser.set_defaults(func=cmd_insights) |
|
|
| |
| |
| |
| claw_parser = subparsers.add_parser( |
| "claw", |
| help="OpenClaw migration tools", |
| description="Migrate settings, memories, skills, and API keys from OpenClaw to Hermes", |
| ) |
| claw_subparsers = claw_parser.add_subparsers(dest="claw_action") |
|
|
| |
| claw_migrate = claw_subparsers.add_parser( |
| "migrate", |
| help="Migrate from OpenClaw to Hermes", |
| description="Import settings, memories, skills, and API keys from an OpenClaw installation. " |
| "Always shows a preview before making changes.", |
| ) |
| claw_migrate.add_argument( |
| "--source", help="Path to OpenClaw directory (default: ~/.openclaw)" |
| ) |
| claw_migrate.add_argument( |
| "--dry-run", |
| action="store_true", |
| help="Preview only β stop after showing what would be migrated", |
| ) |
| claw_migrate.add_argument( |
| "--preset", |
| choices=["user-data", "full"], |
| default="full", |
| help="Migration preset (default: full). 'user-data' excludes secrets", |
| ) |
| claw_migrate.add_argument( |
| "--overwrite", |
| action="store_true", |
| help="Overwrite existing files (default: skip conflicts)", |
| ) |
| claw_migrate.add_argument( |
| "--migrate-secrets", |
| action="store_true", |
| help="Include allowlisted secrets (TELEGRAM_BOT_TOKEN, API keys, etc.)", |
| ) |
| claw_migrate.add_argument( |
| "--workspace-target", help="Absolute path to copy workspace instructions into" |
| ) |
| claw_migrate.add_argument( |
| "--skill-conflict", |
| choices=["skip", "overwrite", "rename"], |
| default="skip", |
| help="How to handle skill name conflicts (default: skip)", |
| ) |
| claw_migrate.add_argument( |
| "--yes", "-y", action="store_true", help="Skip confirmation prompts" |
| ) |
|
|
| |
| claw_cleanup = claw_subparsers.add_parser( |
| "cleanup", |
| aliases=["clean"], |
| help="Archive leftover OpenClaw directories after migration", |
| description="Scan for and archive leftover OpenClaw directories to prevent state fragmentation", |
| ) |
| claw_cleanup.add_argument( |
| "--source", help="Path to a specific OpenClaw directory to clean up" |
| ) |
| claw_cleanup.add_argument( |
| "--dry-run", |
| action="store_true", |
| help="Preview what would be archived without making changes", |
| ) |
| claw_cleanup.add_argument( |
| "--yes", "-y", action="store_true", help="Skip confirmation prompts" |
| ) |
|
|
| def cmd_claw(args): |
| from hermes_cli.claw import claw_command |
|
|
| claw_command(args) |
|
|
| claw_parser.set_defaults(func=cmd_claw) |
|
|
| |
| |
| |
| version_parser = subparsers.add_parser("version", help="Show version information") |
| version_parser.set_defaults(func=cmd_version) |
|
|
| |
| |
| |
| 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.add_argument( |
| "--gateway", |
| action="store_true", |
| default=False, |
| help="Gateway mode: use file-based IPC for prompts instead of stdin (used internally by /update)", |
| ) |
| update_parser.set_defaults(func=cmd_update) |
|
|
| |
| |
| |
| uninstall_parser = subparsers.add_parser( |
| "uninstall", |
| help="Uninstall Hermes Agent", |
| description="Remove Hermes Agent from your system. Can keep configs/data for reinstall.", |
| ) |
| uninstall_parser.add_argument( |
| "--full", |
| action="store_true", |
| help="Full uninstall - remove everything including configs and data", |
| ) |
| uninstall_parser.add_argument( |
| "--yes", "-y", action="store_true", help="Skip confirmation prompts" |
| ) |
| uninstall_parser.set_defaults(func=cmd_uninstall) |
|
|
| |
| |
| |
| acp_parser = subparsers.add_parser( |
| "acp", |
| help="Run Hermes Agent as an ACP (Agent Client Protocol) server", |
| description="Start Hermes Agent in ACP mode for editor integration (VS Code, Zed, JetBrains)", |
| ) |
| _add_accept_hooks_flag(acp_parser) |
|
|
| def cmd_acp(args): |
| """Launch Hermes Agent as an ACP server.""" |
| try: |
| from acp_adapter.entry import main as acp_main |
|
|
| acp_main() |
| except ImportError: |
| print("ACP dependencies not installed.") |
| print("Install them with: pip install -e '.[acp]'") |
| sys.exit(1) |
|
|
| acp_parser.set_defaults(func=cmd_acp) |
|
|
| |
| |
| |
| profile_parser = subparsers.add_parser( |
| "profile", |
| help="Manage profiles β multiple isolated Hermes instances", |
| ) |
| profile_subparsers = profile_parser.add_subparsers(dest="profile_action") |
|
|
| profile_subparsers.add_parser("list", help="List all profiles") |
| profile_use = profile_subparsers.add_parser( |
| "use", help="Set sticky default profile" |
| ) |
| profile_use.add_argument("profile_name", help="Profile name (or 'default')") |
|
|
| profile_create = profile_subparsers.add_parser( |
| "create", help="Create a new profile" |
| ) |
| profile_create.add_argument( |
| "profile_name", help="Profile name (lowercase, alphanumeric)" |
| ) |
| profile_create.add_argument( |
| "--clone", |
| action="store_true", |
| help="Copy config.yaml, .env, SOUL.md from active profile", |
| ) |
| profile_create.add_argument( |
| "--clone-all", |
| action="store_true", |
| help="Full copy of active profile (all state)", |
| ) |
| profile_create.add_argument( |
| "--clone-from", |
| metavar="SOURCE", |
| help="Source profile to clone from (default: active)", |
| ) |
| profile_create.add_argument( |
| "--no-alias", action="store_true", help="Skip wrapper script creation" |
| ) |
|
|
| profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile") |
| profile_delete.add_argument("profile_name", help="Profile to delete") |
| profile_delete.add_argument( |
| "-y", "--yes", action="store_true", help="Skip confirmation prompt" |
| ) |
|
|
| profile_show = profile_subparsers.add_parser("show", help="Show profile details") |
| profile_show.add_argument("profile_name", help="Profile to show") |
|
|
| profile_alias = profile_subparsers.add_parser( |
| "alias", help="Manage wrapper scripts" |
| ) |
| profile_alias.add_argument("profile_name", help="Profile name") |
| profile_alias.add_argument( |
| "--remove", action="store_true", help="Remove the wrapper script" |
| ) |
| profile_alias.add_argument( |
| "--name", |
| dest="alias_name", |
| metavar="NAME", |
| help="Custom alias name (default: profile name)", |
| ) |
|
|
| profile_rename = profile_subparsers.add_parser("rename", help="Rename a profile") |
| profile_rename.add_argument("old_name", help="Current profile name") |
| profile_rename.add_argument("new_name", help="New profile name") |
|
|
| profile_export = profile_subparsers.add_parser( |
| "export", help="Export a profile to archive" |
| ) |
| profile_export.add_argument("profile_name", help="Profile to export") |
| profile_export.add_argument( |
| "-o", "--output", default=None, help="Output file (default: <name>.tar.gz)" |
| ) |
|
|
| profile_import = profile_subparsers.add_parser( |
| "import", help="Import a profile from archive" |
| ) |
| profile_import.add_argument("archive", help="Path to .tar.gz archive") |
| profile_import.add_argument( |
| "--name", |
| dest="import_name", |
| metavar="NAME", |
| help="Profile name (default: inferred from archive)", |
| ) |
|
|
| profile_parser.set_defaults(func=cmd_profile) |
|
|
| |
| |
| |
| completion_parser = subparsers.add_parser( |
| "completion", |
| help="Print shell completion script (bash, zsh, or fish)", |
| ) |
| completion_parser.add_argument( |
| "shell", |
| nargs="?", |
| default="bash", |
| choices=["bash", "zsh", "fish"], |
| help="Shell type (default: bash)", |
| ) |
| completion_parser.set_defaults(func=lambda args: cmd_completion(args, parser)) |
|
|
| |
| |
| |
| dashboard_parser = subparsers.add_parser( |
| "dashboard", |
| help="Start the web UI dashboard", |
| description="Launch the Hermes Agent web dashboard for managing config, API keys, and sessions", |
| ) |
| dashboard_parser.add_argument( |
| "--port", type=int, default=9119, help="Port (default 9119)" |
| ) |
| dashboard_parser.add_argument( |
| "--host", default="127.0.0.1", help="Host (default 127.0.0.1)" |
| ) |
| dashboard_parser.add_argument( |
| "--no-open", action="store_true", help="Don't open browser automatically" |
| ) |
| dashboard_parser.add_argument( |
| "--insecure", |
| action="store_true", |
| help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)", |
| ) |
| dashboard_parser.set_defaults(func=cmd_dashboard) |
|
|
| |
| |
| |
| logs_parser = subparsers.add_parser( |
| "logs", |
| help="View and filter Hermes log files", |
| description="View, tail, and filter agent.log / errors.log / gateway.log", |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| epilog="""\ |
| Examples: |
| hermes logs Show last 50 lines of agent.log |
| hermes logs -f Follow agent.log in real time |
| hermes logs errors Show last 50 lines of errors.log |
| hermes logs gateway -n 100 Show last 100 lines of gateway.log |
| hermes logs --level WARNING Only show WARNING and above |
| hermes logs --session abc123 Filter by session ID |
| hermes logs --component tools Only show tool-related lines |
| hermes logs --since 1h Lines from the last hour |
| hermes logs --since 30m -f Follow, starting from 30 min ago |
| hermes logs list List available log files with sizes |
| """, |
| ) |
| logs_parser.add_argument( |
| "log_name", |
| nargs="?", |
| default="agent", |
| help="Log to view: agent (default), errors, gateway, or 'list' to show available files", |
| ) |
| logs_parser.add_argument( |
| "-n", |
| "--lines", |
| type=int, |
| default=50, |
| help="Number of lines to show (default: 50)", |
| ) |
| logs_parser.add_argument( |
| "-f", |
| "--follow", |
| action="store_true", |
| help="Follow the log in real time (like tail -f)", |
| ) |
| logs_parser.add_argument( |
| "--level", |
| metavar="LEVEL", |
| help="Minimum log level to show (DEBUG, INFO, WARNING, ERROR)", |
| ) |
| logs_parser.add_argument( |
| "--session", |
| metavar="ID", |
| help="Filter lines containing this session ID substring", |
| ) |
| logs_parser.add_argument( |
| "--since", |
| metavar="TIME", |
| help="Show lines since TIME ago (e.g. 1h, 30m, 2d)", |
| ) |
| logs_parser.add_argument( |
| "--component", |
| metavar="NAME", |
| help="Filter by component: gateway, agent, tools, cli, cron", |
| ) |
| logs_parser.set_defaults(func=cmd_logs) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| from hermes_cli.config import get_container_exec_info |
|
|
| container_info = get_container_exec_info() |
| if container_info: |
| _exec_in_container(container_info, sys.argv[1:]) |
| |
| |
| sys.exit(1) |
|
|
| _processed_argv = _coalesce_session_name_args(sys.argv[1:]) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import io as _io |
|
|
| _known_cmds = ( |
| set(subparsers.choices.keys()) if hasattr(subparsers, "choices") else set() |
| ) |
| _has_cmd_token = any( |
| t in _known_cmds for t in _processed_argv if not t.startswith("-") |
| ) |
|
|
| if _has_cmd_token: |
| subparsers.required = True |
| _saved_stderr = sys.stderr |
| try: |
| sys.stderr = _io.StringIO() |
| args = parser.parse_args(_processed_argv) |
| sys.stderr = _saved_stderr |
| except SystemExit as exc: |
| sys.stderr = _saved_stderr |
| |
| |
| |
| if exc.code == 0: |
| raise |
| |
| |
| subparsers.required = False |
| args = parser.parse_args(_processed_argv) |
| else: |
| subparsers.required = False |
| args = parser.parse_args(_processed_argv) |
|
|
| |
| if args.version: |
| cmd_version(args) |
| return |
|
|
| |
| |
| |
| |
| |
| |
| |
| _AGENT_COMMANDS = {None, "chat", "acp", "rl"} |
| _AGENT_SUBCOMMANDS = { |
| "cron": ("cron_command", {"run", "tick"}), |
| "gateway": ("gateway_command", {"run"}), |
| "mcp": ("mcp_action", {"serve"}), |
| } |
| _sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None)) |
| if ( |
| args.command in _AGENT_COMMANDS |
| or (_sub_attr and getattr(args, _sub_attr, None) in _sub_set) |
| ): |
| _accept_hooks = bool(getattr(args, "accept_hooks", False)) |
| try: |
| from hermes_cli.plugins import discover_plugins |
| discover_plugins() |
| except Exception: |
| logger.debug( |
| "plugin discovery failed at CLI startup", exc_info=True, |
| ) |
| try: |
| from hermes_cli.config import load_config |
| from agent.shell_hooks import register_from_config |
| register_from_config(load_config(), accept_hooks=_accept_hooks) |
| except Exception: |
| logger.debug( |
| "shell-hook registration failed at CLI startup", |
| exc_info=True, |
| ) |
|
|
| |
| if (args.resume or args.continue_last) and args.command is None: |
| args.command = "chat" |
| for attr, default in [ |
| ("query", None), |
| ("model", None), |
| ("provider", None), |
| ("toolsets", None), |
| ("verbose", False), |
| ("worktree", False), |
| ]: |
| if not hasattr(args, attr): |
| setattr(args, attr, default) |
| cmd_chat(args) |
| return |
|
|
| |
| if args.command is None: |
| for attr, default in [ |
| ("query", None), |
| ("model", None), |
| ("provider", None), |
| ("toolsets", None), |
| ("verbose", False), |
| ("resume", None), |
| ("continue_last", None), |
| ("worktree", False), |
| ]: |
| if not hasattr(args, attr): |
| setattr(args, attr, default) |
| cmd_chat(args) |
| return |
|
|
| |
| if hasattr(args, "func"): |
| args.func(args) |
| else: |
| parser.print_help() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|