diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 940bdfd4505b..44b5ba0e0794 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -174,6 +174,22 @@ def _select_pool_entry(provider: str) -> Tuple[bool, Optional[Any]]: return True, None +def _peek_pool_entry(provider: str) -> Tuple[bool, Optional[Any]]: + """Return a pool entry without refreshing or rotating credentials.""" + try: + pool = load_pool(provider) + except Exception as exc: + logger.debug("Auxiliary client: could not load pool for %s: %s", provider, exc) + return False, None + if not pool or not pool.has_credentials(): + return False, None + try: + return True, pool.peek() + except Exception as exc: + logger.debug("Auxiliary client: could not peek pool entry for %s: %s", provider, exc) + return True, None + + def _pool_runtime_api_key(entry: Any) -> str: if entry is None: return "" @@ -197,6 +213,15 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str: return str(url or "").strip().rstrip("/") +def _jwt_is_expired(token: str) -> bool: + try: + from hermes_cli.auth import _codex_access_token_is_expiring + + return bool(_codex_access_token_is_expiring(token, 0)) + except Exception: + return False + + # ── Codex Responses → chat.completions adapter ───────────────────────────── # All auxiliary consumers call client.chat.completions.create(**kwargs) and # read response.choices[0].message.content. This adapter translates those @@ -637,11 +662,14 @@ def _read_codex_access_token() -> Optional[str]: fallback-to-Codex working when the pool state is stale but the stored OAuth token is still valid. """ - pool_present, entry = _select_pool_entry("openai-codex") + pool_present, entry = _peek_pool_entry("openai-codex") if pool_present: token = _pool_runtime_api_key(entry) if token: - return token + if _jwt_is_expired(token): + logger.debug("Codex pool access token expired, skipping") + else: + return token try: from hermes_cli.auth import _read_codex_tokens @@ -653,17 +681,9 @@ def _read_codex_access_token() -> Optional[str]: # Check JWT expiry — expired tokens block the auto chain and # prevent fallback to working providers (e.g. Anthropic). - try: - import base64 - payload = access_token.split(".")[1] - payload += "=" * (-len(payload) % 4) - claims = json.loads(base64.urlsafe_b64decode(payload)) - exp = claims.get("exp", 0) - if exp and time.time() > exp: - logger.debug("Codex access token expired (exp=%s), skipping", exp) - return None - except Exception: - pass # Non-JWT token or decode error — use as-is + if _jwt_is_expired(access_token): + logger.debug("Codex access token expired, skipping") + return None return access_token.strip() except Exception as exc: diff --git a/agent/builtin_memory_provider.py b/agent/builtin_memory_provider.py new file mode 100644 index 000000000000..1a796d77875a --- /dev/null +++ b/agent/builtin_memory_provider.py @@ -0,0 +1,30 @@ +"""No-op built-in memory provider shim. + +The built-in memory store is implemented by ``tools.memory_tool.MemoryStore``. +This provider exists so ``MemoryManager`` callers can represent that built-in +slot explicitly alongside one external memory plugin. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +from agent.memory_provider import MemoryProvider + + +class BuiltinMemoryProvider(MemoryProvider): + """Compatibility provider for the always-present built-in memory slot.""" + + @property + def name(self) -> str: + return "builtin" + + def is_available(self) -> bool: + return True + + def initialize(self, session_id: str, **kwargs: Any) -> None: + self.session_id = session_id + self.init_kwargs = dict(kwargs) + + def get_tool_schemas(self) -> List[Dict[str, Any]]: + return [] diff --git a/cli.py b/cli.py index 007b6e1eba26..5e83a2c775cc 100644 --- a/cli.py +++ b/cli.py @@ -6364,7 +6364,9 @@ def _voice_stop_and_transcribe(self): if result.get("success") and result.get("transcript", "").strip(): transcript = result["transcript"].strip() - self._attached_images.clear() + attached_images = getattr(self, "_attached_images", None) + if attached_images is not None: + attached_images.clear() if hasattr(self, '_app') and self._app: self._app.invalidate() self._pending_input.put(transcript) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 8b4e43514b66..c92e2cf1c588 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -8,6 +8,7 @@ """ import asyncio +import inspect import json import logging import os @@ -16,6 +17,13 @@ logger = logging.getLogger(__name__) + +async def _maybe_await(result: Any) -> Any: + """Await PTB calls in production while tolerating simple test doubles.""" + if inspect.isawaitable(result): + return await result + return result + try: from telegram import Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import ( @@ -232,16 +240,16 @@ async def _handle_polling_network_error(self, error: Exception) -> None: try: if self._app and self._app.updater and self._app.updater.running: - await self._app.updater.stop() + await _maybe_await(self._app.updater.stop()) except Exception: pass try: - await self._app.updater.start_polling( + await _maybe_await(self._app.updater.start_polling( allowed_updates=Update.ALL_TYPES, drop_pending_updates=False, error_callback=self._polling_error_callback_ref, - ) + )) logger.info( "[%s] Telegram polling resumed after network error (attempt %d)", self.name, attempt, @@ -279,16 +287,16 @@ async def _handle_polling_conflict(self, error: Exception) -> None: ) try: if self._app and self._app.updater and self._app.updater.running: - await self._app.updater.stop() + await _maybe_await(self._app.updater.stop()) except Exception: pass await asyncio.sleep(RETRY_DELAY) try: - await self._app.updater.start_polling( + await _maybe_await(self._app.updater.start_polling( allowed_updates=Update.ALL_TYPES, drop_pending_updates=False, error_callback=self._polling_error_callback_ref, - ) + )) logger.info("[%s] Telegram polling resumed after conflict retry %d", self.name, self._polling_conflict_count) self._polling_conflict_count = 0 # reset on success return @@ -309,7 +317,7 @@ async def _handle_polling_conflict(self, error: Exception) -> None: self._set_fatal_error("telegram_polling_conflict", message, retryable=False) try: if self._app and self._app.updater: - await self._app.updater.stop() + await _maybe_await(self._app.updater.stop()) except Exception as stop_error: logger.warning("[%s] Failed stopping Telegram polling after conflict: %s", self.name, stop_error, exc_info=True) await self._notify_fatal_error() @@ -622,7 +630,7 @@ def _env_float(name: str, default: float) -> float: _max_connect = 3 for _attempt in range(_max_connect): try: - await self._app.initialize() + await _maybe_await(self._app.initialize()) break except (NetworkError, TimedOut, OSError) as init_err: if _attempt < _max_connect - 1: @@ -634,7 +642,7 @@ def _env_float(name: str, default: float) -> float: await asyncio.sleep(wait) else: raise - await self._app.start() + await _maybe_await(self._app.start()) # Decide between webhook and polling mode webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip() @@ -649,7 +657,7 @@ def _env_float(name: str, default: float) -> float: from urllib.parse import urlparse webhook_path = urlparse(webhook_url).path or "/telegram" - await self._app.updater.start_webhook( + await _maybe_await(self._app.updater.start_webhook( listen="0.0.0.0", port=webhook_port, url_path=webhook_path, @@ -657,7 +665,7 @@ def _env_float(name: str, default: float) -> float: secret_token=webhook_secret, allowed_updates=Update.ALL_TYPES, drop_pending_updates=True, - ) + )) self._webhook_mode = True logger.info( "[%s] Webhook server listening on 0.0.0.0:%d%s", @@ -669,7 +677,7 @@ def _env_float(name: str, default: float) -> float: # previous webhook registration and silently stop receiving updates. delete_webhook = getattr(self._bot, "delete_webhook", None) if callable(delete_webhook): - await delete_webhook(drop_pending_updates=False) + await _maybe_await(delete_webhook(drop_pending_updates=False)) loop = asyncio.get_running_loop() @@ -687,11 +695,11 @@ def _polling_error_callback(error: Exception) -> None: # Store reference for retry use in _handle_polling_conflict self._polling_error_callback_ref = _polling_error_callback - await self._app.updater.start_polling( + await _maybe_await(self._app.updater.start_polling( allowed_updates=Update.ALL_TYPES, drop_pending_updates=True, error_callback=_polling_error_callback, - ) + )) # Register bot commands so Telegram shows a hint menu when users type / # List is derived from the central COMMAND_REGISTRY — adding a new @@ -703,9 +711,9 @@ def _polling_error_callback(error: Exception) -> None: # payload size limit. Skill descriptions are truncated to 40 # chars in telegram_menu_commands() to fit 100 commands safely. menu_commands, hidden_count = telegram_menu_commands(max_commands=100) - await self._bot.set_my_commands([ + await _maybe_await(self._bot.set_my_commands([ BotCommand(name, desc) for name, desc in menu_commands - ]) + ])) if hidden_count: logger.info( "[%s] Telegram menu: %d commands registered, %d hidden (over 100 limit). Use /commands for full list.", diff --git a/gateway/run.py b/gateway/run.py index 659ba8013697..89f3a7fbb128 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6481,7 +6481,7 @@ def _apply_session_model_override( subsequent messages. Fields with ``None`` values are skipped so partial overrides don't clobber valid config defaults. """ - override = self._session_model_overrides.get(session_key) + override = getattr(self, "_session_model_overrides", {}).get(session_key) if not override: return model, runtime_kwargs model = override.get("model", model) @@ -6493,7 +6493,7 @@ def _apply_session_model_override( def _is_intentional_model_switch(self, session_key: str, agent_model: str) -> bool: """Return True if *agent_model* matches an active /model session override.""" - override = self._session_model_overrides.get(session_key) + override = getattr(self, "_session_model_overrides", {}).get(session_key) return override is not None and override.get("model") == agent_model def _evict_cached_agent(self, session_key: str) -> None: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e1c8cb1cc454..25ba3628dc28 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3306,6 +3306,103 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: print(" Your local repo is updated, but your fork on GitHub may be behind.") +def _current_branch_name(git_cmd: list[str], cwd: Path) -> Optional[str]: + """Return the current branch name, or ``HEAD`` when detached.""" + try: + result = subprocess.run( + git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"], + cwd=cwd, + capture_output=True, + text=True, + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception: + pass + return None + + +def _print_fetch_error(stderr: str) -> None: + """Print a user-friendly fetch failure message.""" + stderr = (stderr or "").strip() + if "Could not resolve host" in stderr or "unable to access" in stderr: + print("✗ Network error — cannot reach the remote repository.") + if stderr: + print(f" {stderr.splitlines()[0]}") + elif "Authentication failed" in stderr or "could not read Username" in stderr: + print("✗ Authentication failed — check your git credentials or SSH key.") + else: + print("✗ Failed to fetch updates from remote.") + if stderr: + print(f" {stderr.splitlines()[0]}") + + +def _cmd_update_check(git_cmd: list[str], cwd: Path) -> int: + """Read-only update status check for the current checkout.""" + if not (cwd / ".git").exists(): + print("✗ Not a git repository. `hermes update --check` only works in a git checkout.") + return 1 + + origin_url = _get_origin_url(git_cmd, cwd) + is_fork = _is_fork(origin_url) + current_branch = _current_branch_name(git_cmd, cwd) or "unknown" + + target_remote = "origin" + target_ref = "origin/main" + target_label = "origin/main" + + if is_fork: + if _has_upstream_remote(git_cmd, cwd): + target_remote = "upstream" + target_ref = "upstream/main" + target_label = "official upstream/main" + else: + target_label = "origin/main (fork origin, no upstream remote configured)" + + print("→ Checking for updates...") + print(f" Repo: {'fork' if is_fork else 'official checkout'}") + print(f" Current branch: {current_branch}") + print(f" Comparison target: {target_label}") + + try: + fetch_result = subprocess.run( + git_cmd + ["fetch", target_remote, "--quiet"], + cwd=cwd, + capture_output=True, + text=True, + ) + except Exception as exc: + print(f"✗ Failed to fetch updates from {target_remote}.") + print(f" {exc}") + return 1 + + if fetch_result.returncode != 0: + _print_fetch_error(fetch_result.stderr) + return 1 + + behind = _count_commits_between(git_cmd, cwd, "HEAD", target_ref) + ahead = _count_commits_between(git_cmd, cwd, target_ref, "HEAD") + if behind < 0 or ahead < 0: + print("✗ Could not compare the local checkout to the target branch.") + return 1 + + if behind == 0 and ahead == 0: + print(f"✓ Up to date with {target_ref}") + elif behind > 0 and ahead == 0: + print(f"↑ Update available: {behind} commit(s) behind {target_ref}") + print(" Run `hermes update` to apply the latest changes.") + elif behind == 0 and ahead > 0: + print(f"ℹ Local checkout is {ahead} commit(s) ahead of {target_ref}") + else: + print(f"⚠ Local checkout has diverged from {target_ref} (ahead {ahead}, behind {behind})") + + if current_branch != "main": + label = "detached HEAD" if current_branch == "HEAD" else f"branch '{current_branch}'" + print(f" ℹ Running `hermes update` will switch this checkout from {label} to main before updating.") + + return 0 + + def _invalidate_update_cache(): """Delete the update-check cache for ALL profiles so no banner reports a stale "commits behind" count after a successful update. @@ -3420,6 +3517,7 @@ def cmd_update(args): return gateway_mode = getattr(args, "gateway", False) + check_only = getattr(args, "check", False) # 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 @@ -3452,6 +3550,12 @@ def cmd_update(args): if sys.platform == "win32": git_cmd = ["git", "-c", "windows.appendAtomically=false"] + if check_only: + rc = _cmd_update_check(git_cmd, PROJECT_ROOT) + if rc != 0: + sys.exit(rc) + return + # Detect if we're updating from a fork (before any branch logic) origin_url = _get_origin_url(git_cmd, PROJECT_ROOT) is_fork = _is_fork(origin_url) @@ -3489,15 +3593,10 @@ def cmd_update(args): print(f" {stderr.splitlines()[0]}") sys.exit(1) - # Get current branch (returns literal "HEAD" when detached) - result = subprocess.run( - git_cmd + ["rev-parse", "--abbrev-ref", "HEAD"], - cwd=PROJECT_ROOT, - capture_output=True, - text=True, - check=True, - ) - current_branch = result.stdout.strip() + current_branch = _current_branch_name(git_cmd, PROJECT_ROOT) + if current_branch is None: + print("✗ Failed to determine the current git branch.") + sys.exit(1) # Always update against main branch = "main" @@ -5463,6 +5562,10 @@ def cmd_claw(args): help="Update Hermes Agent to the latest version", description="Pull the latest changes from git and reinstall dependencies" ) + update_parser.add_argument( + "--check", action="store_true", default=False, + help="Check for available updates without modifying the current checkout" + ) 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)" diff --git a/run_agent.py b/run_agent.py index fc7f72b731b7..62ea058b9ed5 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5578,6 +5578,7 @@ def _qwen_prepare_chat_messages_inplace(self, messages: list) -> None: def _build_api_kwargs(self, api_messages: list) -> dict: """Build the keyword arguments dict for the active API mode.""" + request_overrides = getattr(self, "request_overrides", {}) or {} if self.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_kwargs anthropic_messages = self._prepare_anthropic_messages_for_api(api_messages) @@ -5602,7 +5603,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict: preserve_dots=self._anthropic_preserve_dots(), context_length=ctx_len, base_url=getattr(self, "_anthropic_base_url", None), - fast_mode=(self.request_overrides or {}).get("speed") == "fast", + fast_mode=request_overrides.get("speed") == "fast", ) if self.api_mode == "codex_responses": @@ -5659,8 +5660,8 @@ def _build_api_kwargs(self, api_messages: list) -> dict: elif not is_github_responses: kwargs["include"] = [] - if self.request_overrides: - kwargs.update(self.request_overrides) + if request_overrides: + kwargs.update(request_overrides) if self.max_tokens is not None and not is_codex_backend: kwargs["max_output_tokens"] = self.max_tokens @@ -5835,8 +5836,8 @@ def _build_api_kwargs(self, api_messages: list) -> dict: # Priority Processing / generic request overrides (e.g. service_tier). # Applied last so overrides win over any defaults set above. - if self.request_overrides: - api_kwargs.update(self.request_overrides) + if request_overrides: + api_kwargs.update(request_overrides) return api_kwargs diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 17f4dc3c8776..c60a26f4a95e 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -614,6 +614,8 @@ def test_custom_endpoint_uses_config_saved_base_url(self, monkeypatch): def test_codex_fallback_when_nothing_else(self, codex_auth_dir): with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ + patch("agent.auxiliary_client._read_main_provider", return_value=None), \ + patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \ patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client() assert model == "gpt-5.2-codex" diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index c28317d7e4b1..f3ff90512fb7 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -144,7 +144,7 @@ async def test_run_agent_progress_stays_in_originating_topic(monkeypatch, tmp_pa assert adapter.sent == [ { "chat_id": "-1001", - "content": '⚙️ terminal: "pwd"', + "content": '💻 terminal: "pwd"', "reply_to": None, "metadata": {"thread_id": "17585"}, } diff --git a/tests/gateway/test_telegram_approval_buttons.py b/tests/gateway/test_telegram_approval_buttons.py index 98d3cdc312fc..90d4bba66390 100644 --- a/tests/gateway/test_telegram_approval_buttons.py +++ b/tests/gateway/test_telegram_approval_buttons.py @@ -158,6 +158,10 @@ async def test_truncates_long_command(self): class TestTelegramApprovalCallback: """Test the approval callback handling in _handle_callback_query.""" + @pytest.fixture(autouse=True) + def _allow_callback_user(self, monkeypatch): + monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "*") + @pytest.mark.asyncio async def test_resolves_approval_on_click(self): adapter = _make_adapter() diff --git a/tests/gateway/test_telegram_conflict.py b/tests/gateway/test_telegram_conflict.py index 47a67f229b61..12167af5ed7f 100644 --- a/tests/gateway/test_telegram_conflict.py +++ b/tests/gateway/test_telegram_conflict.py @@ -45,6 +45,17 @@ async def _noop(): monkeypatch.setattr("gateway.platforms.telegram.discover_fallback_ips", _noop) +def _application_builder(app): + builder = MagicMock() + builder.token.return_value = builder + builder.base_url.return_value = builder + builder.base_file_url.return_value = builder + builder.request.return_value = builder + builder.get_updates_request.return_value = builder + builder.build.return_value = app + return builder + + @pytest.mark.asyncio async def test_connect_rejects_same_host_token_lock(monkeypatch): adapter = TelegramAdapter(PlatformConfig(enabled=True, token="secret-token")) @@ -96,9 +107,7 @@ async def fake_start_polling(**kwargs): initialize=AsyncMock(), start=AsyncMock(), ) - builder = MagicMock() - builder.token.return_value = builder - builder.build.return_value = app + builder = _application_builder(app) monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder))) # Speed up retries for testing @@ -170,9 +179,7 @@ async def failing_start_polling(**kwargs): initialize=AsyncMock(), start=AsyncMock(), ) - builder = MagicMock() - builder.token.return_value = builder - builder.build.return_value = app + builder = _application_builder(app) monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder))) # Speed up retries for testing @@ -214,8 +221,6 @@ async def test_connect_marks_retryable_fatal_error_for_startup_network_failure(m lambda scope, identity: None, ) - builder = MagicMock() - builder.token.return_value = builder app = SimpleNamespace( bot=SimpleNamespace(delete_webhook=AsyncMock(), set_my_commands=AsyncMock()), updater=SimpleNamespace(), @@ -223,7 +228,7 @@ async def test_connect_marks_retryable_fatal_error_for_startup_network_failure(m initialize=AsyncMock(side_effect=RuntimeError("Temporary failure in name resolution")), start=AsyncMock(), ) - builder.build.return_value = app + builder = _application_builder(app) monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder))) ok = await adapter.connect() @@ -263,9 +268,7 @@ async def test_connect_clears_webhook_before_polling(monkeypatch): initialize=AsyncMock(), start=AsyncMock(), ) - builder = MagicMock() - builder.token.return_value = builder - builder.build.return_value = app + builder = _application_builder(app) monkeypatch.setattr( "gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder)), diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 5bb7d07065c6..a7ce4461cf79 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -118,13 +118,13 @@ def test_oauth_providers_unchanged(self): PROVIDER_ENV_VARS = ( "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", - "CLAUDE_CODE_OAUTH_TOKEN", - "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", "COPILOT_GITHUB_TOKEN", + "GOOGLE_API_KEY", "GEMINI_API_KEY", "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", "KIMI_API_KEY", "KIMI_BASE_URL", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL", - "KILOCODE_API_KEY", "KILOCODE_BASE_URL", + "DEEPSEEK_API_KEY", "KILOCODE_API_KEY", "KILOCODE_BASE_URL", "DASHSCOPE_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY", - "NOUS_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", + "HF_TOKEN", "NOUS_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", "OPENAI_BASE_URL", "HERMES_COPILOT_ACP_COMMAND", "COPILOT_CLI_PATH", "HERMES_COPILOT_ACP_ARGS", "COPILOT_ACP_BASE_URL", ) diff --git a/tests/hermes_cli/test_auth_provider_gate.py b/tests/hermes_cli/test_auth_provider_gate.py index 2eacb71be7b8..d048fda06c67 100644 --- a/tests/hermes_cli/test_auth_provider_gate.py +++ b/tests/hermes_cli/test_auth_provider_gate.py @@ -18,6 +18,12 @@ def _write_auth_store(tmp_path, payload: dict) -> None: (hermes_home / "auth.json").write_text(json.dumps(payload, indent=2)) +@pytest.fixture(autouse=True) +def _clear_anthropic_env(monkeypatch): + for key in ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"): + monkeypatch.delenv(key, raising=False) + + def test_returns_false_when_no_config(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) diff --git a/tests/hermes_cli/test_cmd_update_check.py b/tests/hermes_cli/test_cmd_update_check.py new file mode 100644 index 000000000000..76f26dfa135c --- /dev/null +++ b/tests/hermes_cli/test_cmd_update_check.py @@ -0,0 +1,103 @@ +"""Tests for ``hermes update --check``.""" + +from types import SimpleNamespace + +import hermes_cli.config as hermes_config +import hermes_cli.main as hermes_main + + +def _setup_repo(monkeypatch, tmp_path): + (tmp_path / ".git").mkdir() + monkeypatch.setattr(hermes_main, "PROJECT_ROOT", tmp_path) + monkeypatch.setattr(hermes_config, "is_managed", lambda: False) + + +def test_update_check_reports_official_checkout_up_to_date(monkeypatch, tmp_path, capsys): + _setup_repo(monkeypatch, tmp_path) + + recorded = [] + + def fake_run(cmd, **kwargs): + recorded.append(cmd) + if cmd == ["git", "remote", "get-url", "origin"]: + return SimpleNamespace(returncode=0, stdout=f"{hermes_main.OFFICIAL_REPO_URL}\n", stderr="") + if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]: + return SimpleNamespace(returncode=0, stdout="main\n", stderr="") + if cmd == ["git", "fetch", "origin", "--quiet"]: + return SimpleNamespace(returncode=0, stdout="", stderr="") + if cmd == ["git", "rev-list", "--count", "HEAD..origin/main"]: + return SimpleNamespace(returncode=0, stdout="0\n", stderr="") + if cmd == ["git", "rev-list", "--count", "origin/main..HEAD"]: + return SimpleNamespace(returncode=0, stdout="0\n", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + hermes_main.cmd_update(SimpleNamespace(check=True, gateway=False)) + + out = capsys.readouterr().out + assert "Checking for updates" in out + assert "Repo: official checkout" in out + assert "Current branch: main" in out + assert "✓ Up to date with origin/main" in out + assert not any("pull" in " ".join(c) for c in recorded) + + +def test_update_check_uses_upstream_for_forks(monkeypatch, tmp_path, capsys): + _setup_repo(monkeypatch, tmp_path) + + recorded = [] + + def fake_run(cmd, **kwargs): + recorded.append(cmd) + if cmd == ["git", "remote", "get-url", "origin"]: + return SimpleNamespace(returncode=0, stdout="https://github.com/example/hermes-agent.git\n", stderr="") + if cmd == ["git", "remote", "get-url", "upstream"]: + return SimpleNamespace(returncode=0, stdout=f"{hermes_main.OFFICIAL_REPO_URL}\n", stderr="") + if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]: + return SimpleNamespace(returncode=0, stdout="docs/test\n", stderr="") + if cmd == ["git", "fetch", "upstream", "--quiet"]: + return SimpleNamespace(returncode=0, stdout="", stderr="") + if cmd == ["git", "rev-list", "--count", "HEAD..upstream/main"]: + return SimpleNamespace(returncode=0, stdout="2\n", stderr="") + if cmd == ["git", "rev-list", "--count", "upstream/main..HEAD"]: + return SimpleNamespace(returncode=0, stdout="0\n", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + hermes_main.cmd_update(SimpleNamespace(check=True, gateway=False)) + + out = capsys.readouterr().out + assert "Repo: fork" in out + assert "Comparison target: official upstream/main" in out + assert "Update available: 2 commit(s) behind upstream/main" in out + assert "switch this checkout from branch 'docs/test' to main" in out + assert not any("pull" in " ".join(c) for c in recorded) + + +def test_update_check_falls_back_to_origin_when_no_upstream(monkeypatch, tmp_path, capsys): + _setup_repo(monkeypatch, tmp_path) + + def fake_run(cmd, **kwargs): + if cmd == ["git", "remote", "get-url", "origin"]: + return SimpleNamespace(returncode=0, stdout="https://github.com/example/hermes-agent.git\n", stderr="") + if cmd == ["git", "remote", "get-url", "upstream"]: + return SimpleNamespace(returncode=2, stdout="", stderr="no upstream\n") + if cmd == ["git", "rev-parse", "--abbrev-ref", "HEAD"]: + return SimpleNamespace(returncode=0, stdout="main\n", stderr="") + if cmd == ["git", "fetch", "origin", "--quiet"]: + return SimpleNamespace(returncode=0, stdout="", stderr="") + if cmd == ["git", "rev-list", "--count", "HEAD..origin/main"]: + return SimpleNamespace(returncode=0, stdout="0\n", stderr="") + if cmd == ["git", "rev-list", "--count", "origin/main..HEAD"]: + return SimpleNamespace(returncode=0, stdout="1\n", stderr="") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + hermes_main.cmd_update(SimpleNamespace(check=True, gateway=False)) + + out = capsys.readouterr().out + assert "Comparison target: origin/main (fork origin, no upstream remote configured)" in out + assert "Local checkout is 1 commit(s) ahead of origin/main" in out diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index d40a471444d3..42d36f76335b 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -167,6 +167,9 @@ def test_bare_name_gets_openrouter_slug(self, monkeypatch): "ANTHROPIC_TOKEN", "CLAUDE_CODE_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", ): monkeypatch.delenv(env_var, raising=False) """Bare model names should get mapped to full OpenRouter slugs.""" diff --git a/tests/hermes_cli/test_opencode_go_in_model_list.py b/tests/hermes_cli/test_opencode_go_in_model_list.py index 493d41b992a4..be5fd25d3533 100644 --- a/tests/hermes_cli/test_opencode_go_in_model_list.py +++ b/tests/hermes_cli/test_opencode_go_in_model_list.py @@ -16,8 +16,8 @@ def test_opencode_go_appears_when_api_key_set(): assert opencode_go is not None, "opencode-go should appear when OPENCODE_GO_API_KEY is set" assert opencode_go["models"] == ["glm-5", "kimi-k2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5"] - # opencode-go is in PROVIDER_TO_MODELS_DEV, so it appears as "built-in" (Part 1) - assert opencode_go["source"] == "built-in" + # opencode-go may come from models.dev or the Hermes overlay fallback. + assert opencode_go["source"] in {"built-in", "hermes"} def test_opencode_go_not_appears_when_no_creds(): diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py index ceb05f65c92b..8c2d8eb5446c 100644 --- a/tests/hermes_cli/test_update_gateway_restart.py +++ b/tests/hermes_cli/test_update_gateway_restart.py @@ -370,6 +370,9 @@ def test_update_with_systemd_still_restarts_via_systemd( ) monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + monkeypatch.setattr( + gateway_cli, "is_linux", lambda: True, + ) mock_run.side_effect = _make_run_side_effect( commit_count="3", diff --git a/tests/tools/test_browser_camofox_state.py b/tests/tools/test_browser_camofox_state.py index b1f128ccee39..5e7deb656d68 100644 --- a/tests/tools/test_browser_camofox_state.py +++ b/tests/tools/test_browser_camofox_state.py @@ -62,5 +62,5 @@ def test_default_config_includes_managed_persistence_toggle(self): def test_config_version_unchanged(self): from hermes_cli.config import DEFAULT_CONFIG - # managed_persistence is auto-merged by _deep_merge, no version bump needed - assert DEFAULT_CONFIG["_config_version"] == 13 + # managed_persistence itself needed no bump; later schema bumps are fine. + assert DEFAULT_CONFIG["_config_version"] >= 13 diff --git a/tools/file_operations.py b/tools/file_operations.py index f2b37505f362..201b0a9a06f1 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -48,6 +48,7 @@ os.path.join(_HOME, ".ssh", "id_rsa"), os.path.join(_HOME, ".ssh", "id_ed25519"), os.path.join(_HOME, ".ssh", "config"), + os.path.join(_HOME, ".hermes", ".env"), str(get_hermes_home() / ".env"), os.path.join(_HOME, ".bashrc"), os.path.join(_HOME, ".zshrc"), diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 5b6a1e3b1379..dbde5064bbfe 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -417,6 +417,10 @@ def __init__(self) -> None: # -- public properties --------------------------------------------------- + @property + def is_recording(self) -> bool: + return self._recording + @property def elapsed_seconds(self) -> float: if not self._recording: diff --git a/website/docs/developer-guide/agent-loop.md b/website/docs/developer-guide/agent-loop.md index 4728a634b36a..8ddb41187fc6 100644 --- a/website/docs/developer-guide/agent-loop.md +++ b/website/docs/developer-guide/agent-loop.md @@ -107,14 +107,11 @@ Providers validate these sequences and will reject malformed histories. API requests are wrapped in `_api_call_with_interrupt()` which runs the actual HTTP call in a background thread while monitoring an interrupt event: -```text -┌──────────────────────┐ ┌──────────────┐ -│ Main thread │ │ API thread │ -│ wait on: │────▶│ HTTP POST │ -│ - response ready │ │ to provider │ -│ - interrupt event │ └──────────────┘ -│ - timeout │ -└──────────────────────┘ +```mermaid +flowchart LR + main["Main thread
wait on:
- response ready
- interrupt event
- timeout"] + api["API thread
HTTP POST to provider"] + main --> api ``` When interrupted (user sends new message, `/stop` command, or signal): diff --git a/website/docs/developer-guide/architecture.md b/website/docs/developer-guide/architecture.md index 38802a049197..b8b7665f6e14 100644 --- a/website/docs/developer-guide/architecture.md +++ b/website/docs/developer-guide/architecture.md @@ -10,124 +10,123 @@ This page is the top-level map of Hermes Agent internals. Use it to orient yours ## System Overview -```text -┌─────────────────────────────────────────────────────────────────────┐ -│ Entry Points │ -│ │ -│ CLI (cli.py) Gateway (gateway/run.py) ACP (acp_adapter/) │ -│ Batch Runner API Server Python Library │ -└──────────┬──────────────┬───────────────────────┬────────────────────┘ - │ │ │ - ▼ ▼ ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ AIAgent (run_agent.py) │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Prompt │ │ Provider │ │ Tool │ │ -│ │ Builder │ │ Resolution │ │ Dispatch │ │ -│ │ (prompt_ │ │ (runtime_ │ │ (model_ │ │ -│ │ builder.py) │ │ provider.py)│ │ tools.py) │ │ -│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ -│ │ │ │ │ -│ ┌──────┴───────┐ ┌──────┴───────┐ ┌──────┴───────┐ │ -│ │ Compression │ │ 3 API Modes │ │ Tool Registry│ │ -│ │ & Caching │ │ chat_compl. │ │ (registry.py)│ │ -│ │ │ │ codex_resp. │ │ 48 tools │ │ -│ │ │ │ anthropic │ │ 40 toolsets │ │ -│ └──────────────┘ └──────────────┘ └──────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ │ - ▼ ▼ -┌───────────────────┐ ┌──────────────────────┐ -│ Session Storage │ │ Tool Backends │ -│ (SQLite + FTS5) │ │ Terminal (6 backends) │ -│ hermes_state.py │ │ Browser (5 backends) │ -│ gateway/session.py│ │ Web (4 backends) │ -└───────────────────┘ │ MCP (dynamic) │ - │ File, Vision, etc. │ - └──────────────────────┘ +```mermaid +flowchart TB + cli["CLI"] + gateway["Gateway"] + acp["ACP"] + batch["Batch runner"] + api["API server"] + library["Python library"] + + subgraph agent["AIAgent (run_agent.py)"] + prompt["Prompt builder"] + provider["Provider resolution"] + dispatch["Tool dispatch"] + compression["Compression and caching"] + modes["API modes
chat_completions / codex_responses / anthropic"] + registry["Tool registry
48 tools / 40 toolsets"] + end + + storage["Session storage
SQLite + FTS5"] + backends["Tool backends
terminal / browser / web / MCP / file / vision"] + + cli --> agent + gateway --> agent + acp --> agent + batch --> agent + api --> agent + library --> agent + + prompt --> compression + provider --> modes + dispatch --> registry + + agent --> storage + agent --> backends ``` ## Directory Structure ```text hermes-agent/ -├── run_agent.py # AIAgent — core conversation loop (~9,200 lines) -├── cli.py # HermesCLI — interactive terminal UI (~8,500 lines) -├── model_tools.py # Tool discovery, schema collection, dispatch -├── toolsets.py # Tool groupings and platform presets -├── hermes_state.py # SQLite session/state database with FTS5 -├── hermes_constants.py # HERMES_HOME, profile-aware paths -├── batch_runner.py # Batch trajectory generation -│ -├── agent/ # Agent internals -│ ├── prompt_builder.py # System prompt assembly -│ ├── context_compressor.py # Conversation compression algorithm -│ ├── prompt_caching.py # Anthropic prompt caching -│ ├── auxiliary_client.py # Auxiliary LLM for side tasks (vision, summarization) -│ ├── model_metadata.py # Model context lengths, token estimation -│ ├── models_dev.py # models.dev registry integration -│ ├── anthropic_adapter.py # Anthropic Messages API format conversion -│ ├── display.py # KawaiiSpinner, tool preview formatting -│ ├── skill_commands.py # Skill slash commands -│ ├── memory_manager.py # Memory manager orchestration -│ ├── memory_provider.py # Memory provider ABC -│ └── trajectory.py # Trajectory saving helpers -│ -├── hermes_cli/ # CLI subcommands and setup -│ ├── main.py # Entry point — all `hermes` subcommands (~5,500 lines) -│ ├── config.py # DEFAULT_CONFIG, OPTIONAL_ENV_VARS, migration -│ ├── commands.py # COMMAND_REGISTRY — central slash command definitions -│ ├── auth.py # PROVIDER_REGISTRY, credential resolution -│ ├── runtime_provider.py # Provider → api_mode + credentials -│ ├── models.py # Model catalog, provider model lists -│ ├── model_switch.py # /model command logic (CLI + gateway shared) -│ ├── setup.py # Interactive setup wizard (~3,100 lines) -│ ├── skin_engine.py # CLI theming engine -│ ├── skills_config.py # hermes skills — enable/disable per platform -│ ├── skills_hub.py # /skills slash command -│ ├── tools_config.py # hermes tools — enable/disable per platform -│ ├── plugins.py # PluginManager — discovery, loading, hooks -│ ├── callbacks.py # Terminal callbacks (clarify, sudo, approval) -│ └── gateway.py # hermes gateway start/stop -│ -├── tools/ # Tool implementations (one file per tool) -│ ├── registry.py # Central tool registry -│ ├── approval.py # Dangerous command detection -│ ├── terminal_tool.py # Terminal orchestration -│ ├── process_registry.py # Background process management -│ ├── file_tools.py # read_file, write_file, patch, search_files -│ ├── web_tools.py # web_search, web_extract -│ ├── browser_tool.py # 11 browser automation tools -│ ├── code_execution_tool.py # execute_code sandbox -│ ├── delegate_tool.py # Subagent delegation -│ ├── mcp_tool.py # MCP client (~2,200 lines) -│ ├── credential_files.py # File-based credential passthrough -│ ├── env_passthrough.py # Env var passthrough for sandboxes -│ ├── ansi_strip.py # ANSI escape stripping -│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity) -│ -├── gateway/ # Messaging platform gateway -│ ├── run.py # GatewayRunner — message dispatch (~7,500 lines) -│ ├── session.py # SessionStore — conversation persistence -│ ├── delivery.py # Outbound message delivery -│ ├── pairing.py # DM pairing authorization -│ ├── hooks.py # Hook discovery and lifecycle events -│ ├── mirror.py # Cross-session message mirroring -│ ├── status.py # Token locks, profile-scoped process tracking -│ ├── builtin_hooks/ # Always-registered hooks -│ └── platforms/ # 15 adapters: telegram, discord, slack, whatsapp, -│ # signal, matrix, mattermost, email, sms, -│ # dingtalk, feishu, wecom, weixin, bluebubbles, homeassistant, webhook -│ -├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains) -├── cron/ # Scheduler (jobs.py, scheduler.py) -├── plugins/memory/ # Memory provider plugins -├── environments/ # RL training environments (Atropos) -├── skills/ # Bundled skills (always available) -├── optional-skills/ # Official optional skills (install explicitly) -├── website/ # Docusaurus documentation site -└── tests/ # Pytest suite (~3,000+ tests) + run_agent.py # AIAgent — core conversation loop (~9,200 lines) + cli.py # HermesCLI — interactive terminal UI (~8,500 lines) + model_tools.py # Tool discovery, schema collection, dispatch + toolsets.py # Tool groupings and platform presets + hermes_state.py # SQLite session/state database with FTS5 + hermes_constants.py # HERMES_HOME, profile-aware paths + batch_runner.py # Batch trajectory generation + + agent/ # Agent internals + prompt_builder.py # System prompt assembly + context_compressor.py # Conversation compression algorithm + prompt_caching.py # Anthropic prompt caching + auxiliary_client.py # Auxiliary LLM for side tasks (vision, summarization) + model_metadata.py # Model context lengths, token estimation + models_dev.py # models.dev registry integration + anthropic_adapter.py # Anthropic Messages API format conversion + display.py # KawaiiSpinner, tool preview formatting + skill_commands.py # Skill slash commands + memory_manager.py # Memory manager orchestration + memory_provider.py # Memory provider ABC + trajectory.py # Trajectory saving helpers + + hermes_cli/ # CLI subcommands and setup + main.py # Entry point — all `hermes` subcommands (~5,500 lines) + config.py # DEFAULT_CONFIG, OPTIONAL_ENV_VARS, migration + commands.py # COMMAND_REGISTRY — central slash command definitions + auth.py # PROVIDER_REGISTRY, credential resolution + runtime_provider.py # Provider → api_mode + credentials + models.py # Model catalog, provider model lists + model_switch.py # /model command logic (CLI + gateway shared) + setup.py # Interactive setup wizard (~3,100 lines) + skin_engine.py # CLI theming engine + skills_config.py # hermes skills — enable/disable per platform + skills_hub.py # /skills slash command + tools_config.py # hermes tools — enable/disable per platform + plugins.py # PluginManager — discovery, loading, hooks + callbacks.py # Terminal callbacks (clarify, sudo, approval) + gateway.py # hermes gateway start/stop + + tools/ # Tool implementations (one file per tool) + registry.py # Central tool registry + approval.py # Dangerous command detection + terminal_tool.py # Terminal orchestration + process_registry.py # Background process management + file_tools.py # read_file, write_file, patch, search_files + web_tools.py # web_search, web_extract + browser_tool.py # 11 browser automation tools + code_execution_tool.py # execute_code sandbox + delegate_tool.py # Subagent delegation + mcp_tool.py # MCP client (~2,200 lines) + credential_files.py # File-based credential passthrough + env_passthrough.py # Env var passthrough for sandboxes + ansi_strip.py # ANSI escape stripping + environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity) + + gateway/ # Messaging platform gateway + run.py # GatewayRunner — message dispatch (~7,500 lines) + session.py # SessionStore — conversation persistence + delivery.py # Outbound message delivery + pairing.py # DM pairing authorization + hooks.py # Hook discovery and lifecycle events + mirror.py # Cross-session message mirroring + status.py # Token locks, profile-scoped process tracking + builtin_hooks/ # Always-registered hooks + platforms/ # 15 adapters: telegram, discord, slack, whatsapp, + # signal, matrix, mattermost, email, sms, + # dingtalk, feishu, wecom, weixin, bluebubbles, + # homeassistant, webhook + + acp_adapter/ # ACP server (VS Code / Zed / JetBrains) + cron/ # Scheduler (jobs.py, scheduler.py) + plugins/memory/ # Memory provider plugins + environments/ # RL training environments (Atropos) + skills/ # Bundled skills (always available) + optional-skills/ # Official optional skills (install explicitly) + website/ # Docusaurus documentation site + tests/ # Pytest suite (~3,000+ tests) ``` ## Data Flow diff --git a/website/docs/developer-guide/gateway-internals.md b/website/docs/developer-guide/gateway-internals.md index 0c6a753ec5c3..3fe5709a393a 100644 --- a/website/docs/developer-guide/gateway-internals.md +++ b/website/docs/developer-guide/gateway-internals.md @@ -25,28 +25,28 @@ The messaging gateway is the long-running process that connects Hermes to 14+ ex ## Architecture Overview -```text -┌─────────────────────────────────────────────────┐ -│ GatewayRunner │ -│ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Telegram │ │ Discord │ │ Slack │ ... │ -│ │ Adapter │ │ Adapter │ │ Adapter │ │ -│ └─────┬─────┘ └─────┬────┘ └─────┬────┘ │ -│ │ │ │ │ -│ └──────────────┼──────────────┘ │ -│ ▼ │ -│ _handle_message() │ -│ │ │ -│ ┌────────────┼────────────┐ │ -│ ▼ ▼ ▼ │ -│ Slash command AIAgent Queue/BG │ -│ dispatch creation sessions │ -│ │ │ -│ ▼ │ -│ SessionStore │ -│ (SQLite persistence) │ -└─────────────────────────────────────────────────┘ +```mermaid +flowchart TB + telegram["Telegram adapter"] + discord["Discord adapter"] + slack["Slack adapter"] + other["Other platform adapters"] + + handler["_handle_message()"] + slash["Slash command dispatch"] + agent["AIAgent creation"] + queue["Queue / background sessions"] + store["SessionStore
SQLite persistence"] + + telegram --> handler + discord --> handler + slack --> handler + other --> handler + + handler --> slash + handler --> agent + handler --> queue + agent --> store ``` ## Message Flow diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 16bb0ce4714a..7c0480b86670 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -65,12 +65,14 @@ If `git status --short` shows unexpected changes after `hermes update`, stop and hermes version ``` -Compare against the latest release at the [GitHub releases page](https://github.com/NousResearch/hermes-agent/releases) or check for available updates: +Compare against the latest release at the [GitHub releases page](https://github.com/NousResearch/hermes-agent/releases) or run a read-only update check: ```bash hermes update --check ``` +`hermes update --check` fetches the latest git refs and reports whether the current checkout is up to date, behind, ahead, or diverged. It does not pull code, reinstall dependencies, migrate config, or restart the gateway. + ### Updating from Messaging Platforms You can also update directly from Telegram, Discord, Slack, or WhatsApp by sending: diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index a7362b06ff7d..64dc54565410 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -745,7 +745,7 @@ hermes completion zsh >> ~/.zshrc | Command | Description | |---------|-------------| | `hermes version` | Print version information. | -| `hermes update` | Pull latest changes and reinstall dependencies. | +| `hermes update [--check]` | Pull latest changes and reinstall dependencies, or run a read-only update check. | | `hermes uninstall [--full] [--yes]` | Remove Hermes, optionally deleting all config/data. | ## See also