From 64ec9233f1293e4019dbde9a6bea27c0dcebd4d0 Mon Sep 17 00:00:00 2001 From: LeonSGP <154585401+LeonSGP43@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:46:29 +0800 Subject: [PATCH] fix(whatsapp): repair partial bridge installs --- hermes_cli/main.py | 4338 ++++++++++++++++++----- tests/hermes_cli/test_whatsapp_setup.py | 93 + 2 files changed, 3588 insertions(+), 843 deletions(-) create mode 100644 tests/hermes_cli/test_whatsapp_setup.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 46a7e2c5f9c..27bd545d17f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -45,11 +45,26 @@ 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. @@ -71,6 +86,7 @@ def _require_tty(command_name: str) -> None: PROJECT_ROOT = Path(__file__).parent.parent.resolve() sys.path.insert(0, str(PROJECT_ROOT)) + # --------------------------------------------------------------------------- # Profile override — MUST happen before any hermes module import. # @@ -101,6 +117,7 @@ def _apply_profile_override() -> None: 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() @@ -114,13 +131,17 @@ def _apply_profile_override() -> None: 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: # A bug in profiles.py must NEVER prevent hermes from starting - print(f"Warning: profile override failed ({exc}), using default", file=sys.stderr) + print( + f"Warning: profile override failed ({exc}), using default", + file=sys.stderr, + ) return os.environ["HERMES_HOME"] = hermes_home # Strip the flag from argv so argparse doesn't choke @@ -128,25 +149,28 @@ def _apply_profile_override() -> None: for i, arg in enumerate(argv): if arg in ("--profile", "-p"): start = i + 1 # +1 because argv is sys.argv[1:] - sys.argv = sys.argv[:start] + sys.argv[start + consume:] + 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:] + sys.argv = sys.argv[:start] + sys.argv[start + 1 :] break + _apply_profile_override() # Load .env from ~/.hermes/.env first, then project root as dev fallback. # User-managed env files should override stale shell exports on restart. 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') + +load_hermes_dotenv(project_env=PROJECT_ROOT / ".env") # Initialize centralized file logging early — all `hermes` subcommands # (chat, setup, gateway, config, etc.) write to agent.log + errors.log. try: from hermes_logging import setup_logging as _setup_logging + _setup_logging(mode="cli") except Exception: pass # best-effort — don't crash the CLI if logging setup fails @@ -155,6 +179,7 @@ def _apply_profile_override() -> None: 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"): @@ -168,7 +193,7 @@ def _apply_profile_override() -> None: from datetime import datetime from hermes_cli import __version__, __release_date__ -from hermes_constants import OPENROUTER_BASE_URL +from hermes_constants import AI_GATEWAY_BASE_URL, OPENROUTER_BASE_URL logger = logging.getLogger(__name__) @@ -201,6 +226,7 @@ def _has_any_provider_configured() -> bool: # tool credentials (Claude Code, Codex CLI) that shouldn't silently skip # the setup wizard on a fresh install. from hermes_cli.config import DEFAULT_CONFIG + _DEFAULT_MODEL = DEFAULT_CONFIG.get("model", "") cfg = load_config() model_cfg = cfg.get("model") @@ -218,7 +244,13 @@ def _has_any_provider_configured() -> bool: from hermes_cli.auth import PROVIDER_REGISTRY # Collect all provider env vars - provider_env_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"} + 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) @@ -256,6 +288,7 @@ def _has_any_provider_configured() -> bool: if auth_file.exists(): try: import json + auth = json.loads(auth_file.read_text()) active = auth.get("active_provider") if active: @@ -265,7 +298,6 @@ def _has_any_provider_configured() -> bool: except Exception: pass - # Check config.yaml — if model is a dict with an explicit provider set, # the user has gone through setup (fresh installs have model as a plain # string). Also covers custom endpoints that store api_key/base_url in @@ -282,9 +314,15 @@ def _has_any_provider_configured() -> bool: # being installed doesn't mean the user wants Hermes to use their tokens. if _has_hermes_config: try: - from agent.anthropic_adapter import read_claude_code_credentials, is_claude_code_token_valid + 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")): + if creds and ( + is_claude_code_token_valid(creds) or creds.get("refreshToken") + ): return True except Exception: pass @@ -346,10 +384,10 @@ def _curses_browse(stdscr): if curses.has_colors(): curses.start_color() curses.use_default_colors() - curses.init_pair(1, curses.COLOR_GREEN, -1) # selected + curses.init_pair(1, curses.COLOR_GREEN, -1) # selected curses.init_pair(2, curses.COLOR_YELLOW, -1) # header - curses.init_pair(3, curses.COLOR_CYAN, -1) # search - curses.init_pair(4, 8, -1) # dim + curses.init_pair(3, curses.COLOR_CYAN, -1) # search + curses.init_pair(4, 8, -1) # dim cursor = 0 scroll_offset = 0 @@ -390,7 +428,9 @@ def _curses_browse(stdscr): 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 + 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 @@ -417,10 +457,12 @@ def _curses_browse(stdscr): 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) - )): + 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 @@ -446,18 +488,23 @@ def _curses_browse(stdscr): 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) + 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 key in (curses.KEY_UP,): if filtered: cursor = (cursor - 1) % len(filtered) - elif key in (curses.KEY_DOWN, ): + elif key in (curses.KEY_DOWN,): if filtered: cursor = (cursor + 1) % len(filtered) elif key in (curses.KEY_ENTER, 10, 13): @@ -483,7 +530,7 @@ def _curses_browse(stdscr): filtered = list(sessions) cursor = 0 scroll_offset = 0 - elif key == ord('q') and not search_text: + elif key == ord("q") and not search_text: return elif 32 <= key <= 126: # Printable character → add to search filter @@ -526,12 +573,13 @@ def _curses_browse(stdscr): return None -def _resolve_last_cli_session() -> Optional[str]: - """Look up the most recent CLI session ID from SQLite. Returns None if unavailable.""" +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="cli", limit=1) + sessions = db.search_sessions(source=source, limit=1) db.close() if sessions: return sessions[0]["id"] @@ -570,7 +618,6 @@ def _exec_in_container(container_info: dict, cli_args: list): container_info: dict with backend, container_name, exec_user, hermes_bin cli_args: the original CLI arguments (everything after 'hermes') """ - import shutil backend = container_info["backend"] container_name = container_info["container_name"] @@ -579,8 +626,10 @@ def _exec_in_container(container_info: dict, cli_args: list): runtime = shutil.which(backend) if not runtime: - print(f"Error: {backend} not found on PATH. Cannot route to container.", - file=sys.stderr) + print( + f"Error: {backend} not found on PATH. Cannot route to container.", + file=sys.stderr, + ) sys.exit(1) # Rootful containers (NixOS systemd service) are invisible to unprivileged @@ -588,14 +637,16 @@ def _exec_in_container(container_info: dict, cli_args: list): # Probe whether the runtime can see the container; if not, try via sudo. sudo_path = None probe = _probe_container( - [runtime, "inspect", "--format", "ok", container_name], backend, + [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, + backend, + via_sudo=True, ) if probe2.returncode != 0: print( @@ -608,10 +659,10 @@ def _exec_in_container(container_info: dict, cli_args: list): f"\n" f"On NixOS:\n" f"\n" - f' security.sudo.extraRules = [{{\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"\n" f"Or run: sudo hermes {' '.join(cli_args)}", file=sys.stderr, @@ -636,7 +687,8 @@ def _exec_in_container(container_info: dict, cli_args: list): cmd_prefix = [sudo_path, "-n", runtime] if sudo_path else [runtime] exec_cmd = ( - cmd_prefix + ["exec"] + cmd_prefix + + ["exec"] + tty_flags + ["-u", exec_user] + env_flags @@ -653,29 +705,361 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]: - 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() # Try as exact session ID first session = db.get_session(name_or_id) + resolved_id: Optional[str] = None if session: - db.close() - return session["id"] + resolved_id = session["id"] + else: + # Try as title (with auto-latest for lineage) + resolved_id = db.resolve_session_by_title(name_or_id) + + if resolved_id: + # Project forward through compression chain so resumes land on + # the live tip instead of a dead compressed parent. + try: + resolved_id = db.get_compression_tip(resolved_id) or resolved_id + except Exception: + pass - # Try as title (with auto-latest for lineage) - session_id = db.resolve_session_by_title(name_or_id) db.close() - return session_id + 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 _whatsapp_bridge_need_npm_install(root: Path) -> bool: + """True when the WhatsApp bridge install is missing, partial, or stale.""" + baileys = root / "node_modules" / "@whiskeysockets" / "baileys" / "package.json" + if not baileys.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: + # Helper writes logs to stderr; we ask bash to print `command -v node` + # on stdout once ensure_node succeeds. Subshell PATH edits don't leak + # back into Python, so the stdout capture is the bridge. + 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 + + # pre-built dist + node_modules (nix / full HERMES_TUI_DIR) skips npm. + 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()) + # Guarantee an 8GB V8 heap + exposed GC for the TUI. Default node cap is + # ~1.5–4GB depending on version and can fatal-OOM on long sessions with + # large transcripts / reasoning blobs. Token-level merge: respect any + # user-supplied --max-old-space-size (they may have set it higher) and + # avoid duplicating --expose-gc. + _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.""" - # Resolve --continue into --resume with the latest CLI session or by name + use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1" + + # Resolve --continue into --resume with the latest session or by name continue_val = getattr(args, "continue_last", None) if continue_val and not getattr(args, "resume", None): if isinstance(continue_val, str): @@ -689,11 +1073,15 @@ def cmd_chat(args): sys.exit(1) else: # -c with no argument — continue the most recent session - last_id = _resolve_last_cli_session() + 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: - print("No previous CLI session found to continue.") + kind = "TUI" if use_tui else "CLI" + print(f"No previous {kind} session found to continue.") sys.exit(1) # Resolve --resume by title if it's not a direct session ID @@ -708,12 +1096,17 @@ def cmd_chat(args): # First-run guard: check if any provider is configured before launching if not _has_any_provider_configured(): print() - print("It looks like Hermes isn't configured yet -- no API keys or providers found.") + print( + "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 + from hermes_cli.setup import ( + is_interactive_stdin, + print_noninteractive_setup_guidance, + ) if not is_interactive_stdin(): print_noninteractive_setup_guidance( @@ -735,6 +1128,7 @@ def cmd_chat(args): # Start update check in background (runs while other init happens) try: from hermes_cli.banner import prefetch_update_check + prefetch_update_check() except Exception: pass @@ -742,6 +1136,7 @@ def cmd_chat(args): # Sync bundled skills on every CLI launch (fast -- skips unchanged skills) try: from tools.skills_sync import sync_skills + sync_skills(quiet=True) except Exception: pass @@ -754,9 +1149,15 @@ def cmd_chat(args): 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), + ) + # Import and run the CLI from cli import main as cli_main - + # Build kwargs from args kwargs = { "model": args.model, @@ -775,7 +1176,7 @@ def cmd_chat(args): } # Filter out None values kwargs = {k: v for k, v in kwargs.items() if v is not None} - + try: cli_main(**kwargs) except ValueError as e: @@ -786,14 +1187,13 @@ def cmd_chat(args): 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") - import subprocess - from pathlib import Path from hermes_cli.config import get_env_value, save_env_value print() @@ -808,7 +1208,9 @@ def cmd_whatsapp(args): 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( + " 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.") @@ -843,7 +1245,9 @@ def cmd_whatsapp(args): 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)" + mode_label = ( + "separate bot number" if wa_mode == "bot" else "personal number (self-chat)" + ) print(f"\n✓ Mode: {mode_label}") # ── Step 2: Enable WhatsApp ────────────────────────────────────────── @@ -865,7 +1269,9 @@ def cmd_whatsapp(args): response = "n" if response.lower() in ("y", "yes"): if wa_mode == "bot": - phone = input(" Phone numbers that can message the bot (comma-separated): ").strip() + phone = input( + " Phone numbers that can message the bot (comma-separated): " + ).strip() else: phone = input(" Your phone number (e.g. 15551234567): ").strip() if phone: @@ -875,7 +1281,9 @@ def cmd_whatsapp(args): print() if wa_mode == "bot": print(" Who should be allowed to message the bot?") - phone = input(" Phone numbers (comma-separated, or * for anyone): ").strip() + phone = input( + " Phone numbers (comma-separated, or * for anyone): " + ).strip() else: phone = input(" Your phone number (e.g. 15551234567): ").strip() if phone: @@ -893,17 +1301,28 @@ def cmd_whatsapp(args): print(f"\n✗ Bridge script not found at {bridge_script}") return - if not (bridge_dir / "node_modules").exists(): - print("\n→ Installing WhatsApp bridge dependencies...") - result = subprocess.run( - ["npm", "install"], - cwd=str(bridge_dir), - capture_output=True, - text=True, - timeout=120, - ) + if _whatsapp_bridge_need_npm_install(bridge_dir): + 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: - print(f" ✗ npm install failed: {result.stderr}") + 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: @@ -916,11 +1335,12 @@ def cmd_whatsapp(args): 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() + response = input( + "\n Re-pair? This will clear the existing session. [y/N] " + ).strip() except (EOFError, KeyboardInterrupt): response = "n" if response.lower() in ("y", "yes"): - import shutil shutil.rmtree(session_dir, ignore_errors=True) session_dir.mkdir(parents=True, exist_ok=True) print(" ✓ Session cleared") @@ -979,6 +1399,7 @@ def cmd_whatsapp(args): def cmd_setup(args): """Interactive setup wizard.""" from hermes_cli.setup import run_setup_wizard + run_setup_wizard(args) @@ -997,9 +1418,15 @@ def select_provider_and_model(args=None): persistence. """ from hermes_cli.auth import ( - resolve_provider, AuthError, format_auth_error, + resolve_provider, + AuthError, + format_auth_error, + ) + from hermes_cli.config import ( + get_compatible_custom_providers, + load_config, + get_env_value, ) - from hermes_cli.config import get_compatible_custom_providers, load_config, get_env_value config = load_config() current_model = config.get("model") @@ -1009,16 +1436,13 @@ def select_provider_and_model(args=None): # Read effective provider the same way the CLI does at startup: # config.yaml model.provider > env var > auto-detect - import os config_provider = None model_cfg = config.get("model") if isinstance(model_cfg, dict): config_provider = model_cfg.get("provider") effective_provider = ( - config_provider - or os.getenv("HERMES_INFERENCE_PROVIDER") - or "auto" + config_provider or os.getenv("HERMES_INFERENCE_PROVIDER") or "auto" ) try: active = resolve_provider(effective_provider) @@ -1075,7 +1499,9 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: return custom_provider_map # Add user-defined custom providers from config.yaml - _custom_provider_map = _named_custom_provider_map(config) # key → {name, base_url, api_key} + _custom_provider_map = _named_custom_provider_map( + config + ) # key → {name, base_url, api_key} for key, provider_info in _custom_provider_map.items(): name = provider_info["name"] base_url = provider_info["base_url"] @@ -1095,13 +1521,17 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: 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")) + _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(("cancel", "Cancel")) + 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, + [label for _, label in ordered], + default=default_idx, ) if provider_idx is None or ordered[provider_idx][0] == "cancel": print("No change.") @@ -1109,22 +1539,33 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: selected_provider = ordered[provider_idx][0] + if selected_provider == "aux-config": + _aux_config_menu() + return + # Step 2: Provider-specific setup + model selection 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: + 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( @@ -1139,15 +1580,37 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: _model_flow_anthropic(config, current_model) elif selected_provider == "kimi-coding": _model_flow_kimi(config, current_model) - elif selected_provider in ("gemini", "deepseek", "xai", "zai", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface", "xiaomi", "arcee"): + 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) # ── Post-switch cleanup: clear stale OPENAI_BASE_URL ────────────── # When the user switches to a named provider (anything except "custom"), # a leftover OPENAI_BASE_URL in ~/.hermes/.env can poison auxiliary # clients that use provider:auto. Clear it proactively. (#5161) - if selected_provider not in ("custom", "cancel", "remove-custom") \ - and not selected_provider.startswith("custom:"): + if selected_provider not in ( + "custom", + "cancel", + "remove-custom", + ) and not selected_provider.startswith("custom:"): _clear_stale_openai_base_url() @@ -1174,113 +1637,510 @@ def _clear_stale_openai_base_url(): 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})") + 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})" + ) -def _prompt_provider_choice(choices, *, default=0): - """Show provider selection menu with curses arrow-key navigation. +# ───────────────────────────────────────────────────────────────────────────── +# Auxiliary model configuration +# +# Hermes uses lightweight "auxiliary" models for side tasks (vision analysis, +# context compression, web extraction, session search, etc.). Each task has +# its own provider+model pair in config.yaml under `auxiliary.`. +# +# The UI lives behind "Configure auxiliary models..." at the bottom of the +# `hermes model` provider picker. It does NOT re-run credential setup — it +# only routes already-authenticated providers to specific aux tasks. Users +# configure new providers through the normal `hermes model` flow first. +# ───────────────────────────────────────────────────────────────────────────── + +# (task_key, display_name, short_description) +_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"), +] - 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. + +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. """ - 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 + from hermes_cli.config import load_config, save_config - # Fallback: numbered list - 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 + 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 _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 +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 - 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() + 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 + # Preserve timeout/download_timeout — those are user-tuned, not routing + if changed: + count += 1 + save_config(cfg) + return count - from hermes_cli.models import model_ids, get_pricing_for_provider - openrouter_models = model_ids(force_refresh=True) - # Fetch live pricing (non-blocking — returns empty dict on failure) - pricing = get_pricing_for_provider("openrouter", force_refresh=True) +def _aux_config_menu() -> None: + """Top-level auxiliary-model picker — choose a task to configure. - selected = _prompt_model_selection(openrouter_models, current_model=current_model, pricing=pricing) - if selected: - _save_model_choice(selected) + 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 - # Update config provider and deactivate any OAuth provider - from hermes_cli.config import load_config, save_config + while True: 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.") + 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() -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, save_config, save_env_value - from hermes_cli.nous_subscription import ( - apply_nous_provider_defaults, - get_nous_subscription_explainer_lines, + # Build the task menu with current settings inline + 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 + # Otherwise configure the specific task + _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) + + # Gather authenticated providers (has credentials + curated model list) + 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]]] = [] # (slug, label, models) + # "auto" always first + 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 endpoint (raw base_url) + 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 + + # Regular provider — pick a model from its curated list + _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) + + # Fetch live pricing for this provider (non-blocking) + pricing: dict = {} + try: + pricing = get_pricing_for_provider(provider_slug) or {} + except Exception: + pricing = {} + + model_list = list(curated_models) + + # Let the user pick a model. _prompt_model_selection supports "Enter custom + # model name" and cancel. When there's no curated list (rare), fall back + # to a raw input prompt. + 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 + + # Fallback: numbered list + 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, ) - import argparse + 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) + + # Fetch live pricing (non-blocking — returns empty dict on failure) + 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) + + # Update config provider and deactivate any OAuth provider + from hermes_cli.config import load_config, save_config + + cfg = load_config() + model = cfg.get("model") + if 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"): @@ -1298,9 +2158,12 @@ def _model_flow_nous(config, current_model="", args=None): insecure=bool(getattr(args, "insecure", False)), ) _login_nous(mock_args, PROVIDER_REGISTRY["nous"]) - print() - for line in get_nous_subscription_explainer_lines(): - print(line) + # Offer Tool Gateway enablement for paid subscribers + try: + _refreshed = load_config() or {} + prompt_enable_tool_gateway(_refreshed) + except Exception: + pass except SystemExit: print("Login cancelled or failed.") return @@ -1314,9 +2177,13 @@ def _model_flow_nous(config, current_model="", args=None): # The live /models endpoint returns hundreds of models; the curated list # shows only agentic models users recognize from OpenRouter. from hermes_cli.models import ( - _PROVIDER_MODELS, get_pricing_for_provider, filter_nous_free_models, - check_nous_free_tier, partition_nous_models_by_tier, + _PROVIDER_MODELS, + get_pricing_for_provider, + filter_nous_free_models, + 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.") @@ -1333,9 +2200,14 @@ def _model_flow_nous(config, current_model="", args=None): 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, + 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: @@ -1356,7 +2228,9 @@ def _model_flow_nous(config, current_model="", args=None): model_ids = filter_nous_free_models(model_ids, pricing) unavailable_models: list[str] = [] if free_tier: - model_ids, unavailable_models = partition_nous_models_by_tier(model_ids, pricing, free_tier=True) + 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.") @@ -1375,15 +2249,21 @@ def _model_flow_nous(config, current_model="", args=None): 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.") + 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, + model_ids, + current_model=current_model, + pricing=pricing, + unavailable_models=unavailable_models, + portal_url=_nous_portal_url, ) if selected: _save_model_choice(selected) @@ -1408,18 +2288,10 @@ def _model_flow_nous(config, current_model="", args=None): if get_env_value("OPENAI_BASE_URL"): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") - changed_defaults = apply_nous_provider_defaults(config) save_config(config) print(f"Default model set to: {selected} (via Nous Portal)") - if "tts" in changed_defaults: - print("TTS provider set to: OpenAI TTS via your Nous subscription") - else: - current_tts = str(config.get("tts", {}).get("provider") or "edge") - if current_tts.lower() not in {"", "edge"}: - print(f"Keeping your existing TTS provider: {current_tts}") - print() - for line in get_nous_subscription_explainer_lines(): - print(line) + # Offer Tool Gateway enablement for paid subscribers + prompt_enable_tool_gateway(config) else: print("No change.") @@ -1427,12 +2299,15 @@ def _model_flow_nous(config, current_model="", args=None): 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, + 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 - import argparse status = get_codex_auth_status() if not status.get("logged_in"): @@ -1460,6 +2335,7 @@ def _model_flow_openai_codex(config, current_model=""): 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: @@ -1476,7 +2352,6 @@ def _model_flow_openai_codex(config, current_model=""): print("No change.") - _DEFAULT_QWEN_PORTAL_MODELS = [ "qwen3-coder-plus", "qwen3-coder", @@ -1526,6 +2401,80 @@ def _model_flow_qwen_oauth(_config, current_model=""): 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 + + # Verify creds resolve + trigger project discovery + 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. @@ -1547,9 +2496,14 @@ def _model_flow_custom(config): print() try: - base_url = input(f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: ").strip() + 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() + + api_key = getpass.getpass( + f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: " + ).strip() except (KeyboardInterrupt, EOFError): print("\nCancelled.") return @@ -1566,6 +2520,30 @@ def _model_flow_custom(config): effective_key = api_key or current_key + # Hint: most local model servers (Ollama, vLLM, llama.cpp) require /v1 + # in the base URL for OpenAI-compatible chat completions. Prompt the + # user if the URL looks like a local server without /v1. + _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) @@ -1590,7 +2568,9 @@ def _model_flow_custom(config): 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}") + 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}") @@ -1609,7 +2589,9 @@ def _model_flow_custom(config): 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() + 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: @@ -1617,7 +2599,9 @@ def _model_flow_custom(config): 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() + context_length_str = input( + "Context length in tokens [leave blank for auto-detect]: " + ).strip() # Prompt for a display name — shown in the provider menu on future runs default_name = _auto_provider_name(effective_url) @@ -1629,7 +2613,11 @@ def _model_flow_custom(config): context_length = None if context_length_str: try: - context_length = int(context_length_str.replace(",", "").replace("k", "000").replace("K", "000")) + context_length = int( + context_length_str.replace(",", "") + .replace("k", "000") + .replace("K", "000") + ) if context_length <= 0: context_length = None except ValueError: @@ -1677,8 +2665,13 @@ def _model_flow_custom(config): print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.") # Auto-save to custom_providers so it appears in the menu next time - _save_custom_provider(effective_url, effective_key, model_name or "", - context_length=context_length, name=display_name) + _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: @@ -1689,6 +2682,7 @@ def _auto_provider_name(base_url: str) -> str: 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] @@ -1701,8 +2695,9 @@ def _auto_provider_name(base_url: str) -> str: return name -def _save_custom_provider(base_url, api_key="", model="", context_length=None, - name=None): +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 @@ -1718,7 +2713,9 @@ def _save_custom_provider(base_url, api_key="", model="", context_length=None, # Check if this URL is already saved — update model/context_length if so for entry in providers: - if isinstance(entry, dict) and entry.get("base_url", "").rstrip("/") == base_url.rstrip("/"): + 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 @@ -1750,7 +2747,7 @@ def _save_custom_provider(base_url, api_key="", model="", context_length=None, providers.append(entry) cfg["custom_providers"] = providers save_config(cfg) - print(f" 💾 Saved to custom providers as \"{name}\" (edit in config.yaml)") + print(f' 💾 Saved to custom providers as "{name}" (edit in config.yaml)') def _remove_custom_provider(config): @@ -1778,15 +2775,20 @@ def _remove_custom_provider(config): 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"), + [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, + 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): @@ -1806,8 +2808,10 @@ def _remove_custom_provider(config): 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.") + 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): @@ -1845,19 +2849,23 @@ def _model_flow_named_custom(config, provider_info): 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 + 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_items, + cursor_index=default_idx, + menu_cursor="-> ", + menu_cursor_style=("fg_green", "bold"), menu_highlight_style=("fg_green",), - cycle_cursor=True, clear_screen=False, + 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): @@ -1970,7 +2978,11 @@ def _set_reasoning_effort(config, effort: str) -> None: 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())) + 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) @@ -2012,6 +3024,7 @@ def _label(effort): ) idx = menu.show() from hermes_cli.curses_ui import flush_stdin + flush_stdin() if idx is None: return None @@ -2080,7 +3093,9 @@ def _model_flow_copilot(config, current_model=""): 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( + " → 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") @@ -2099,6 +3114,7 @@ def _model_flow_copilot(config, current_model=""): 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) @@ -2113,6 +3129,7 @@ def _model_flow_copilot(config, current_model=""): elif choice == "2": try: import getpass + new_key = getpass.getpass(" Token (COPILOT_GITHUB_TOKEN): ").strip() except (KeyboardInterrupt, EOFError): print() @@ -2123,6 +3140,7 @@ def _model_flow_copilot(config, current_model=""): # Validate token type try: from hermes_cli.copilot_auth import validate_copilot_token + valid, msg = validate_copilot_token(new_key) if not valid: print(f" ✗ {msg}") @@ -2151,23 +3169,34 @@ def _model_flow_copilot(config, current_model=""): 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 + 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( + " ⚠ 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) + selected = _prompt_model_selection( + model_list, current_model=normalized_current_model + ) else: try: selected = input("Model name: ").strip() @@ -2175,11 +3204,14 @@ def _model_flow_copilot(config, current_model=""): selected = None if selected: - selected = normalize_copilot_model_id( - selected, - catalog=catalog, - api_key=api_key, - ) or 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( @@ -2246,7 +3278,9 @@ def _model_flow_copilot_acp(config, current_model=""): 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" + 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`.") @@ -2260,7 +3294,9 @@ def _model_flow_copilot_acp(config, current_model=""): 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.") + 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 @@ -2273,11 +3309,14 @@ def _model_flow_copilot_acp(config, current_model=""): 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 + 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")] @@ -2285,7 +3324,9 @@ def _model_flow_copilot_acp(config, current_model=""): else: model_list = _PROVIDER_MODELS.get("copilot", []) if model_list: - print(" ⚠ Could not auto-detect models from GitHub Copilot — showing defaults.") + 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: @@ -2303,11 +3344,14 @@ def _model_flow_copilot_acp(config, current_model=""): print("No change.") return - selected = normalize_copilot_model_id( - selected, - catalog=catalog, - api_key=catalog_api_key, - ) or selected + selected = ( + normalize_copilot_model_id( + selected, + catalog=catalog, + api_key=catalog_api_key, + ) + or selected + ) _save_model_choice(selected) cfg = load_config() @@ -2333,10 +3377,18 @@ def _model_flow_kimi(config, current_model=""): 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, + 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, ) - from hermes_cli.config import get_env_value, save_env_value, load_config, save_config provider_id = "kimi-coding" pconfig = PROVIDER_REGISTRY[provider_id] @@ -2355,6 +3407,7 @@ def _model_flow_kimi(config, current_model=""): if key_env: try: import getpass + new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip() except (KeyboardInterrupt, EOFError): print() @@ -2383,56 +3436,339 @@ def _model_flow_kimi(config, current_model=""): save_env_value(base_url_env, "") print() - # Step 3: Model selection — show appropriate models for the endpoint - if is_coding_plan: - # Coding Plan models (kimi-for-coding first) - model_list = [ - "kimi-for-coding", - "kimi-k2.5", - "kimi-k2-thinking", - "kimi-k2-thinking-turbo", + # Step 3: Model selection — show appropriate models for the endpoint + if is_coding_plan: + # Coding Plan models (kimi-k2.6 first) + model_list = [ + "kimi-k2.6", + "kimi-k2.5", + "kimi-for-coding", + "kimi-k2-thinking", + "kimi-k2-thinking-turbo", + ] + else: + # Legacy Moonshot models (excludes Coding Plan-only models) + 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) + + # Update config with provider and base URL + 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) # let runtime auto-detect from URL + 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 _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" + + # Prompt for API key + 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 selection — use static list (mantle doesn't need boto3 for discovery) + 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) + + # Save as custom provider pointing to bedrock-mantle + 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) # chat_completions is the default + + # Also save region in bedrock config for reference + bedrock_cfg = cfg.get("bedrock", {}) + if not isinstance(bedrock_cfg, dict): + bedrock_cfg = {} + bedrock_cfg["region"] = region + cfg["bedrock"] = bedrock_cfg + + # Save the API key env var name so hermes knows where to find it + 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 + + # 1. Check for AWS credentials + 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() + + # 2. Region selection + 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 + + # 2b. Authentication mode + 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 + + # 3. Model discovery — try live API first, fall back to static list + 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) + + # Deduplicate: prefer inference profiles (us.*, global.*) over bare + # foundation model IDs. + 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: - # Legacy Moonshot models (excludes Coding Plan-only models) - model_list = _PROVIDER_MODELS.get("moonshot", []) + 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 + # 4. Model selection if model_list: selected = _prompt_model_selection(model_list, current_model=current_model) else: try: - selected = input("Enter model name: ").strip() + selected = input(" Model ID: ").strip() except (KeyboardInterrupt, EOFError): selected = None if selected: _save_model_choice(selected) - # Update config with provider and base URL 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) # let runtime auto-detect from URL + model["provider"] = "bedrock" + model["base_url"] = f"https://bedrock-runtime.{region}.amazonaws.com" + model.pop("api_mode", None) # bedrock_converse is auto-detected + + 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() - endpoint_label = "Kimi Coding" if is_coding_plan else "Moonshot" - print(f"Default model set to: {selected} (via {endpoint_label})") + print(f" Default model set to: {selected} (via AWS Bedrock, {region})") else: - print("No change.") + 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, + 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 + 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 "" @@ -2450,6 +3786,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): if key_env: try: import getpass + new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip() except (KeyboardInterrupt, EOFError): print() @@ -2477,7 +3814,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): 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.") + print( + " Invalid URL — must start with http:// or https://. Keeping current value." + ) else: save_env_value(base_url_env, override) effective_base = override @@ -2486,37 +3825,58 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): # 1. models.dev registry (cached, filtered for agentic/tool-capable models) # 2. Curated static fallback list (offline insurance) # 3. Live /models endpoint probe (small providers without models.dev data) - curated = _PROVIDER_MODELS.get(provider_id, []) - - # Try models.dev first — returns tool-capable models, filtered for noise - mdev_models: list = [] - try: - from agent.models_dev import list_agentic_models - mdev_models = list_agentic_models(provider_id) - except Exception: - pass + # + # Ollama Cloud: dedicated merged discovery (live API + models.dev + disk cache) + if provider_id == "ollama-cloud": + from hermes_cli.models import fetch_ollama_cloud_models - if mdev_models: - model_list = mdev_models - print(f" Found {len(model_list)} model(s) from models.dev registry") - elif curated and len(curated) >= 8: - # Curated list is substantial — use it directly, skip live probe - 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 = 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, []) + + # Try models.dev first — returns tool-capable models, filtered for noise + 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: + model_list = mdev_models + print(f" Found {len(model_list)} model(s) from models.dev registry") + elif curated and len(curated) >= 8: + # Curated list is substantial — use it directly, skip live probe model_list = curated - if model_list: - print(f" Showing {len(model_list)} curated models — use \"Enter custom model name\" for others.") - # else: no defaults either, will fall through to raw input + 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.' + ) + # else: no defaults either, will fall through to raw input if provider_id in {"opencode-zen", "opencode-go"}: - model_list = [normalize_opencode_model_id(provider_id, mid) for mid in model_list] + 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)) @@ -2572,13 +3932,15 @@ def _activate_claude_code_credentials_if_available() -> bool: except Exception: creds = None if creds and ( - is_claude_code_token_valid(creds) - or bool(creds.get("refreshToken")) + 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.") + + print( + f" Hermes will use Claude's credential store directly instead of copying a setup-token into {_dhh_fn()}/.env." + ) return True return False @@ -2601,7 +3963,10 @@ def _activate_claude_code_credentials_if_available() -> bool: print() try: import getpass - manual_token = getpass.getpass(" Paste setup-token (or Enter to cancel): ").strip() + + manual_token = getpass.getpass( + " Paste setup-token (or Enter to cancel): " + ).strip() except (KeyboardInterrupt, EOFError): print() return False @@ -2629,6 +3994,7 @@ def _activate_claude_code_credentials_if_available() -> bool: print() try: import getpass + token = getpass.getpass(" Setup-token (or Enter to cancel): ").strip() except (KeyboardInterrupt, EOFError): print() @@ -2644,21 +4010,29 @@ def _activate_claude_code_credentials_if_available() -> bool: 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, + _prompt_model_selection, + _save_model_choice, deactivate_provider, ) from hermes_cli.config import ( - save_env_value, load_config, save_config, + save_env_value, + load_config, + save_config, save_anthropic_api_key, ) from hermes_cli.models import _PROVIDER_MODELS # Check ALL credential sources 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 + 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 @@ -2711,10 +4085,11 @@ def _model_flow_anthropic(config, current_model=""): elif choice == "2": print() - print(" Get an API key at: https://console.anthropic.com/settings/keys") + 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() @@ -2765,60 +4140,76 @@ def _model_flow_anthropic(config, current_model=""): def cmd_login(args): """Authenticate Hermes CLI with a provider.""" from hermes_cli.auth import login_command + login_command(args) def cmd_logout(args): """Clear provider authentication.""" from hermes_cli.auth import logout_command + logout_command(args) def cmd_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) @@ -2826,15 +4217,18 @@ 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) @@ -2842,13 +4236,14 @@ def cmd_version(args): """Show version.""" print(f"Hermes Agent v{__version__} ({__release_date__})") print(f"Project: {PROJECT_ROOT}") - + # Show Python version print(f"Python: {sys.version.split()[0]}") - + # Check for key dependencies try: import openai + print(f"OpenAI SDK: {openai.__version__}") except ImportError: print("OpenAI SDK: Not installed") @@ -2857,6 +4252,7 @@ def cmd_version(args): 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" @@ -2874,6 +4270,7 @@ def cmd_uninstall(args): """Uninstall Hermes Agent.""" _require_tty("uninstall") from hermes_cli.uninstall import run_uninstall + run_uninstall(args) @@ -2891,13 +4288,13 @@ def _clear_bytecode_cache(root: Path) -> int: for dirpath, dirnames, _ in os.walk(root): # Skip venv / node_modules / .git entirely dirnames[:] = [ - d for d in dirnames + d + for d in dirnames if d not in ("venv", ".venv", "node_modules", ".git", ".worktrees") ] if os.path.basename(dirpath) == "__pycache__": try: - import shutil as _shutil - _shutil.rmtree(dirpath) + shutil.rmtree(dirpath) removed += 1 except OSError: pass @@ -2936,7 +4333,6 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) tmp.replace(prompt_path) # Poll for response - import time as _time deadline = _time.monotonic() + timeout while _time.monotonic() < deadline: if response_path.exists(): @@ -2968,7 +4364,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: """ if not (web_dir / "package.json").exists(): return True - import shutil + npm = shutil.which("npm") if not npm: if fatal: @@ -2978,15 +4374,19 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: 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)")) + 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)")) + 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 @@ -2996,34 +4396,40 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: 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 + + Used on Windows when git file I/O is broken (antivirus, NTFS filter drivers causing 'Invalid argument' errors on file creation). """ - import shutil import tempfile import zipfile from urllib.request import urlretrieve - + branch = "main" - zip_url = f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip" - + 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: + with zipfile.ZipFile(zip_path, "r") as zf: # Validate paths to prevent zip-slip (path traversal) 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") + 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) - + # GitHub ZIPs extract to hermes-agent-/ extracted = os.path.join(tmp_dir, f"hermes-agent-{branch}") if not os.path.isdir(extracted): @@ -3033,9 +4439,9 @@ def _update_via_zip(args): if os.path.isdir(candidate) and d != "__MACOSX": extracted = candidate break - + # Copy updated files over existing installation, preserving venv/node_modules/.git - preserve = {'venv', 'node_modules', '.git', '.env'} + preserve = {"venv", "node_modules", ".git", ".env"} update_count = 0 for item in os.listdir(extracted): if item in preserve: @@ -3049,12 +4455,12 @@ def _update_via_zip(args): else: shutil.copy2(src, dst) update_count += 1 - + print(f"✓ Updated {update_count} items from ZIP") - + # Cleanup shutil.rmtree(tmp_dir, ignore_errors=True) - + except Exception as e: print(f"✗ ZIP update failed: {e}") sys.exit(1) @@ -3062,13 +4468,15 @@ def _update_via_zip(args): # Clear stale bytecode after ZIP extraction removed = _clear_bytecode_cache(PROJECT_ROOT) if removed: - print(f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}") - + print( + f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}" + ) + # Reinstall Python dependencies. Prefer .[all], but if one optional extra # breaks on this machine, keep base deps and reinstall the remaining extras # individually so update does not silently strip working capabilities. print("→ Updating Python dependencies...") - import subprocess + uv_bin = shutil.which("uv") if uv_bin: uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} @@ -3080,7 +4488,12 @@ def _update_via_zip(args): # ensurepip before trying the editable install. pip_cmd = [sys.executable, "-m", "pip"] try: - subprocess.run(pip_cmd + ["--version"], cwd=PROJECT_ROOT, check=True, capture_output=True) + 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"], @@ -3089,18 +4502,21 @@ def _update_via_zip(args): ) _install_python_dependencies_with_optional_fallback(pip_cmd) - # Build web UI frontend (optional — requires npm) + _update_node_dependencies() _build_web_ui(PROJECT_ROOT / "web") # Sync skills 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'])}") + 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"): @@ -3109,7 +4525,7 @@ def _update_via_zip(args): print(" ✓ Skills are up to date") except Exception: pass - + print() print("✓ Update complete!") @@ -3141,7 +4557,9 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st from datetime import datetime, timezone - stash_name = datetime.now(timezone.utc).strftime("hermes-update-autostash-%Y%m%d-%H%M%S") + 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], @@ -3158,8 +4576,9 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st return stash_ref - -def _resolve_stash_selector(git_cmd: list[str], cwd: Path, stash_ref: str) -> Optional[str]: +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, @@ -3174,15 +4593,19 @@ def _resolve_stash_selector(git_cmd: list[str], cwd: Path, stash_ref: str) -> Op 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.") +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}}") - + print( + f" Look for commit {stash_ref}, then drop its selector with: git stash drop stash@{{N}}" + ) def _restore_stashed_changes( @@ -3195,7 +4618,9 @@ def _restore_stashed_changes( if prompt_user: print() print("⚠ Local changes were stashed before updating.") - print(" Restoring them may reapply local customizations onto the updated codebase.") + 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: @@ -3259,8 +4684,12 @@ def _restore_stashed_changes( 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( + "⚠ 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( @@ -3270,18 +4699,23 @@ def _restore_stashed_changes( text=True, ) if drop.returncode != 0: - print("⚠ Local changes were restored, but Hermes couldn't drop the saved stash entry.") + 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( + " 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 + # ========================================================================= # Fork detection and upstream management for `hermes update` # ========================================================================= @@ -3376,6 +4810,7 @@ def _count_commits_between(git_cmd: list[str], cwd: Path, base: str, head: str) 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() @@ -3383,6 +4818,7 @@ 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 @@ -3427,7 +4863,9 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: 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() + response = ( + input("Add official repo as 'upstream' remote? [Y/n]: ").strip().lower() + ) except (EOFError, KeyboardInterrupt): print() response = "n" @@ -3435,13 +4873,17 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: 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") + 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.") + print( + " Skipped. Run 'git remote add upstream https://github.com/NousResearch/hermes-agent.git' to add later." + ) _mark_skip_upstream_prompt() return @@ -3461,7 +4903,9 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: # Compare origin/main with upstream/main origin_ahead = _count_commits_between(git_cmd, cwd, "upstream/main", "origin/main") - upstream_ahead = _count_commits_between(git_cmd, cwd, "origin/main", "upstream/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.") @@ -3493,7 +4937,9 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: check=True, ) except subprocess.CalledProcessError: - print(" ✗ Failed to pull from upstream. You may need to resolve conflicts manually.") + print( + " ✗ Failed to pull from upstream. You may need to resolve conflicts manually." + ) return print(" ✓ Updated from upstream") @@ -3503,7 +4949,9 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: 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( + " ℹ 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.") @@ -3517,6 +4965,7 @@ def _invalidate_update_cache(): homes = [] # Default profile home (Docker-aware — uses /opt/data in Docker) from hermes_constants import get_default_hermes_root + default_home = get_default_hermes_root() homes.append(default_home) # Named profiles under /profiles/ @@ -3544,6 +4993,7 @@ def _load_installable_optional_extras() -> list[str]: """ try: import tomllib + with (PROJECT_ROOT / "pyproject.toml").open("rb") as handle: project = tomllib.load(handle).get("project", {}) except Exception: @@ -3566,7 +5016,6 @@ def _load_installable_optional_extras() -> list[str]: return referenced - def _install_python_dependencies_with_optional_fallback( install_cmd_prefix: list[str], *, @@ -3582,7 +5031,9 @@ def _install_python_dependencies_with_optional_fallback( ) return except subprocess.CalledProcessError: - print(" ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually...") + print( + " ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..." + ) subprocess.run( install_cmd_prefix + ["install", "-e", ".", "--quiet"], @@ -3591,29 +5042,247 @@ def _install_python_dependencies_with_optional_fallback( 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) + 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): + # Mirror to the log file first — it's the most reliable destination. + if self._log is not None: + try: + self._log.write(data) + except Exception: + # Log errors should never abort the update. + 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): + # Terminal vanished (SSH disconnect, shell close). Stop trying + # to write to it, but keep the update running. + 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): + # Some tools probe fileno(); defer to the underlying stream and let + # callers handle failures (same behaviour as the unwrapped stream). + 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 + + # (1) Ignore SIGHUP for the remainder of this process. + if hasattr(_signal, "SIGHUP"): + try: + _signal.signal(_signal.SIGHUP, _signal.SIG_IGN) + except (ValueError, OSError): + # Called from a non-main thread — not fatal. The update still + # runs, just without hangup protection. + pass + + # (2) Mirror output to update.log and wrap stdio for broken-pipe + # tolerance. Any failure here is non-fatal; we just skip the wrap. + try: + # Late-bound import so tests can monkeypatch + # hermes_cli.config.get_hermes_home to simulate setup failure. + 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: + # Leave stdio untouched on any setup failure. Update continues + # without mirroring. + state["log_file"] = None + + return state - 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 _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.""" - import shutil + """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(): @@ -3621,31 +5290,60 @@ def cmd_update(args): return gateway_mode = getattr(args, "gateway", False) + + # Protect against mid-update terminal disconnects (SIGHUP) and tolerate + # writes to a closed stdout. No-op in gateway mode. See + # _install_hangup_protection for rationale. + _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``.""" # In gateway mode, use file-based IPC for prompts instead of stdin - gw_input_fn = (lambda prompt, default="": _gateway_prompt(prompt, default)) if gateway_mode else None - + gw_input_fn = ( + (lambda prompt, default="": _gateway_prompt(prompt, default)) + if gateway_mode + else None + ) + print("⚕ Updating Hermes Agent...") print() - + # Try git-based update first, fall back to ZIP download on Windows # when git file I/O is broken (antivirus, NTFS filter drivers, etc.) use_zip_update = False - git_dir = PROJECT_ROOT / '.git' - + 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") + print( + " curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash" + ) sys.exit(1) - + # On Windows, git can fail with "unable to write loose object file: Invalid argument" # due to filesystem atomicity issues. Set the recommended workaround. if sys.platform == "win32" and git_dir.exists(): subprocess.run( - ["git", "-c", "windows.appendAtomically=false", "config", "windows.appendAtomically", "false"], - cwd=PROJECT_ROOT, check=False, capture_output=True + [ + "git", + "-c", + "windows.appendAtomically=false", + "config", + "windows.appendAtomically", + "false", + ], + cwd=PROJECT_ROOT, + check=False, + capture_output=True, ) # Build git command once — reused for fork detection and the update itself. @@ -3682,8 +5380,12 @@ def cmd_update(args): 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.") + 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: @@ -3705,7 +5407,11 @@ def cmd_update(args): # If user is on a non-main branch or detached HEAD, switch to main if current_branch != "main": - label = "detached HEAD" if current_branch == "HEAD" else f"branch '{current_branch}'" + label = ( + "detached HEAD" + if current_branch == "HEAD" + else f"branch '{current_branch}'" + ) print(f" ⚠ Currently on {label} — switching to main for update...") # Stash before checkout so uncommitted work isn't lost auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) @@ -3738,14 +5444,19 @@ def cmd_update(args): # Restore stash and switch back to original branch if we moved if auto_stash_ref is not None: _restore_stashed_changes( - git_cmd, PROJECT_ROOT, auto_stash_ref, + 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, + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, ) print("✓ Already up to date!") return @@ -3765,7 +5476,9 @@ def cmd_update(args): # ff-only failed — local and remote have diverged (e.g. upstream # force-pushed or rebase). Since local changes are already # stashed, reset to match the remote exactly. - print(" ⚠ Fast-forward not possible (history diverged), resetting to match remote...") + 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, @@ -3776,7 +5489,9 @@ def cmd_update(args): 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") + print( + " Try manually: git fetch origin && git reset --hard origin/main" + ) sys.exit(1) update_succeeded = True finally: @@ -3784,7 +5499,9 @@ def cmd_update(args): # Don't attempt stash restore if the code update itself failed — # working tree is in an unknown state. if not update_succeeded: - print(f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})") + print( + f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})" + ) print(f" Restore manually with: git stash apply") else: _restore_stashed_changes( @@ -3794,7 +5511,7 @@ def cmd_update(args): prompt_user=prompt_for_restore, input_fn=gw_input_fn, ) - + _invalidate_update_cache() # Clear stale .pyc bytecode cache — prevents ImportError on gateway @@ -3802,12 +5519,14 @@ def cmd_update(args): # the old bytecode (e.g. get_hermes_home added to hermes_constants). removed = _clear_bytecode_cache(PROJECT_ROOT) if removed: - print(f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}") + print( + f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}" + ) # Fork upstream sync logic (only for main branch on forks) if is_fork and branch == "main": _sync_with_upstream_if_needed(git_cmd, PROJECT_ROOT) - + # Reinstall Python dependencies. Prefer .[all], but if one optional extra # breaks on this machine, keep base deps and reinstall the remaining extras # individually so update does not silently strip working capabilities. @@ -3815,7 +5534,9 @@ def cmd_update(args): 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) + _install_python_dependencies_with_optional_fallback( + [uv_bin, "pip"], env=uv_env + ) else: # Use sys.executable to explicitly call the venv's pip module, # avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu. @@ -3823,7 +5544,12 @@ def cmd_update(args): # ensurepip before trying the editable install. pip_cmd = [sys.executable, "-m", "pip"] try: - subprocess.run(pip_cmd + ["--version"], cwd=PROJECT_ROOT, check=True, capture_output=True) + 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"], @@ -3831,20 +5557,13 @@ def cmd_update(args): check=True, ) _install_python_dependencies_with_optional_fallback(pip_cmd) - - # Check for Node.js deps - if (PROJECT_ROOT / "package.json").exists(): - import shutil - if shutil.which("npm"): - print("→ Updating Node.js dependencies...") - subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False) - - # Build web UI frontend (optional — requires npm) + + _update_node_dependencies() _build_web_ui(PROJECT_ROOT / "web") print() print("✓ Code updated!") - + # After git pull, source files on disk are newer than cached Python # modules in this process. Reload hermes_constants so that any lazy # import executed below (skills sync, gateway restart) sees new @@ -3852,20 +5571,24 @@ def cmd_update(args): try: import importlib import hermes_constants as _hc + importlib.reload(_hc) except Exception: pass # non-fatal — worst case a lazy import fails gracefully - + # Sync bundled skills (copies new, updates changed, respects user deletions) 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'])}") + 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"): @@ -3877,7 +5600,12 @@ def cmd_update(args): # Sync bundled skills to all other profiles try: - from hermes_cli.profiles import list_profiles, get_active_profile_name, seed_profile_skills + 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: @@ -3891,9 +5619,12 @@ def cmd_update(args): 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") + 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" @@ -3906,6 +5637,7 @@ def cmd_update(args): # Sync Honcho host blocks to all profiles 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)") @@ -3915,46 +5647,60 @@ def cmd_update(args): # Check for config migrations print() print("→ Checking configuration for new options...") - + from hermes_cli.config import ( - get_missing_env_vars, get_missing_config_fields, - check_config_version, migrate_config + 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") + 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() + 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.") + 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() + response = ( + input("Would you like to configure them now? [Y/n]: ") + .strip() + .lower() + ) except EOFError: response = "n" - - if response in ('', 'y', 'yes'): + + if response in ("", "y", "yes"): print() # In gateway mode, run auto-migrations only (no input() prompts # for API keys which would hang the detached process). results = migrate_config(interactive=not gateway_mode, quiet=False) - + if results["env_added"] or results["config_added"]: print() print("✓ Configuration updated!") @@ -3965,10 +5711,10 @@ def cmd_update(args): print("Skipped. Run 'hermes config migrate' later to configure.") else: print(" ✓ Configuration is up to date") - + print() print("✓ Update complete!") - + # Write exit code *before* the gateway restart attempt. # When running as ``hermes update --gateway`` (spawned by the gateway's # /update command), this process lives inside the gateway's systemd @@ -3988,13 +5734,15 @@ def cmd_update(args): _exit_code_path.write_text("0") except OSError: pass - + # Auto-restart ALL gateways after update. # The code update (git pull) is shared across all profiles, so every # running gateway needs restarting to pick up the new code. try: from hermes_cli.gateway import ( - is_macos, supports_systemd_services, _ensure_user_systemd_env, + is_macos, + supports_systemd_services, + _ensure_user_systemd_env, find_gateway_pids, _get_service_pids, ) @@ -4011,46 +5759,113 @@ def cmd_update(args): except Exception: pass - for scope, scope_cmd in [("user", ["systemctl", "--user"]), ("system", ["systemctl"])]: + 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, + 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] # e.g. hermes-gateway.service or hermes-gateway-coder.service + unit = parts[ + 0 + ] # e.g. hermes-gateway.service or hermes-gateway-coder.service if not unit.endswith(".service"): continue svc_name = unit.removesuffix(".service") # Check if active check = subprocess.run( scope_cmd + ["is-active", svc_name], - capture_output=True, text=True, timeout=5, + capture_output=True, + text=True, + timeout=5, ) if check.stdout.strip() == "active": restart = subprocess.run( scope_cmd + ["restart", svc_name], - capture_output=True, text=True, timeout=15, + capture_output=True, + text=True, + timeout=15, ) if restart.returncode == 0: - restarted_services.append(svc_name) + # Verify the service actually survived the + # restart. systemctl restart returns 0 even + # if the new process crashes immediately. + _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: + # Retry once — transient startup failures + # (stale module cache, import race) often + # resolve on the second attempt. + 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()}") + print( + f" ⚠ Failed to restart {svc_name}: {restart.stderr.strip()}" + ) except (FileNotFoundError, subprocess.TimeoutExpired): pass # --- Launchd services (macOS) --- if is_macos(): try: - from hermes_cli.gateway import launchd_restart, get_launchd_label, get_launchd_plist_path + 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, + capture_output=True, + text=True, + timeout=5, ) if check.returncode == 0: try: @@ -4067,7 +5882,9 @@ def cmd_update(args): # Exclude PIDs that belong to just-restarted services so we don't # immediately kill the process that systemd/launchd just spawned. service_pids = _get_service_pids() - manual_pids = find_gateway_pids(exclude_pids=service_pids, all_profiles=True) + manual_pids = find_gateway_pids( + exclude_pids=service_pids, all_profiles=True + ) for pid in manual_pids: try: os.kill(pid, _signal.SIGTERM) @@ -4084,7 +5901,9 @@ def cmd_update(args): print(" Restart manually: hermes gateway run") # Also restart for each profile if needed if len(killed_pids) > 1: - print(" (or: hermes -p gateway run for each profile)") + print( + " (or: hermes -p gateway run for each profile)" + ) if not restarted_services and not killed_pids: # No gateways were running — nothing to do @@ -4092,11 +5911,40 @@ def cmd_update(args): except Exception as e: logger.debug("Gateway restart during update failed: %s", e) - + + # Warn if legacy Hermes gateway unit files are still installed. + # When both hermes.service (from a pre-rename install) and the + # current hermes-gateway.service are enabled, they SIGTERM-fight + # for the same bot token (see PR #11909). Flagging here means + # every `hermes update` surfaces the issue until the user migrates. + 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}") @@ -4120,10 +5968,41 @@ def _coalesce_session_name_args(argv: list) -> list: 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", + "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"} @@ -4136,7 +6015,11 @@ def _coalesce_session_name_args(argv: list) -> list: i += 1 # Collect subsequent non-flag, non-subcommand tokens as one name parts: list = [] - while i < len(argv) and not argv[i].startswith("-") and argv[i] not in _SUBCOMMANDS: + while ( + i < len(argv) + and not argv[i].startswith("-") + and argv[i] not in _SUBCOMMANDS + ): parts.append(argv[i]) i += 1 if parts: @@ -4150,10 +6033,17 @@ def _coalesce_session_name_args(argv: list) -> list: 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, + 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 @@ -4170,8 +6060,13 @@ def cmd_profile(args): 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"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}") @@ -4192,7 +6087,11 @@ def cmd_profile(args): print(f" {'─' * 15} {'─' * 27} {'─' * 11} {'─' * 12}") for p in profiles: - marker = " ◆" if (p.name == active or (active == "default" and p.is_default)) else " " + 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" @@ -4233,7 +6132,9 @@ def cmd_profile(args): 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() + source_label = ( + getattr(args, "clone_from", None) or get_active_profile_name() + ) if clone_all: print(f"Full copy from {source_label}.") else: @@ -4243,6 +6144,7 @@ def cmd_profile(args): 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: @@ -4255,14 +6157,20 @@ def cmd_profile(args): copied = len(result.get("copied", [])) print(f"{copied} bundled skills synced.") else: - print("⚠ Skills could not be seeded. Run `{} update` to retry.".format(name)) + print( + "⚠ Skills could not be seeded. Run `{} update` to retry.".format( + name + ) + ) # Create wrapper alias 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 ") + print( + f" Choose a custom alias: hermes profile alias {name} --name " + ) print(f" Or access via flag: hermes -p {name} chat") else: wrapper_path = create_wrapper_script(name) @@ -4270,7 +6178,9 @@ def cmd_profile(args): 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" Add to your shell config (~/.bashrc or ~/.zshrc):" + ) print(f' export PATH="$HOME/.local/bin:$PATH"') # Profile dir for display @@ -4288,7 +6198,9 @@ def cmd_profile(args): 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"\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() @@ -4308,7 +6220,14 @@ def cmd_profile(args): 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 + 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) @@ -4324,8 +6243,12 @@ def cmd_profile(args): 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'}") + 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() @@ -4336,6 +6259,7 @@ def cmd_profile(args): 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) @@ -4363,6 +6287,7 @@ def cmd_profile(args): 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}") @@ -4373,6 +6298,7 @@ def cmd_profile(args): elif action == "export": from hermes_cli.profiles import export_profile + name = args.profile_name output = args.output or f"{name}.tar.gz" try: @@ -4384,8 +6310,11 @@ def cmd_profile(args): elif action == "import": from hermes_cli.profiles import import_profile + try: - profile_dir = import_profile(args.archive, name=getattr(args, "import_name", None)) + profile_dir = import_profile( + args.archive, name=getattr(args, "import_name", None) + ) name = profile_dir.name print(f"✓ Imported profile '{name}' at {profile_dir}") @@ -4408,28 +6337,34 @@ def cmd_dashboard(args): import uvicorn # noqa: F401 except ImportError: print("Web UI dependencies not installed.") - print("Install them with: pip install hermes-agent[web]") + print(f"Install them with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'") sys.exit(1) - if not _build_web_ui(PROJECT_ROOT / "web", fatal=True): - 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): +def cmd_completion(args, parser=None): """Print shell completion script.""" - from hermes_cli.profiles import generate_bash_completion, generate_zsh_completion + from hermes_cli.completion import generate_bash, generate_zsh, generate_fish + shell = getattr(args, "shell", "bash") if shell == "zsh": - print(generate_zsh_completion()) + print(generate_zsh(parser)) + elif shell == "fish": + print(generate_fish(parser)) else: - print(generate_bash_completion()) + print(generate_bash(parser)) def cmd_logs(args): @@ -4492,152 +6427,221 @@ def main(): For more help on a command: hermes --help -""" +""", ) - + parser.add_argument( - "--version", "-V", - action="store_true", - help="Show version and exit" + "--version", "-V", action="store_true", help="Show version and exit" ) parser.add_argument( - "--resume", "-r", + "--resume", + "-r", metavar="SESSION", default=None, - help="Resume a previous session by ID or title" + help="Resume a previous session by ID or title", ) parser.add_argument( - "--continue", "-c", + "--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" + 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( - "--worktree", "-w", + "--accept-hooks", action="store_true", default=False, - help="Run in an isolated git worktree (for parallel agents)" + 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", + "--skills", + "-s", action="append", default=None, - help="Preload one or more skills for the session (repeat flag or comma-separate)" + 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)" + 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" + help="Include the session ID in the agent's system prompt", + ) + 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 command # ========================================================================= chat_parser = subparsers.add_parser( "chat", help="Interactive chat with the agent", - description="Start an interactive chat session with Hermes Agent" + description="Start an interactive chat session with Hermes Agent", ) chat_parser.add_argument( - "-q", "--query", - help="Single query (non-interactive mode)" + "-q", "--query", help="Single query (non-interactive mode)" ) chat_parser.add_argument( - "--image", - help="Optional local image path to attach to a single query" + "--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)" + "-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)" ) chat_parser.add_argument( - "-t", "--toolsets", - help="Comma-separated toolsets to enable" + "-t", "--toolsets", help="Comma-separated toolsets to enable" ) chat_parser.add_argument( - "-s", "--skills", + "-s", + "--skills", action="append", default=argparse.SUPPRESS, - help="Preload one or more skills for the session (repeat flag or comma-separate)" + 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", "huggingface", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "xiaomi", "arcee"], + choices=[ + "auto", + "openrouter", + "nous", + "openai-codex", + "copilot-acp", + "copilot", + "anthropic", + "gemini", + "xai", + "ollama-cloud", + "huggingface", + "zai", + "kimi-coding", + "kimi-coding-cn", + "minimax", + "minimax-cn", + "kilocode", + "xiaomi", + "arcee", + "nvidia", + ], default=None, - help="Inference provider (default: auto)" + help="Inference provider (default: auto)", ) chat_parser.add_argument( - "-v", "--verbose", - action="store_true", - help="Verbose output" + "-v", "--verbose", action="store_true", help="Verbose output" ) chat_parser.add_argument( - "-Q", "--quiet", + "-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." + 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", + "--resume", + "-r", metavar="SESSION_ID", default=argparse.SUPPRESS, - help="Resume a previous session by ID (shown on exit)" + help="Resume a previous session by ID (shown on exit)", ) chat_parser.add_argument( - "--continue", "-c", + "--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" + 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( - "--worktree", "-w", + "--accept-hooks", action="store_true", default=argparse.SUPPRESS, - help="Run in an isolated git worktree (for parallel agents on the same repo)" + 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)" + 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)" + 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)" + 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" + help="Include the session ID in the agent's system prompt", ) 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." + 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) @@ -4647,45 +6651,42 @@ def main(): model_parser = subparsers.add_parser( "model", help="Select default model and provider", - description="Interactively select your inference provider and default model" + 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)" + 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)" + 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)" + 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" + "--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" + 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)" + 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" + "--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)" + help="Disable TLS verification for Nous login (testing only)", ) model_parser.set_defaults(func=cmd_model) @@ -4695,52 +6696,140 @@ def main(): gateway_parser = subparsers.add_parser( "gateway", help="Messaging gateway management", - description="Manage the messaging gateway (Telegram, Discord, WhatsApp)" + description="Manage the messaging gateway (Telegram, Discord, WhatsApp)", ) gateway_subparsers = gateway_parser.add_subparsers(dest="gateway_command") - + # gateway run (default) - gateway_run = gateway_subparsers.add_parser("run", help="Run gateway in foreground (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)") - + 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_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 = 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_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_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_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 = 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_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("--system", action="store_true", help="Target the Linux system-level gateway service") - + gateway_status.add_argument( + "--system", + action="store_true", + help="Target the Linux system-level gateway service", + ) + # gateway install - gateway_install = gateway_subparsers.add_parser("install", help="Install gateway as a systemd/launchd background 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_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_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_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 setup gateway_subparsers.add_parser("setup", help="Configure messaging platforms") + # gateway migrate-legacy + 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-.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 command # ========================================================================= @@ -4748,24 +6837,22 @@ def main(): "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" + "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" + 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)" + help="Non-interactive mode (use defaults/env vars)", ) setup_parser.add_argument( - "--reset", - action="store_true", - help="Reset configuration to defaults" + "--reset", action="store_true", help="Reset configuration to defaults" ) setup_parser.set_defaults(func=cmd_setup) @@ -4775,7 +6862,7 @@ def main(): whatsapp_parser = subparsers.add_parser( "whatsapp", help="Set up WhatsApp integration", - description="Configure WhatsApp and pair via QR code" + description="Configure WhatsApp and pair via QR code", ) whatsapp_parser.set_defaults(func=cmd_whatsapp) @@ -4785,51 +6872,43 @@ def main(): login_parser = subparsers.add_parser( "login", help="Authenticate with an inference provider", - description="Run OAuth device authorization flow for Hermes CLI" + 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)" + help="Provider to authenticate with (default: nous)", ) login_parser.add_argument( - "--portal-url", - help="Portal base URL (default: production portal)" + "--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)" + help="Inference API base URL (default: production inference API)", ) login_parser.add_argument( - "--scope", - default=None, - help="OAuth scope to request" + "--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" + 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)" + help="HTTP request timeout in seconds (default: 15)", ) login_parser.add_argument( - "--ca-bundle", - help="Path to CA bundle PEM file for TLS verification" + "--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)" + help="Disable TLS verification (testing only)", ) login_parser.set_defaults(func=cmd_login) @@ -4839,13 +6918,13 @@ def main(): logout_parser = subparsers.add_parser( "logout", help="Clear authentication for an inference provider", - description="Remove stored credentials and reset provider config" + 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)" + help="Provider to log out from (default: active provider)", ) logout_parser.set_defaults(func=cmd_logout) @@ -4855,24 +6934,50 @@ def main(): ) 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( + "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( + "--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( + "--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 = 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_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) @@ -4882,57 +6987,92 @@ def main(): status_parser = subparsers.add_parser( "status", help="Show status of all components", - description="Display status of Hermes Agent components" + description="Display status of Hermes Agent components", ) status_parser.add_argument( - "--all", - action="store_true", - help="Show all details (redacted for sharing)" + "--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)" + "--deep", action="store_true", help="Run deep checks (may take longer)" ) status_parser.set_defaults(func=cmd_status) - + # ========================================================================= # cron command # ========================================================================= cron_parser = subparsers.add_parser( - "cron", - help="Cron job management", - description="Manage scheduled tasks" + "cron", help="Cron job management", description="Manage scheduled tasks" ) cron_subparsers = cron_parser.add_subparsers(dest="cron_command") - + # cron list cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs") cron_list.add_argument("--all", action="store_true", help="Include disabled jobs") # cron create/add - 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 = 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( + "--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_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_edit = cron_subparsers.add_parser("edit", help="Edit an existing scheduled job") + 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_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.", + ) # lifecycle actions cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job") @@ -4941,18 +7081,24 @@ def main(): 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 = 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 = 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 status cron_subparsers.add_parser("status", help="Check if cron scheduler is running") # cron tick (mostly for debugging) - cron_subparsers.add_parser("tick", help="Run due jobs once and exit") - + cron_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) # ========================================================================= @@ -4965,39 +7111,131 @@ def main(): ) 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 = webhook_subparsers.add_parser( + "subscribe", aliases=["add"], help="Create a webhook subscription" + ) wh_sub.add_argument("name", help="Route name (used in URL: /webhooks/)") - 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( + "--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( + "--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") + 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 = 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 = 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)") + wh_test.add_argument( + "--payload", default="", help="JSON payload to send (default: test payload)" + ) webhook_parser.set_defaults(func=cmd_webhook) + # ========================================================================= + # hooks command — shell-hook inspection and management + # ========================================================================= + 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 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 command # ========================================================================= doctor_parser = subparsers.add_parser( "doctor", help="Check configuration and dependencies", - description="Diagnose issues with Hermes Agent setup" + description="Diagnose issues with Hermes Agent setup", ) doctor_parser.add_argument( - "--fix", - action="store_true", - help="Attempt to fix issues automatically" + "--fix", action="store_true", help="Attempt to fix issues automatically" ) doctor_parser.set_defaults(func=cmd_doctor) @@ -5008,12 +7246,12 @@ def main(): "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" + "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" + help="Show redacted API key prefixes (first/last 4 chars) instead of just set/not set", ) dump_parser.set_defaults(func=cmd_dump) @@ -5024,8 +7262,8 @@ def main(): "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.", + "upload a debug report (system info + recent logs) to a paste " + "service and get a shareable URL.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""\ Examples: @@ -5033,6 +7271,7 @@ def main(): 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 Delete a previously uploaded paste """, ) debug_sub = debug_parser.add_subparsers(dest="debug_command") @@ -5041,17 +7280,32 @@ def main(): help="Upload debug report to a paste service and print a shareable URL", ) share_parser.add_argument( - "--lines", type=int, default=200, + "--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, + "--expire", + type=int, + default=7, help="Paste expiry in days (default: 7)", ) share_parser.add_argument( - "--local", action="store_true", + "--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) # ========================================================================= @@ -5061,21 +7315,22 @@ def main(): "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." + "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-.zip)" + "-o", + "--output", + help="Output path for the zip file (default: ~/hermes-backup-.zip)", ) backup_parser.add_argument( - "-q", "--quick", + "-q", + "--quick", action="store_true", - help="Quick snapshot: only critical state files (config, state.db, .env, auth, cron)" + 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)" + "-l", "--label", help="Label for the snapshot (only used with --quick)" ) backup_parser.set_defaults(func=cmd_backup) @@ -5086,17 +7341,15 @@ def main(): "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" + "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( - "zipfile", - help="Path to the backup zip file" - ) - import_parser.add_argument( - "--force", "-f", + "--force", + "-f", action="store_true", - help="Overwrite existing files without confirmation" + help="Overwrite existing files without confirmation", ) import_parser.set_defaults(func=cmd_import) @@ -5106,49 +7359,55 @@ def main(): config_parser = subparsers.add_parser( "config", help="View and edit configuration", - description="Manage Hermes Agent configuration" + description="Manage Hermes Agent configuration", ) config_subparsers = config_parser.add_subparsers(dest="config_command") - + # config show (default) config_subparsers.add_parser("show", help="Show current configuration") - + # config edit config_subparsers.add_parser("edit", help="Open config file in editor") - + # config set config_set = config_subparsers.add_parser("set", help="Set a configuration value") - config_set.add_argument("key", nargs="?", help="Configuration key (e.g., model, terminal.backend)") + config_set.add_argument( + "key", nargs="?", help="Configuration key (e.g., model, terminal.backend)" + ) config_set.add_argument("value", nargs="?", help="Value to set") - + # config path config_subparsers.add_parser("path", help="Print config file path") - + # config env-path config_subparsers.add_parser("env-path", help="Print .env file path") - + # config check config_subparsers.add_parser("check", help="Check for missing/outdated config") - + # config migrate config_subparsers.add_parser("migrate", help="Update config with new options") - + config_parser.set_defaults(func=cmd_config) - + # ========================================================================= # pairing command # ========================================================================= pairing_parser = subparsers.add_parser( "pairing", help="Manage DM pairing codes for user authorization", - description="Approve or revoke user access via pairing codes" + 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 = 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") @@ -5159,6 +7418,7 @@ def main(): def cmd_pairing(args): from hermes_cli.pairing import pairing_command + pairing_command(args) pairing_parser.set_defaults(func=cmd_pairing) @@ -5169,58 +7429,158 @@ def cmd_pairing(args): 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." + 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_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( + "--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_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 = 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_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_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_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_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 = skills_subparsers.add_parser( + "uninstall", help="Remove a hub-installed skill" + ) skills_uninstall.add_argument("name", help="Skill name to remove") - skills_publish = skills_subparsers.add_parser("publish", help="Publish a skill to a registry") + skills_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_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") + 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 = 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 = 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") + 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") @@ -5231,16 +7591,21 @@ def cmd_pairing(args): tap_rm.add_argument("name", help="Tap name to remove") # config sub-action: interactive enable/disable - skills_subparsers.add_parser("config", help="Interactive skill configuration — enable/disable individual skills") + skills_subparsers.add_parser( + "config", + help="Interactive skill configuration — enable/disable individual skills", + ) def cmd_skills(args): # Route 'config' action to skills_config module - if getattr(args, 'skills_action', None) == 'config': + 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) @@ -5263,9 +7628,22 @@ def cmd_skills(args): help="Git URL or owner/repo shorthand (e.g. anpicasso/hermes-plugin-chrome-profiles)", ) plugins_install.add_argument( - "--force", "-f", action="store_true", + "--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 `", + ) plugins_update = plugins_subparsers.add_parser( "update", help="Pull latest changes for an installed plugin" @@ -5291,6 +7669,7 @@ def cmd_skills(args): def cmd_plugins(args): from hermes_cli.plugins_cmd import plugins_command + plugins_command(args) plugins_parser.set_defaults(func=cmd_plugins) @@ -5302,6 +7681,7 @@ def cmd_plugins(args): # ========================================================================= 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"], @@ -5311,8 +7691,7 @@ def cmd_plugins(args): ) cmd_info["setup_fn"](plugin_parser) except Exception as _exc: - import logging as _log - _log.getLogger(__name__).debug("Plugin CLI discovery failed: %s", _exc) + logging.getLogger(__name__).debug("Plugin CLI discovery failed: %s", _exc) # ========================================================================= # memory command @@ -5329,14 +7708,33 @@ def cmd_plugins(args): ), ) memory_sub = memory_parser.add_subparsers(dest="memory_command") - memory_sub.add_parser("setup", help="Interactive provider selection and configuration") + 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"] = {} @@ -5344,8 +7742,54 @@ def cmd_memory(args): 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")) + + # Check what exists + 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) @@ -5366,7 +7810,7 @@ def cmd_memory(args): tools_parser.add_argument( "--summary", action="store_true", - help="Print a summary of enabled tools per platform and exit" + help="Print a summary of enabled tools per platform and exit", ) tools_sub = tools_parser.add_subparsers(dest="tools_action") @@ -5376,7 +7820,8 @@ def cmd_memory(args): help="Show all tools and their enabled/disabled status", ) tools_list_p.add_argument( - "--platform", default="cli", + "--platform", + default="cli", help="Platform to show (default: cli)", ) @@ -5386,11 +7831,14 @@ def cmd_memory(args): help="Disable toolsets or MCP tools", ) tools_disable_p.add_argument( - "names", nargs="+", metavar="NAME", + "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", + "--platform", + default="cli", help="Platform to apply to (default: cli)", ) @@ -5400,11 +7848,14 @@ def cmd_memory(args): help="Enable toolsets or MCP tools", ) tools_enable_p.add_argument( - "names", nargs="+", metavar="NAME", + "names", + nargs="+", + metavar="NAME", help="Toolset name or MCP tool in server:tool form", ) tools_enable_p.add_argument( - "--platform", default="cli", + "--platform", + default="cli", help="Platform to apply to (default: cli)", ) @@ -5412,10 +7863,12 @@ 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) @@ -5439,18 +7892,30 @@ def cmd_tools(args): help="Run Hermes as an MCP server (expose conversations to other agents)", ) mcp_serve_p.add_argument( - "-v", "--verbose", action="store_true", + "-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 = 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( + "--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_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") @@ -5460,11 +7925,22 @@ def cmd_tools(args): 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 = 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) @@ -5475,31 +7951,52 @@ def cmd_mcp(args): sessions_parser = subparsers.add_parser( "sessions", help="Manage session history (list, rename, export, prune, delete)", - description="View and manage the SQLite session store" + 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_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 = 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 = 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_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( + "--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_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 = 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") @@ -5507,8 +8004,12 @@ def cmd_mcp(args): "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)") + 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.""" @@ -5519,8 +8020,10 @@ def _confirm_prompt(prompt: str) -> bool: 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}") @@ -5533,7 +8036,9 @@ def cmd_sessions(args): _exclude = None if _source else ["tool"] if action == "list": - sessions = db.list_sessions_rich(source=args.source, exclude_sources=_exclude, limit=args.limit) + sessions = db.list_sessions_rich( + source=args.source, exclude_sources=_exclude, limit=args.limit + ) if not sessions: print("No sessions found.") return @@ -5546,7 +8051,11 @@ def cmd_sessions(args): 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] + 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"] @@ -5567,7 +8076,7 @@ def cmd_sessions(args): return line = _json.dumps(data, ensure_ascii=False) + "\n" if args.output == "-": - import sys + sys.stdout.write(line) else: with open(args.output, "w", encoding="utf-8") as f: @@ -5576,7 +8085,7 @@ def cmd_sessions(args): else: sessions = db.export_all(source=args.source) if args.output == "-": - import sys + for s in sessions: sys.stdout.write(_json.dumps(s, ensure_ascii=False) + "\n") else: @@ -5591,7 +8100,9 @@ def cmd_sessions(args): 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] "): + 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): @@ -5603,7 +8114,9 @@ def cmd_sessions(args): 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] "): + 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) @@ -5627,7 +8140,9 @@ def cmd_sessions(args): 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) + sessions = db.list_sessions_rich( + source=source, exclude_sources=_browse_exclude, limit=limit + ) db.close() if not sessions: print("No sessions found.") @@ -5640,7 +8155,6 @@ def cmd_sessions(args): # Launch hermes --resume by replacing the current process print(f"Resuming session: {selected_id}") - import shutil hermes_bin = shutil.which("hermes") if hermes_bin: os.execvp(hermes_bin, ["hermes", "--resume", selected_id]) @@ -5679,10 +8193,14 @@ def cmd_sessions(args): 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" + 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.)" ) - 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: @@ -5705,7 +8223,7 @@ def cmd_insights(args): claw_parser = subparsers.add_parser( "claw", help="OpenClaw migration tools", - description="Migrate settings, memories, skills, and API keys from OpenClaw to Hermes" + description="Migrate settings, memories, skills, and API keys from OpenClaw to Hermes", ) claw_subparsers = claw_parser.add_subparsers(dest="claw_action") @@ -5714,47 +8232,43 @@ def cmd_insights(args): "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." + "Always shows a preview before making changes.", ) claw_migrate.add_argument( - "--source", - help="Path to OpenClaw directory (default: ~/.openclaw)" + "--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" + 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" + help="Migration preset (default: full). 'user-data' excludes secrets", ) claw_migrate.add_argument( "--overwrite", action="store_true", - help="Overwrite existing files (default: skip conflicts)" + 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.)" + help="Include allowlisted secrets (TELEGRAM_BOT_TOKEN, API keys, etc.)", ) claw_migrate.add_argument( - "--workspace-target", - help="Absolute path to copy workspace instructions into" + "--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)" + help="How to handle skill name conflicts (default: skip)", ) claw_migrate.add_argument( - "--yes", "-y", - action="store_true", - help="Skip confirmation prompts" + "--yes", "-y", action="store_true", help="Skip confirmation prompts" ) # claw cleanup @@ -5762,25 +8276,23 @@ def cmd_insights(args): "cleanup", aliases=["clean"], help="Archive leftover OpenClaw directories after migration", - description="Scan for and archive leftover OpenClaw directories to prevent state fragmentation" + 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" + "--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" + help="Preview what would be archived without making changes", ) claw_cleanup.add_argument( - "--yes", "-y", - action="store_true", - help="Skip confirmation prompts" + "--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) @@ -5788,43 +8300,40 @@ def cmd_claw(args): # ========================================================================= # version command # ========================================================================= - version_parser = subparsers.add_parser( - "version", - help="Show version information" - ) + version_parser = subparsers.add_parser("version", help="Show version information") version_parser.set_defaults(func=cmd_version) - + # ========================================================================= # update command # ========================================================================= update_parser = subparsers.add_parser( "update", help="Update Hermes Agent to the latest version", - description="Pull the latest changes from git and reinstall dependencies" + 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)" + "--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 command # ========================================================================= uninstall_parser = subparsers.add_parser( "uninstall", help="Uninstall Hermes Agent", - description="Remove Hermes Agent from your system. Can keep configs/data for reinstall." + 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" + help="Full uninstall - remove everything including configs and data", ) uninstall_parser.add_argument( - "--yes", "-y", - action="store_true", - help="Skip confirmation prompts" + "--yes", "-y", action="store_true", help="Skip confirmation prompts" ) uninstall_parser.set_defaults(func=cmd_uninstall) @@ -5836,11 +8345,13 @@ def cmd_claw(args): 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.") @@ -5859,48 +8370,81 @@ def cmd_acp(args): 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 = 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_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_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 = 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_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 = 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: .tar.gz)") + profile_export.add_argument( + "-o", "--output", default=None, help="Output file (default: .tar.gz)" + ) - profile_import = profile_subparsers.add_parser("import", help="Import a profile from archive") + 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_import.add_argument( + "--name", + dest="import_name", + metavar="NAME", + help="Profile name (default: inferred from archive)", + ) profile_parser.set_defaults(func=cmd_profile) @@ -5909,13 +8453,16 @@ def cmd_acp(args): # ========================================================================= completion_parser = subparsers.add_parser( "completion", - help="Print shell completion script (bash or zsh)", + help="Print shell completion script (bash, zsh, or fish)", ) completion_parser.add_argument( - "shell", nargs="?", default="bash", choices=["bash", "zsh"], + "shell", + nargs="?", + default="bash", + choices=["bash", "zsh", "fish"], help="Shell type (default: bash)", ) - completion_parser.set_defaults(func=cmd_completion) + completion_parser.set_defaults(func=lambda args: cmd_completion(args, parser)) # ========================================================================= # dashboard command @@ -5925,9 +8472,20 @@ def cmd_acp(args): 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( + "--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) # ========================================================================= @@ -5953,31 +8511,42 @@ def cmd_acp(args): """, ) logs_parser.add_argument( - "log_name", nargs="?", default="agent", + "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, + "-n", + "--lines", + type=int, + default=50, help="Number of lines to show (default: 50)", ) logs_parser.add_argument( - "-f", "--follow", action="store_true", + "-f", + "--follow", + action="store_true", help="Follow the log in real time (like tail -f)", ) logs_parser.add_argument( - "--level", metavar="LEVEL", + "--level", + metavar="LEVEL", help="Minimum log level to show (DEBUG, INFO, WARNING, ERROR)", ) logs_parser.add_argument( - "--session", metavar="ID", + "--session", + metavar="ID", help="Filter lines containing this session ID substring", ) logs_parser.add_argument( - "--since", metavar="TIME", + "--since", + metavar="TIME", help="Show lines since TIME ago (e.g. 1h, 30m, 2d)", ) logs_parser.add_argument( - "--component", metavar="NAME", + "--component", + metavar="NAME", help="Filter by component: gateway, agent, tools, cli, cron", ) logs_parser.set_defaults(func=cmd_logs) @@ -5994,6 +8563,7 @@ def cmd_acp(args): # --help, unrecognised flags, and every subcommand are forwarded # transparently instead of being intercepted by argparse on the host. 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:]) @@ -6002,42 +8572,124 @@ def cmd_acp(args): sys.exit(1) _processed_argv = _coalesce_session_name_args(sys.argv[1:]) - args = parser.parse_args(_processed_argv) + + # ── Defensive subparser routing (bpo-9338 workaround) ─────────── + # On some Python versions (notably <3.11), argparse fails to route + # subcommand tokens when the parent parser has nargs='?' optional + # arguments (--continue). The symptom: "unrecognized arguments: model" + # even though 'model' is a registered subcommand. + # + # Fix: when argv contains a token matching a known subcommand, set + # subparsers.required=True to force deterministic routing. If that + # fails (e.g. 'hermes -c model' where 'model' is consumed as the + # session name for --continue), fall back to the default behaviour. + 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 + # Help/version flags (exit code 0) already printed output — + # re-raise immediately to avoid a second parse_args printing + # the same help text again (#10230). + if exc.code == 0: + raise + # Subcommand name was consumed as a flag value (e.g. -c model). + # Fall back to optional subparsers so argparse handles it normally. + subparsers.required = False + args = parser.parse_args(_processed_argv) + else: + subparsers.required = False + args = parser.parse_args(_processed_argv) # Handle --version flag if args.version: cmd_version(args) return - + + # Discover Python plugins and register shell hooks once, before any + # command that can fire lifecycle hooks. Both are idempotent; gated + # so introspection/management commands (hermes hooks list, cron + # list, gateway status, mcp add, ...) don't pay discovery cost or + # trigger consent prompts for hooks the user is still inspecting. + # Groups with mixed admin/CRUD vs. agent-running entries narrow via + # the nested subcommand (dest varies by parser). + _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, + ) + # Handle top-level --resume / --continue as shortcut to chat if (args.resume or args.continue_last) and args.command is None: args.command = "chat" - args.query = None - args.model = None - args.provider = None - args.toolsets = None - args.verbose = False - if not hasattr(args, "worktree"): - args.worktree = False + 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 - + # Default to chat if no command specified if args.command is None: - args.query = None - args.model = None - args.provider = None - args.toolsets = None - args.verbose = False - args.resume = None - args.continue_last = None - if not hasattr(args, "worktree"): - args.worktree = False + 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 - + # Execute the command - if hasattr(args, 'func'): + if hasattr(args, "func"): args.func(args) else: parser.print_help() diff --git a/tests/hermes_cli/test_whatsapp_setup.py b/tests/hermes_cli/test_whatsapp_setup.py new file mode 100644 index 00000000000..560cee2ba52 --- /dev/null +++ b/tests/hermes_cli/test_whatsapp_setup.py @@ -0,0 +1,93 @@ +"""Regression tests for WhatsApp setup dependency repair.""" + +import os +from argparse import Namespace +from pathlib import Path +from unittest.mock import MagicMock, patch + + +def _touch_baileys(root: Path) -> None: + pkg = root / "node_modules" / "@whiskeysockets" / "baileys" / "package.json" + pkg.parent.mkdir(parents=True, exist_ok=True) + pkg.write_text("{}") + + +def test_need_install_when_baileys_missing(tmp_path: Path) -> None: + import hermes_cli.main as main_mod + + (tmp_path / "node_modules").mkdir() + (tmp_path / "package-lock.json").write_text("{}") + + assert main_mod._whatsapp_bridge_need_npm_install(tmp_path) is True + + +def test_need_install_when_lock_newer_than_marker(tmp_path: Path) -> None: + import hermes_cli.main as main_mod + + _touch_baileys(tmp_path) + (tmp_path / "package-lock.json").write_text("{}") + (tmp_path / "node_modules" / ".package-lock.json").write_text("{}") + os.utime(tmp_path / "package-lock.json", (200, 200)) + os.utime(tmp_path / "node_modules" / ".package-lock.json", (100, 100)) + + assert main_mod._whatsapp_bridge_need_npm_install(tmp_path) is True + + +def test_no_install_when_baileys_present_and_marker_current(tmp_path: Path) -> None: + import hermes_cli.main as main_mod + + _touch_baileys(tmp_path) + (tmp_path / "package-lock.json").write_text("{}") + (tmp_path / "node_modules" / ".package-lock.json").write_text("{}") + os.utime(tmp_path / "package-lock.json", (100, 100)) + os.utime(tmp_path / "node_modules" / ".package-lock.json", (200, 200)) + + assert main_mod._whatsapp_bridge_need_npm_install(tmp_path) is False + + +def test_cmd_whatsapp_repairs_partial_bridge_install(tmp_path: Path, capsys) -> None: + import hermes_cli.main as main_mod + + project_root = tmp_path / "repo" + bridge_dir = project_root / "scripts" / "whatsapp-bridge" + bridge_dir.mkdir(parents=True) + (bridge_dir / "bridge.js").write_text("// bridge") + (bridge_dir / "package.json").write_text("{}") + (bridge_dir / "package-lock.json").write_text("{}") + (bridge_dir / "node_modules").mkdir() + + fake_main = project_root / "hermes_cli" / "main.py" + fake_main.parent.mkdir(parents=True) + fake_main.write_text("# test shim") + + env = {} + + def fake_get_env_value(key): + return env.get(key, "") + + def fake_save_env_value(key, value): + env[key] = value + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return MagicMock(returncode=0, stderr="") + + with ( + patch.object(main_mod, "__file__", str(fake_main)), + patch.object(main_mod, "_require_tty"), + patch.object(main_mod, "get_hermes_home", return_value=tmp_path / ".hermes"), + patch.object(main_mod.shutil, "which", return_value="/usr/bin/npm"), + patch.object(main_mod.subprocess, "run", side_effect=fake_run), + patch("hermes_cli.config.get_env_value", side_effect=fake_get_env_value), + patch("hermes_cli.config.save_env_value", side_effect=fake_save_env_value), + patch("builtins.input", side_effect=["2", "189984136"]), + ): + main_mod.cmd_whatsapp(Namespace()) + + out = capsys.readouterr().out + assert "✓ Dependencies installed" in out + assert "✓ Bridge dependencies already installed" not in out + assert calls[0][:2] == ["/usr/bin/npm", "install"] +