4bdd64b334
* fix(auth): close the free tier's gaps against the gateway's welcome-tier contract The inference gateway's welcome tier (NousResearch/api DOCS/anon-tier/plan.md) serves an anonymous account exactly one model on its own host, refuses everything else with a structured 429, cross-refuses a request on the wrong host with a 400 (403 while the tier is dark), and tells a signed-in account that still asks for `nous/welcome` what to switch to in an `x-nous-model-switch` header. Four client-side gaps against that contract: - Auxiliary calls were refused on every session. The auxiliary client asked the welcome host for the Portal's recommended compaction/vision model, a guaranteed 429 `model_not_free` before each fallback. On the welcome host it now uses `nous/welcome` (its backing model covers auxiliary work) and skips Nous for vision, which the welcome model does not take. - The structured 429 body was never read. The classifier now parses `reason` / `retry_after` / `alternates` / `upgrade_url`: `model_not_free` and `feature_not_free` are non-retryable gates that fall back; `at_capacity`, `admission_closed` and `rate_limited` are rate limits that honour `retry_after` and never rotate the free tier's only credential. The wrong-host 400 and the dark-tier 403 are deterministic, so they abort this route and fall back instead of retrying or re-exchanging. The terminal paths say what happened and name the sign-in (`/login` in a chat, `hermes auth upgrade` in a terminal). - The `x-nous-model-switch` header was ignored. The chat-completions transport records it beside the rate-limit and credits headers; the next call moves the session, and the config default when it still names `nous/welcome`, to the backing model the gateway named. - A guest fell back to the paid host. With `inference_base_url` absent from the exchange or outside the host allowlist, routing defaulted to inference-api, where every request is a 400. A guest now defaults to the welcome literal at the exchange, in the shared store's shape, and in effective routing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit fc758aad7efceff6223fc144a9b5c69f13e41bd8) * feat(auth): the free tier is set up on request; nous.guest_setup decides whether also on first use A caller that names nous/welcome on a Nous route with no Nous identity in reach — the guided setup's session (provider=nous, which skips the resolver's nothing-configured rung), the free-tier picker row, a bare --provider nous pointed at it — is asking for the free tier. The OAuth runtime rung now sets it up there instead of failing "not logged in", so the guided chat no longer races the root profile's first-run mint. nous.guest_setup is the policy seam: "auto" (default) keeps today's first-use setup wherever nothing else is configured; "on-request" mints only when the free tier is asked for by name (nous/welcome, /login, hermes auth upgrade, replacing a retired identity). Implicit callers — the resolver's last rung, the first-run check, free_tier.status, the CLI's background setup, the connector token path — still adopt what the shared store holds, so every profile follows the one identity the guided setup created, but never create one on their own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit ae915ddc65ecdb81b81e29b604671d15cd49233c) (cherry picked from commit 62ad1ff3ab200ea064975a32c502041b25910165) * feat(auth): the guided setup provisions the free tier explicitly; nous.guest_setup is auto | explicit Two questions govern the free tier: may it exist (nous.guest) and who may CREATE the identity (nous.guest_setup). "auto" (default) keeps today's first-use setup wherever nothing else is configured. "explicit" means Hermes never creates one on its own: the only creator is the new provision_free_tier() primitive, exposed as the free_tier.provision RPC, which the guided setup on Hermes Desktop calls as its first step — on the root gateway, before the setup profile and before the guided chat exists — so the identity lands in the root store every profile reads through and is there before any session asks for nous/welcome. That closes the race against the backend's own setup, and makes "only when the setup-bot flow is used" literally true. The earlier "on-request" tier is replaced: it minted whenever any caller named nous/welcome (the hermes model row, --provider nous), which treated a model name as intent and was broader than the guided setup. Under "explicit" a nous/welcome request with no identity fails "not logged in" as before the free tier existed, and /login or hermes auth upgrade report nothing to sign in from. Implicit callers still adopt an identity the shared store holds, and a retired credential is replaced (a continuation, not a creation). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit c63d2c935c1e59016164fdfb90cf70b4094466a0) * fix(auth): remove the nous.guest_setup knob; the free tier is created on first use `nous.guest_setup: auto | explicit` decided who may CREATE the free-tier identity. Under its default every line it added was inert (`may_mint` always true), nothing in tree set `explicit`, unknown values read as `auto`, and under `explicit` a CLI-only install could never get an identity, which contradicts the first-run contract (first command mints, then chats). The mint race the knob accompanied is already benign: every caller takes the profile lock then the shared-store lock, and the loser adopts what the winner wrote. What makes the guided setup win deterministically is `provision_free_tier()` behind the `free_tier.provision` RPC, which stays. `nous.guest` remains the only free-tier policy. Removed: `guest_setup_policy()` and its constants, the `explicit=` / `may_mint=` threading through `ensure_portal_identity` and `_reconcile_and_provision`, the flag at the three replacement call sites (now no-ops), the config default, the docs section, and the four `guest_setup` test-config entries. The three policy tests that hold regardless of the knob are kept under `TestExplicitProvision`; the two that only tested the knob are deleted. (cherry picked from commit d8a50526d93c374c0067dd935b5a65055e0af261) * fix(gateway): a server-driven model switch off nous/welcome does not evict the cached agent When a signed-in account still asks the paid host for `nous/welcome`, the inference gateway serves the current backing model and names it in `x-nous-model-switch`. `apply_model_switch` moves the live session to that model and moves `config.yaml`'s default off the alias in the same step. The messaging gateway's fallback-eviction check compares the agent's model with the config default and evicts on any mismatch that is not a /model override, so when the config write did not land (unreadable config, lock) the cached agent was evicted once per turn, and prompt caching with it. `apply_model_switch` now stamps the alias it moved the session off on the agent, and `_is_intentional_model_switch` treats "agent moved off the alias the config still carries" as deliberate, beside the existing /model override case. The check takes the agent and the config model instead of a bare model string; its one caller in `_run_agent_evict_on_fallback` passes them. (cherry picked from commit 696d1ec86b69db28bf002c841e9389b85178a954) * fix(auth): the free tier outranks implicit host credentials in provider resolution On a fresh install with a leftover ~/.aws profile, resolve_provider("auto") reached the Bedrock rung before the free-tier rung, so the first turn ran on Bedrock and failed 403 while the free tier was still being minted in the background at agent setup (NS-829). Live on a Mac with ~/.aws present: 28 s, three retries, no answer; the next process then switched to nous/welcome. The free-tier rung now sits directly above the Bedrock chain: when nous.guest is on, an existing free-tier identity answers, else a blocking mint runs, and only then does the boto chain get a say. Everything above is unchanged and still wins: CLI creds, config.yaml model.provider, env keys, the OpenRouter pool, a logged-in active_provider. nous.guest: false skips the rung, and a failed mint still falls through to Bedrock and the no-provider guidance. Tests: six precedence cases (identity present, fresh mint, free tier off, env key still wins, sign-in still wins, failed mint falls through). The opt-out test now neutralizes the AWS chain like the precedence tests do; on a machine with ~/.aws it was failing for the same reason as the bug. Live after the fix, same Mac, AWS credentials visible, isolated shared store: identity minted 2 s in, turn on model=nous/welcome provider=nous, answer in 11 s. (cherry picked from commit a04b05260cd334dd7199ad9b6cd5b2538364c75a) * fix(auth): review follow-ups for the free-tier rung (NS-829) - tests/agent/test_bedrock_integration.py: the Bedrock auto-detect test switches the free tier off; its contract is the boto chain, and the free tier now sits above it. - gateway/run_notifications.py: the free-tier startup line reads auth.json before consulting the resolver, so a gateway boot on a machine with AWS credentials never mints or refreshes over the network. - hermes_cli/anon_auth.py: module docstring says where the free tier sits in the ladder instead of "the ladder is untouched". - tests/hermes_cli/test_provider_precedence.py: two invariant tests instead of six (parametrized ladder cases; a failed mint that returns None or raises falls through to Bedrock). scripts/run_tests.sh on the five affected files: 147 passed, 0 failed. (cherry picked from commit 10790d148c60ada11b9ecdde2cd2c836c6a82a11) * feat(auth): HERMES_GUEST_ONBOARDING=1 is the one launch gate for the free tier; HERMES_FORCE_GUEST is gone The free tier is pre-GA. Until GA it must not exist for anyone who did not ask for it: no identity minted, no portal traffic, no free-tier copy on any surface. One environment variable now decides that, and one function reads it. `guest_enabled()` returns False unless `HERMES_GUEST_ONBOARDING` is exactly "1"; only then does `nous.guest` (the user's off switch) get consulted. Every free-tier site already funnels through `guest_enabled()`, so the gate closes minting, routing, connector entitlement, status lines and the picker row in one place. With the variable unset, `resolve_provider("auto")` on a fresh install raises `no_provider_configured` exactly as upstream does. `HERMES_FORCE_GUEST` and `force_guest_mode()` are removed. They inverted the gate (forced the tier ON over `nous.guest: false`), their "new" value re-minted identities as a side effect of provider resolution, and `_has_any_provider_ configured` read them ahead of every other check, making the CLI a second reader of a flag that must have exactly one. `_forced_new_done` and the `force` parameter of `_reconcile_and_provision` go with them. Supersedes the dev lever introduced in fcf9d11679 (rung 1) and hardened in b5c162c3ec. Ruling: NS-845 Q1.1 (recorded on NS-847). Not a user preference: the variable is never written to config.yaml or .env and never shown in setup. It is deleted at GA together with its comment in anon_auth.py. This is a deliberate, temporary exception to the "no new HERMES_* env vars for non-secret config" rule. Tests: fixtures set the gate instead of deleting the old lever; one new invariant (`test_launch_gate_off_means_no_free_tier_at_all`) proves that "", "0", "true" and "new" all leave the tier off with zero portal calls, red on the previous commit. The `HERMES_FORCE_GUEST=new` re-mint test is deleted with the feature. * feat(auth): the free-tier identity is created in one place, at boot; every other site is a read Before this commit eight sites could create a Nous free-tier identity as a side effect of something else: resolving a provider, the CLI's first-run check, the CLI's session setup (in the background beside an own key), a connector bearer read, the desktop polling `free_tier.status`, the sign-in precondition, the desktop's `free_tier.provision`, and the dead-credential re-mint. A poll could mint. Provider resolution could hit the network. Two of them raced each other on a fresh install. Now `hermes_cli/free_tier_bootstrap.py::run_bootstrap` is the only creator. `hermes serve` runs it on a daemon thread from `_lifespan` beside the other background boots; `cmd_chat` runs it synchronously before the first-run guard. It inventories credentials first (`resolve_provider("auto", skip_free_tier=True)`: what would carry inference if the free tier did not exist), creates the identity only when `guest_enabled()`, resolves inference, records a `SetupRecord` in process memory and broadcasts ONE `setup.ready` event. It runs on every boot; only the mint is gated. `ensure_portal_identity` now requires `explicit=True` and raises otherwise. Its callers are the bootstrap, the desktop's `free_tier.provision` (the explicit retry when the boot could not create the identity) and the two dead-credential replacements (`auth_nous.resolve_nous_runtime_credentials`, `managed_tool_gateway._replace_dead_guest_token`). The background thread path and `provision_free_tier` are deleted with their last callers. Reads that used to mint and now only read: `auth.py::resolve_provider` rung 7 (an existing identity still outranks the Bedrock chain, NS-829 ordering kept), `main.py::_has_any_provider_configured`, `cli_agent_setup_mixin._ensure_runtime_credentials`, `managed_tool_gateway.read_nous_access_token` (no identity -> None), `anon_sign_in.run_sign_in` (no identity -> Unavailable), `methods_free_tier` `free_tier.status`. `setup.status` answers from the record for the launch profile, blocking up to 8 s while the bootstrap is in flight so a client's first poll lands after the identity exists rather than racing it; a named profile, or a process that never ran the bootstrap, keeps today's live probe. The record's fields ride along additively (`ready`, `free_tier`, `other_providers`, `inference_provider`). Identity and inference are decoupled (NS-845 Q1.3): the mint sets `active_provider="nous"` only when the inventory found nothing else usable (`_mint_locked(carries_inference=)`); an adopted account always does. A token refresh no longer re-elects the provider it refreshed (`_save_provider_state_to_source` writes credentials, not the user's choice) — that write was how an own-key install ended up on the free tier after the first connector call. Supersedes the mint sites in fcf9d11679, a42d0748fc (first-run check), bbbaa8935a (CLI background setup), 0179efc989 (`free_tier.status` mint), 62ad1ff3ab / c63d2c935c / d8a50526d9 (the `nous.guest_setup` knob and `provision_free_tier`), and a04b05260c (blocking mint in the resolver). Ruling: NS-845 Q1.2 + Q1.3, recorded on NS-847. Tests: `TestBootstrapIsTheOneCreator` (one mint per process; own key keeps inference; reads never reach the portal; a refused mint is memoised), `free_tier.status` fails loudly if it ever calls the creator, the resolver stub fails loudly if resolution ever mints, `setup.status` reads the record, `skip_free_tier` proves the inventory question. The three sign-in tests for the deleted pre-mint collapse into one (`no identity -> Unavailable, zero portal calls`). Live: real `_lifespan` boot with a fake portal, gate on and off (/tmp/ns847-recon/evidence/e2e-rung5-c2-serve-boot.txt), and the CLI matrix incl. an own-key cell (e2e-rung5-c2-bootstrap.txt), 20/20. * fix(credits): the welcome host is free-tier evidence, so a free-tier identity never sees "run /topup" A free-tier identity carries $0 by design, so the portal seed reports `paid_access=False` for it. `is_free_tier_model` did not know the welcome host, read that as a depleted account, and every free-tier turn ended with the credits-depleted notice telling the user to top up an account they do not have. Rule (4) in `is_free_tier_model`: a `base_url` on the Nous welcome host (`anon_auth.route_is_welcome_host`) is the free tier. The host is the evidence, not the model name: the paid inference host can serve `nous/welcome` to a named account and that account's depletion is real, so `("nous/welcome", <inference host>)` stays False. Local data only, like the three rules above it. Restores the two contracts dropped by hermes-magic 674e11d1eaa (the prototype line ran without unit tests): the welcome host is free without any pricing evidence; the model name alone is not. The first is red without this fix. * fix(copy): free-tier text stops promising a connector transfer and never names the config key Sign-in copy on every surface said "Sign in to keep your connectors" and ended with "Your connectors are kept." The transfer registry that would make that true is empty (NS-821): nothing carries over today. The copy now says what signing in does give ("unlock more models and tools") and the completion line names the account, not a transfer. The docs page loses the "connectors carry over" paragraph for the same reason. The picker's off-state line exposed `nous.guest: false` and the word "guest"; user copy names the free tier only (R-USR-1). The docs page gains the pre-rollout note: until GA nothing on it happens without `HERMES_GUEST_ONBOARDING=1`. Its "first command mints" and "replaced on next use" sentences now describe the boot bootstrap. zh is a strict locale: the `freeTier` block was English placeholder text copied from `en`; it is now Chinese. `connectorsKept` is renamed `completedBody` since it no longer talks about connectors. * feat(desktop): the free-tier launch flag is decided once in Electron and stamped onto every backend spawn The Python backend reads `HERMES_GUEST_ONBOARDING` and treats exactly "1" as on. Until now nothing in the desktop set it, so a packaged app could never turn the free tier on, and a backend spawned by the app could disagree with the app about whether the tier was live. `electron/guest-onboarding.ts` owns the decision: `guestOnboardingEnabled` is true when the launch env has `HERMES_GUEST_ONBOARDING=1` or argv has `--guest-onboarding` (the packaged-app spelling). It is read ONCE at launch into a module constant. `desktopBackendSpawnEnv` wraps every backend env as the outermost call and writes the flag LAST, as "1" or an explicit "0", so no earlier spread (`process.env`, `backend.env`) can resurrect a stray value from the parent shell. Stamped onto all three spawn sites: the primary `serve` spawn, the pooled per-profile spawn, and the remote SSH `exec env ...` command (which gains ` HERMES_GUEST_ONBOARDING=1` only when on). The embedded terminal PTY and the backend probes are not backend spawns and do not get it: a `hermes --tui` typed in the pane must not mint. The renderer learns the same fact read-only through the existing `hermes:launch-flags` sync IPC (`guestOnboarding`) and preload (`window.hermesDesktop.guestOnboardingEnabled`). Ruling: NS-845 Q1.1 / Q2 (env var is the contract, `--guest-onboarding` maps to it in main). Two invariant tests on the pure helpers: only "1" or the argv flag enables; the spawn env carries "1"/"0" as the last word and preserves every other key. * feat(desktop): the renderer learns free-tier readiness from one `setup.ready` push, not a 60 s poll The backend's boot bootstrap now announces `setup.ready` once, after it has created (or refused) the free-tier identity and resolved the inference route. The renderer used to discover both by polling `setup.status`, `setup.runtime_check` and `free_tier.status` every 60 s from `useStatusSnapshot`; a fresh install's chip, notice strip and onboarding overlay could sit stale for up to a minute after boot, and three RPCs a minute per window kept asking a question whose answer changes only at boundaries the backend already announces. `handleLifecycleEvent` routes `setup.ready` (active source only, like `skin.changed`) to `notifySetupReady()`, a one-shot tick atom in `live-sync.ts` beside the other change ticks. `useStatusSnapshot` listens to it and runs one readiness round at once (`setup.status` + `setup.runtime_check` + `free_tier.status`). The readiness legs also run once on open and on return from another app, as today. The 60 s tick keeps only `getStatus()`. `SetupStatusSnapshot` types the record's additive fields (`ready`, `free_tier`, `other_providers`, `inference_provider`); readiness semantics are unchanged and still key on `provider_configured` + `runtime_check`. Ruling: NS-845 Q1.2 (renderer half). Tests: the lifecycle branch fires one refresh from the active source and none from another; the snapshot hook's contract is three legs on open, one leg on the tick. * fix(cli): the banner names the free tier's model instead of "no model configured" The welcome banner prints before credentials resolve, so on a fresh install `model` is empty and the banner said, in red, "no model configured — run /model or hermes setup". Under the free tier that is false: the route is already known from local state (identity on disk, tier on), and the first message will run on `nous/welcome`. `_banner_left_lines` now asks the route the same question when `model` is empty (`guest_carries_inference()`, a local read) and shows `welcome · Nous Research`. When nothing resolves the red line stays. Ruling: NS-845 ("the banner's 'no model configured' line reads the resolved route"). Live: fresh HERMES_HOME + fake portal, gate on -> `welcome · Nous Research`; gate off -> the red line, zero portal calls. * fix(aux): vision on the free tier uses nous/welcome too The text-only modality on the gateway's `nous/welcome` row is DeepSeek V4 Flash's, the backing model until the repoint; `z-ai/glm-5.3-flash` is natively multimodal and the repoint declares the welcome row `text+image->text`. Skipping Nous for vision on the welcome host would have sent every image step past the free tier for no reason, so the auxiliary client pins the route's one model for every lane. A backing model that takes no images answers with the upstream's own error, which the ladder handles as it always has. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 7456e028faba55480db43015dc2c8df3e393a415) * fix(gateway): hermes gateway run is a boot owner of the free tier too Rung 5 made every demand-time free-tier site a read: resolve_provider, the connector token, the /login precondition. That is only correct if every process that can reach those sites ran the bootstrap first. The CLI (cmd_chat) and hermes serve (_lifespan) did; the standalone messaging gateway did not. A fresh HERMES_HOME with the gate on and `hermes gateway run` reached provider resolution with no identity to consume, and /login returned Unavailable. Reported by @andrexibiza on #107697 (P1). GatewayRunner.start now runs `free_tier_bootstrap.run_bootstrap` on an executor thread right after startup recovery and BEFORE any adapter connects, so a fast first DM cannot arrive with nothing to resolve. It is its own step, not part of the turn-machinery warm-up: the warm-up is an optimisation with an off switch (HERMES_STARTUP_WARMUP_TIMEOUT<=0); the bootstrap is correctness and must always run. With the gate unset it is a local inventory and no network. Live, real GatewayRunner.start against a fake portal in a fresh home: gate on -> 1 create, identity persisted, resolve_runtime_provider=nous, /login precondition sees the identity gate off -> 0 portal calls, no identity, no_provider_configured Before the fix the gate-on row was identical to the gate-off row. Test: the bootstrap seam runs before _start_prefilter_platforms and delegates to the one creator. Red on 5554eb6993 (no seam), green here. --------- Co-authored-by: Robin Fernandes <robin@soal.org> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
3481 lines
139 KiB
Python
3481 lines
139 KiB
Python
#!/usr/bin/env python3
|
|
"""Hermes CLI - Main entry point.
|
|
|
|
Usage:
|
|
hermes # Interactive chat (default)
|
|
hermes chat / gateway / setup / status / cron / doctor / update / ...
|
|
hermes --version # Show version and update status
|
|
hermes <cmd> --help # Per-command help
|
|
"""
|
|
|
|
# hermes_bootstrap must be the very first import — it sets up UTF-8 stdio on
|
|
# Windows (no-op on POSIX). Guarded: after a ``git pull`` / interrupted
|
|
# ``hermes update`` the editable install's ``.pth`` may not list it yet; crashing
|
|
# here would block ``hermes update``.
|
|
try:
|
|
import hermes_bootstrap # noqa: F401
|
|
except ModuleNotFoundError:
|
|
pass
|
|
|
|
# Windows: neutralize CPython's ``platform._syscmd_ver`` before anything else
|
|
# imports — it shells out ``cmd /c ver`` and flashes a console when this
|
|
# process is windowless (pythonw gateway, kanban workers). No-op on POSIX.
|
|
from hermes_cli._subprocess_compat import suppress_platform_ver_console
|
|
|
|
suppress_platform_ver_console()
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
# Inline path math so ``python hermes_cli/main.py`` (script mode: sys.path[0]
|
|
# is hermes_cli/, not the repo root) can import hermes_cli._startup_fast.
|
|
_bootstrap_root = os.path.realpath(os.path.join(os.path.dirname(__file__), os.pardir))
|
|
if _bootstrap_root not in sys.path:
|
|
sys.path.insert(0, _bootstrap_root)
|
|
from hermes_cli import _startup_fast # noqa: E402
|
|
|
|
# Early venv self-heal — MUST run before any third-party import below. A prior
|
|
# ``hermes update`` may have left a recovery marker with a core package wiped;
|
|
# the hermes_cli.config/env_loader imports further down would then crash before
|
|
# main() reaches _recover_from_interrupted_install(). ``_early_recovery`` is
|
|
# stdlib-only (safe on a corrupted venv) and repairs just enough to finish this
|
|
# import; the marker lifecycle stays with the full recovery path. Its own
|
|
# import is unguarded on purpose: same package dir, so if IT can't import
|
|
# nothing in hermes_cli can.
|
|
# It is also the canonical home of the probe/repair tables reused by the full recovery path below. See
|
|
# #57828.
|
|
from hermes_cli import _early_recovery as _early_recovery_mod
|
|
|
|
try:
|
|
_early_recovery_mod.recover_if_needed()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# Startup-liveness watchdog: for gateway runs, arm BEFORE the heavy import
|
|
# graph below — an import-time deadlock (native-extension init, contended
|
|
# import lock) is exactly the "wedged before the event loop, no logs, live
|
|
# PID" class it exists for. ``hermes_startup_watchdog`` is stdlib-only so it
|
|
# cannot itself wedge. The match requires the ADJACENT pair ``gateway run``
|
|
# (wherever global flags like ``-p <profile>`` put it) so unrelated commands
|
|
# mentioning both words never arm a 300s hard-exit timer. Foreground runs arm
|
|
# too — a pre-loop wedge is just as dead without a supervisor; GatewayRunner
|
|
# disarms once the event loop is live.
|
|
def _argv_is_gateway_run(argv: list) -> bool:
|
|
return any(a == "gateway" and b == "run" for a, b in zip(argv, argv[1:]))
|
|
|
|
|
|
if _argv_is_gateway_run(sys.argv[1:]):
|
|
try:
|
|
from hermes_startup_watchdog import arm_startup_watchdog as _arm_sw
|
|
|
|
_arm_sw()
|
|
del _arm_sw
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _exit_after_oneshot(rc: object) -> None:
|
|
"""Exit one-shot mode without letting late native finalizers change rc.
|
|
|
|
The SIGABRT this guards against fires in a native-extension finalizer
|
|
during ``Py_FinalizeEx``, *after* the response printed. Flush, shut down
|
|
file logging, then ``os._exit`` past finalization. The ``atexit`` chain is
|
|
deliberately skipped — several handlers re-enter native code that may be
|
|
the abort source; stateful cleanup lives in ``_cleanup_oneshot_runtime``.
|
|
|
|
See #30387, #43055.
|
|
"""
|
|
for stream in (sys.stdout, sys.stderr):
|
|
try:
|
|
stream.flush()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
logging.shutdown()
|
|
except Exception:
|
|
pass
|
|
os._exit(rc if isinstance(rc, int) else (0 if rc is None else 1))
|
|
|
|
|
|
_oneshot_cleanup_done = False
|
|
# (module, attr, kwargs, exceptions swallowed). MCP shutdown may raise
|
|
# BaseException-derived errors from executor teardown; the rest are Exception.
|
|
_ONESHOT_CLEANUPS = (
|
|
("tools.terminal_tool", "cleanup_all_environments", {}, Exception),
|
|
("tools.async_delegation", "interrupt_all", {"reason": "oneshot shutdown"}, Exception),
|
|
("tools.browser_tool_lifecycle", "_emergency_cleanup_all_sessions", {}, Exception),
|
|
("tools.mcp_tool_lifecycle", "shutdown_mcp_servers", {}, BaseException),
|
|
("agent.auxiliary_client", "shutdown_cached_clients", {}, Exception),
|
|
)
|
|
|
|
|
|
def _cleanup_oneshot_runtime() -> None:
|
|
"""Best-effort process-global cleanup before one-shot hard exit.
|
|
|
|
``run_oneshot`` owns the agent-local cleanup (memory provider, agent.close,
|
|
session_db.close — all in ``_run_agent``'s finally block). This mirrors the
|
|
process-global pieces from ``cli.py:_run_cleanup()`` that would otherwise
|
|
be skipped by ``os._exit``.
|
|
"""
|
|
global _oneshot_cleanup_done
|
|
if _oneshot_cleanup_done:
|
|
return
|
|
_oneshot_cleanup_done = True
|
|
import importlib
|
|
|
|
for module, attr, kwargs, swallow in _ONESHOT_CLEANUPS:
|
|
try:
|
|
getattr(importlib.import_module(module), attr)(**kwargs)
|
|
except swallow:
|
|
pass
|
|
|
|
|
|
def _run_and_exit_oneshot(
|
|
prompt: str,
|
|
*,
|
|
model: object = None,
|
|
provider: object = None,
|
|
toolsets: object = None,
|
|
skills: object = None,
|
|
usage_file: object = None,
|
|
resume: object = None,
|
|
reasoning: object = None,
|
|
) -> None:
|
|
try:
|
|
from hermes_cli.oneshot import run_oneshot
|
|
|
|
rc = run_oneshot(
|
|
prompt,
|
|
model=model,
|
|
provider=provider,
|
|
toolsets=toolsets,
|
|
skills=skills,
|
|
usage_file=usage_file,
|
|
resume=resume,
|
|
reasoning=reasoning,
|
|
)
|
|
except KeyboardInterrupt:
|
|
rc = 130
|
|
except SystemExit as exc:
|
|
if exc.code is not None and not isinstance(exc.code, int):
|
|
print(exc.code, file=sys.stderr)
|
|
rc = 1
|
|
else:
|
|
rc = exc.code
|
|
except BaseException:
|
|
# ``run_oneshot`` already maps agent failures to an int rc; anything
|
|
# still escaping means it malfunctioned. Print it but never fall
|
|
# through to interpreter teardown (the SIGABRT path this routine fixes).
|
|
import traceback
|
|
try:
|
|
traceback.print_exc()
|
|
except Exception:
|
|
pass
|
|
rc = 1
|
|
try:
|
|
_cleanup_oneshot_runtime()
|
|
finally:
|
|
# Even an interrupt during cleanup must not fall back into interpreter
|
|
# finalization, where the native SIGABRT occurs.
|
|
# The hard exit is the safety boundary for #43055.
|
|
_exit_after_oneshot(rc)
|
|
|
|
|
|
def _set_process_title() -> None:
|
|
"""Cosmetic: show 'hermes' instead of 'python3.xx' in ps/top/htop.
|
|
|
|
Order: opt-in ``setproctitle`` dep; ctypes ``prctl(PR_SET_NAME)`` (Linux,
|
|
15-char limit); ``pthread_setname_np`` (macOS — lldb/top only, not ``ps
|
|
aux``); no-op on Windows (the .exe is already ``hermes.exe``). Never fatal.
|
|
"""
|
|
try:
|
|
import setproctitle # type: ignore[import-untyped]
|
|
|
|
setproctitle.setproctitle("hermes")
|
|
return
|
|
except ImportError:
|
|
pass
|
|
|
|
import ctypes
|
|
import platform
|
|
|
|
try:
|
|
system = platform.system()
|
|
if system == "Linux":
|
|
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
|
libc.prctl(15, b"hermes", 0, 0, 0) # PR_SET_NAME = 15
|
|
elif system == "Darwin":
|
|
libc = ctypes.CDLL("libc.dylib", use_errno=True)
|
|
libc.pthread_setname_np(b"hermes")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# Cheap read of `display.interface` for the earliest hot-path decisions
|
|
# (mouse-residue suppression, Termux fast launch) that run before
|
|
# hermes_cli.config is importable. Cached so early callers don't re-parse YAML.
|
|
_EARLY_INTERFACE_CACHE: "list | None" = None
|
|
|
|
|
|
def _config_default_interface_early() -> str:
|
|
"""Return the configured default interface ("cli"/"tui") via a minimal
|
|
YAML read. Best-effort: any error falls back to "cli" (legacy behavior)."""
|
|
global _EARLY_INTERFACE_CACHE
|
|
if _EARLY_INTERFACE_CACHE is not None:
|
|
return _EARLY_INTERFACE_CACHE[0]
|
|
value = "cli"
|
|
try:
|
|
home = os.environ.get("HERMES_HOME")
|
|
if home:
|
|
cfg_path = os.path.join(home, "config.yaml")
|
|
else:
|
|
cfg_path = os.path.join(os.path.expanduser("~"), ".hermes", "config.yaml")
|
|
if os.path.exists(cfg_path):
|
|
import yaml as _yaml_iface
|
|
|
|
with open(cfg_path, encoding="utf-8") as _f:
|
|
raw = _yaml_iface.load(
|
|
_f, Loader=getattr(_yaml_iface, "CSafeLoader", None) or _yaml_iface.SafeLoader
|
|
) or {}
|
|
disp = raw.get("display", {})
|
|
if isinstance(disp, dict):
|
|
iface = disp.get("interface")
|
|
if isinstance(iface, str) and iface.strip().lower() == "tui":
|
|
value = "tui"
|
|
except Exception:
|
|
value = "cli" # best-effort — default to classic REPL on any error
|
|
_EARLY_INTERFACE_CACHE = [value]
|
|
return value
|
|
|
|
|
|
def _wants_tui_early(argv: "list[str] | None" = None) -> bool:
|
|
"""Earliest TUI decision, usable before argparse/config imports.
|
|
|
|
Precedence: ``--cli`` wins, then ``--tui``/``HERMES_TUI=1``, then a
|
|
real-TTY gate, then ``display.interface``. The TTY gate is load-bearing
|
|
for headless spawners (kanban workers, cron, pipes running ``chat -q``):
|
|
a ``display.interface: tui`` default used to boot the TUI here, whose
|
|
no-TTY bail-out exits 0 without doing the task. An explicit ``--tui``
|
|
still reaches that informative bail-out.
|
|
"""
|
|
if argv is None:
|
|
argv = sys.argv[1:]
|
|
if "--cli" in argv:
|
|
return False
|
|
if os.environ.get("HERMES_TUI") == "1" or "--tui" in argv:
|
|
return True
|
|
try:
|
|
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
return False
|
|
except Exception:
|
|
return False
|
|
return _config_default_interface_early() == "tui"
|
|
|
|
|
|
# Mouse-tracking residue suppression — runs BEFORE every other import on the
|
|
# TUI hot path: while the launcher is still importing (~100-300ms, cooked+echo
|
|
# mode, before the Node TUI takes stdin raw) incoming SGR/X10 mouse reports
|
|
# echo into the shell scrollback as ``^[[<…M``. entry.tsx's
|
|
# `resetTerminalModes()` is the later cousin. ``HERMES_TUI_NO_EARLY_DISABLE``
|
|
# escapes the behaviour for diagnostics.
|
|
def _suppress_mouse_residue_early() -> None:
|
|
if os.environ.get("HERMES_TUI_NO_EARLY_DISABLE") == "1":
|
|
return
|
|
if not _wants_tui_early():
|
|
return
|
|
try:
|
|
if not os.isatty(1): # redirected stdout: raw CSI would pollute the log
|
|
return
|
|
# Every mouse-tracking variant we know about; idempotent.
|
|
os.write(
|
|
1,
|
|
b"\x1b[?1003l\x1b[?1002l\x1b[?1001l\x1b[?1000l\x1b[?9l"
|
|
b"\x1b[?1006l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?2029l",
|
|
)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
_suppress_mouse_residue_early()
|
|
|
|
|
|
_startup_fast.ensure_project_root_on_path()
|
|
|
|
# ``hermes --version`` is answered before config/logging imports.
|
|
if _startup_fast.try_fast_version():
|
|
raise SystemExit(0)
|
|
|
|
import argparse
|
|
import contextlib
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
|
|
from hermes_cli.subcommands.cron import build_cron_parser
|
|
from hermes_cli.subcommands.sync import build_sync_parser
|
|
from hermes_cli.subcommands.gateway import build_gateway_parser
|
|
from hermes_cli.subcommands.profile import build_profile_parser
|
|
from hermes_cli.subcommands.model import build_model_parser
|
|
from hermes_cli.subcommands.setup import build_setup_parser
|
|
|
|
from hermes_cli.subcommands.whatsapp import build_whatsapp_parser, build_whatsapp_cloud_parser
|
|
from hermes_cli.subcommands.slack import build_slack_parser
|
|
from hermes_cli.subcommands.login import build_login_parser
|
|
from hermes_cli.subcommands.logout import build_logout_parser
|
|
from hermes_cli.subcommands.auth import build_auth_parser
|
|
from hermes_cli.subcommands.status import build_status_parser
|
|
from hermes_cli.subcommands.pause import build_pause_parser
|
|
from hermes_cli.subcommands.webhook import build_webhook_parser
|
|
from hermes_cli.subcommands.hooks import build_hooks_parser
|
|
from hermes_cli.subcommands.doctor import build_doctor_parser
|
|
from hermes_cli.subcommands.verify import build_verify_parser
|
|
from hermes_cli.subcommands.security import build_security_parser
|
|
from hermes_cli.subcommands.approvals import build_approvals_parser
|
|
from hermes_cli.subcommands.dump import build_dump_parser
|
|
from hermes_cli.subcommands.debug import build_debug_parser
|
|
from hermes_cli.subcommands.backup import build_backup_parser
|
|
from hermes_cli.subcommands.import_cmd import build_import_cmd_parser
|
|
from hermes_cli.subcommands.import_agent import build_import_agent_parser
|
|
from hermes_cli.subcommands.config import build_config_parser
|
|
from hermes_cli.subcommands.skin import build_skin_parser
|
|
from hermes_cli.subcommands.console import build_console_parser
|
|
from hermes_cli.subcommands.update import build_update_parser
|
|
from hermes_cli.subcommands.uninstall import build_uninstall_parser
|
|
from hermes_cli.subcommands.dashboard import build_dashboard_parser, build_serve_parser
|
|
from hermes_cli.subcommands.gui import build_gui_parser
|
|
from hermes_cli.subcommands.logs import build_logs_parser
|
|
from hermes_cli.subcommands.prompt_size import build_prompt_size_parser
|
|
from hermes_cli.subcommands.memory import build_memory_parser
|
|
from hermes_cli.subcommands.acp import build_acp_parser
|
|
from hermes_cli.subcommands.tools import build_tools_parser
|
|
from hermes_cli.subcommands.insights import build_insights_parser
|
|
from hermes_cli.subcommands.monitoring import build_monitoring_parser
|
|
from hermes_cli.subcommands.skills import build_skills_parser
|
|
from hermes_cli.subcommands.pairing import build_pairing_parser
|
|
from hermes_cli.subcommands.plugins import build_plugins_parser
|
|
from hermes_cli.subcommands.mcp import build_mcp_parser
|
|
from hermes_cli.subcommands.claw import build_claw_parser
|
|
from hermes_cli.subcommands.vault import build_vault_parser
|
|
from hermes_cli.subcommands.moa import build_moa_parser
|
|
from hermes_cli.subcommands.fallback import build_fallback_parser
|
|
from hermes_cli.subcommands.worktree import build_worktree_parser
|
|
from hermes_cli.subcommands.browser import build_browser_parser
|
|
from hermes_cli.subcommands.secrets import build_secrets_parser
|
|
from hermes_cli.subcommands.egress import build_egress_parser
|
|
from hermes_cli.subcommands.migrate import build_migrate_parser
|
|
from hermes_cli.subcommands.checkpoints import build_checkpoints_parser
|
|
from hermes_cli.subcommands.bundles import build_bundles_parser
|
|
from hermes_cli.subcommands.curator import build_curator_parser
|
|
from hermes_cli.subcommands.pets import build_pets_parser
|
|
from hermes_cli.subcommands.journey import build_journey_parser
|
|
from hermes_cli.subcommands.computer_use import build_computer_use_parser
|
|
from hermes_cli.subcommands.sessions import build_sessions_parser
|
|
from hermes_cli.subcommands.completion import build_completion_parser
|
|
|
|
|
|
def _require_tty(command_name: str) -> None:
|
|
"""Exit 1 if stdin is not a terminal: curses/input() prompts spin at 100% CPU on a pipe."""
|
|
if not sys.stdin.isatty():
|
|
print(
|
|
f"Error: 'hermes {command_name}' requires an interactive terminal.\n"
|
|
f"It cannot be run through a pipe or non-interactive subprocess.\n"
|
|
f"Run it directly in your terminal instead.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
PROJECT_ROOT = Path(_startup_fast.project_root_str())
|
|
_startup_fast.ensure_project_root_on_path()
|
|
|
|
|
|
# Profile override — MUST happen before any hermes module import: many modules
|
|
# cache HERMES_HOME at import time. --profile/-p is pre-parsed from sys.argv,
|
|
# HERMES_HOME set, and the flag stripped so argparse never sees it. Falls back
|
|
# to ~/.hermes/active_profile for the sticky default.
|
|
_PROFILE_NAME_RE = r"^[a-z0-9][a-z0-9_-]{0,63}$" # mirrors hermes_cli.profiles._PROFILE_ID_RE
|
|
|
|
|
|
def _inside_mcp_add_args(argv: list, index: int) -> bool:
|
|
"""True once argv reaches `hermes mcp add ... --args <command argv>`.
|
|
|
|
``mcp add --args`` is command-argv passthrough. Flags after that point
|
|
belong to the child MCP command (for example Docker MCP Toolkit's
|
|
``--profile``), not to Hermes' own profile selector.
|
|
"""
|
|
try:
|
|
mcp_index = argv.index("mcp", 0, index)
|
|
argv.index("add", mcp_index + 1, index)
|
|
except ValueError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _scan_profile_flag(argv: list) -> tuple:
|
|
"""Find -p/--profile/--profile= in argv -> (name, tokens_consumed, index).
|
|
|
|
Historically the flag worked even after the subcommand (`hermes chat -p
|
|
coder`), so scan broadly; stop at ``--`` and at the `mcp add --args`
|
|
passthrough region. Values that can't be profile names (pytest's
|
|
``-p no:xdist``) are rejected so resolve_profile_env never sys.exits on them.
|
|
"""
|
|
from hermes_cli._parser import top_level_value_flag_sets
|
|
|
|
value_flags, optional_value_flags = top_level_value_flag_sets()
|
|
i = 0
|
|
while i < len(argv):
|
|
arg = argv[i]
|
|
if arg == "--" or (arg == "--args" and _inside_mcp_add_args(argv, i)):
|
|
break
|
|
if arg in {"--profile", "-p"} and i + 1 < len(argv):
|
|
if re.match(_PROFILE_NAME_RE, argv[i + 1]):
|
|
return argv[i + 1], 2, i
|
|
break
|
|
if arg.startswith("--profile="):
|
|
return arg.split("=", 1)[1], 1, i
|
|
takes_value = "=" not in arg and i + 1 < len(argv) and (
|
|
arg in value_flags
|
|
or (arg in optional_value_flags and not argv[i + 1].startswith("-"))
|
|
)
|
|
i += 2 if takes_value else 1
|
|
return None, 0, None
|
|
|
|
|
|
def _resolve_sudo_user_profile_env(name: str) -> str | None:
|
|
"""Resolve `sudo hermes -p <name>` against the invoking user's home.
|
|
|
|
This runs before argparse, so `--run-as-user` is not available yet. For
|
|
sudo invocations the best signal is SUDO_USER: root is only doing the
|
|
privileged install/start action; the profile store belongs to the user.
|
|
"""
|
|
if name == "default" or not hasattr(os, "geteuid") or os.geteuid() != 0:
|
|
return None
|
|
sudo_user = os.environ.get("SUDO_USER", "").strip()
|
|
if not sudo_user or sudo_user == "root":
|
|
return None
|
|
try:
|
|
import pwd
|
|
|
|
candidate = Path(pwd.getpwnam(sudo_user).pw_dir) / ".hermes" / "profiles" / name
|
|
return str(candidate) if candidate.is_dir() else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _under_gateway_supervisor(argv: list) -> bool:
|
|
"""A supervisor-launched gateway child must NOT follow the sticky active_profile.
|
|
|
|
Each supervised slot has a fixed profile identity: named slots pass
|
|
``-p <name>`` or pin HERMES_HOME to the profile dir; a bare invocation
|
|
means "the root HERMES_HOME profile". If a supervised default-profile
|
|
child read active_profile, switching the active profile (dashboard,
|
|
``hermes profile use``) would silently redirect the default gateway into
|
|
that profile — adopting its credentials and double-polling a Telegram
|
|
token already owned by that profile's own gateway (#74872).
|
|
|
|
Markers (see gateway/restart.py ``is_gateway_supervisor_process``):
|
|
HERMES_SUPERVISED_CHILD (systemd unit / launchd plist / Windows task),
|
|
HERMES_S6_SUPERVISED_CHILD (legacy s6 container), INVOCATION_ID (systemd
|
|
service children only — consulted ONLY for gateway commands because it is
|
|
inherited by every descendant of a systemd-launched process, e.g.
|
|
self-hosted CI runners), HERMES_GATEWAY_EXTERNAL_SUPERVISOR (explicit
|
|
opt-in). XPC_SERVICE_NAME is deliberately NOT consulted: interactive macOS
|
|
terminals set it too.
|
|
"""
|
|
if os.environ.get("HERMES_SUPERVISED_CHILD") or os.environ.get("HERMES_S6_SUPERVISED_CHILD"):
|
|
return True
|
|
is_gateway_cmd = next((a for a in argv if not a.startswith("-")), None) == "gateway"
|
|
if is_gateway_cmd and os.environ.get("INVOCATION_ID"):
|
|
return True
|
|
return os.environ.get(
|
|
"HERMES_GATEWAY_EXTERNAL_SUPERVISOR", ""
|
|
).strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _desktop_ssh_backend(argv: list) -> bool:
|
|
"""A Desktop-owned ``serve --ssh-session-token-file`` child has a fixed identity too.
|
|
|
|
The Desktop client names the remote profile explicitly (``--profile <name>``, or none for
|
|
the root home). Following the remote host's sticky ``active_profile`` instead silently
|
|
re-homes the backend into a profile the UI never asked for, so Settings read one
|
|
``config.yaml`` and the user edits another (KC's "nothing sticks over SSH").
|
|
"""
|
|
return "--ssh-session-token-file" in argv
|
|
|
|
|
|
def _apply_profile_override() -> None:
|
|
"""Pre-parse --profile/-p and set HERMES_HOME before imports."""
|
|
argv = sys.argv[1:]
|
|
profile_name, consume, profile_index = _scan_profile_flag(argv)
|
|
|
|
# HERMES_HOME already set with no explicit flag: trust it only when it
|
|
# points at a specific profile dir ("profiles" as immediate parent). If it
|
|
# points at the hermes root (systemd hardcodes HERMES_HOME=/root/.hermes)
|
|
# we must still read active_profile — the user may have run
|
|
# `hermes profile use` and the gateway should honour it (#22502).
|
|
hermes_home_env = os.environ.get("HERMES_HOME", "")
|
|
if profile_name is None and hermes_home_env and Path(hermes_home_env).parent.name == "profiles":
|
|
return
|
|
|
|
if profile_name is None and not _under_gateway_supervisor(argv) and not _desktop_ssh_backend(argv):
|
|
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(encoding="utf-8").strip()
|
|
if name and name != "default":
|
|
profile_name = name # consume stays 0: nothing to strip
|
|
except (UnicodeDecodeError, OSError):
|
|
pass # corrupted file, skip
|
|
|
|
if profile_name is None:
|
|
return
|
|
try:
|
|
from hermes_cli.profiles import resolve_profile_env
|
|
|
|
hermes_home = resolve_profile_env(profile_name)
|
|
except FileNotFoundError as exc:
|
|
hermes_home = _resolve_sudo_user_profile_env(profile_name)
|
|
if not hermes_home:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except ValueError 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)
|
|
return
|
|
os.environ["HERMES_HOME"] = hermes_home
|
|
# Strip the flag from argv so argparse doesn't choke
|
|
if consume > 0 and profile_index is not None:
|
|
start = profile_index + 1 # +1 because argv is sys.argv[1:]
|
|
sys.argv = sys.argv[:start] + sys.argv[start + consume :]
|
|
|
|
|
|
_apply_profile_override()
|
|
|
|
# Windows launcher self-heal — the ``hermes`` command is a COPY of the venv
|
|
# console script staged into the managed bin dir (outside the checkout, since
|
|
# ``hermes update``'s autostash once swept ``<checkout>\bin`` copies off disk;
|
|
# venv\Scripts must stay off PATH as it shadows the user's ``python``).
|
|
# Re-staging at process start reaches already-broken installs via the desktop
|
|
# app's ``python -m hermes_cli.main`` spawn. Gates fail toward inaction. Sits
|
|
# AFTER the profile override on purpose — no hermes module may import before
|
|
# profiles resolve; the helper anchors on the DEFAULT root, so profile
|
|
# sessions heal the same shared dir.
|
|
# That dir lives OUTSIDE the git checkout precisely because an earlier layout staged the copies at
|
|
# ``<checkout>\bin``, where ``hermes update``'s autostash (``git stash push --include-untracked``) swept
|
|
# them off disk; with the desktop updater's ``--keep-stash`` nothing restored them and ``hermes`` stopped
|
|
# resolving in every new terminal (venv\Scripts itself must stay off PATH — it shadows the user's
|
|
# ``python``, #83797). Costs a few stat calls when healthy; gates fail toward inaction so source checkouts
|
|
# are untouched.
|
|
if sys.platform == "win32":
|
|
try:
|
|
from hermes_cli import _install_repair as _install_repair_mod
|
|
|
|
_install_repair_mod.ensure_windows_bin_launchers(_bootstrap_root)
|
|
except Exception:
|
|
pass
|
|
|
|
# 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
|
|
|
|
# ``update`` must not import optional secret-manager libs before ``uv``
|
|
# replaces the environment: on Windows Bitwarden's cryptography import maps
|
|
# ``_rust.pyd`` and the parent updater then blocks its own child installer.
|
|
# Profile flags are already stripped, so argv[1] is the authoritative subcommand.
|
|
# Profile flags have already been stripped above, so the first remaining argument is the authoritative
|
|
# argparse subcommand. Dotenv/managed config still loads; only external secret fetches are unnecessary for
|
|
# installation maintenance. See #73381.
|
|
load_hermes_dotenv(
|
|
project_env=PROJECT_ROOT / ".env",
|
|
load_external_secrets=sys.argv[1:2] != ["update"],
|
|
)
|
|
|
|
# Bridge security.redact_secrets → HERMES_REDACT_SECRETS BEFORE hermes_logging
|
|
# imports agent.redact, which snapshots the flag exactly once at import. A
|
|
# .env value still wins — this is config.yaml fallback only. network.force_ipv4
|
|
# is read from the same parse to avoid a second full load_config() (~17ms).
|
|
_FORCE_IPV4_EARLY = False
|
|
try:
|
|
# read_raw_config()'s (mtime, size)-keyed cache means this SAME parse serves
|
|
# hermes_logging and later raw reads: 3-4 config.yaml parses become one.
|
|
from hermes_cli.config import read_raw_config as _read_raw_early
|
|
|
|
_cfg_path = get_hermes_home() / "config.yaml"
|
|
if _cfg_path.exists():
|
|
_early_cfg_raw = _read_raw_early() or {}
|
|
# Managed scope overlay: administrator-pinned redact_secrets /
|
|
# force_ipv4 must win here too (load_config isn't usable yet). Fail-open.
|
|
try:
|
|
from hermes_cli import managed_scope
|
|
_early_cfg_raw = managed_scope.apply_managed_overlay(_early_cfg_raw)
|
|
except Exception:
|
|
pass
|
|
if "HERMES_REDACT_SECRETS" not in os.environ:
|
|
_early_sec_cfg = _early_cfg_raw.get("security", {})
|
|
if isinstance(_early_sec_cfg, dict):
|
|
_early_redact = _early_sec_cfg.get("redact_secrets")
|
|
if _early_redact is not None:
|
|
os.environ["HERMES_REDACT_SECRETS"] = str(_early_redact).lower()
|
|
_early_net_cfg = _early_cfg_raw.get("network", {})
|
|
if isinstance(_early_net_cfg, dict) and _early_net_cfg.get("force_ipv4"):
|
|
_FORCE_IPV4_EARLY = True
|
|
del _early_cfg_raw
|
|
del _cfg_path
|
|
except Exception:
|
|
pass # best-effort — redaction stays at default (enabled) on config errors
|
|
|
|
# Centralized file logging for every subcommand (agent.log + errors.log).
|
|
# Dashboard entrypoints use GUI mode so gui.log captures pre-dispatch failures.
|
|
try:
|
|
from hermes_logging import setup_logging as _setup_logging
|
|
|
|
_setup_logging(
|
|
mode=(
|
|
"gui"
|
|
if next((arg for arg in sys.argv[1:] if not arg.startswith("-")), "")
|
|
in {"dashboard", "serve", "gui", "desktop"}
|
|
else "cli"
|
|
)
|
|
)
|
|
except Exception:
|
|
pass # best-effort — don't crash the CLI if logging setup fails
|
|
|
|
# Apply IPv4 preference before any HTTP client is created.
|
|
if _FORCE_IPV4_EARLY:
|
|
try:
|
|
from hermes_constants import apply_ipv4_preference as _apply_ipv4
|
|
|
|
_apply_ipv4(force=True)
|
|
except Exception:
|
|
pass # best-effort — don't crash if hermes_constants not importable yet
|
|
|
|
import logging
|
|
import threading
|
|
from datetime import datetime
|
|
|
|
from hermes_cli import __version__, __release_date__
|
|
|
|
from hermes_cli.model_setup_flows import (
|
|
_model_flow_openrouter,
|
|
_model_flow_nous,
|
|
_model_flow_openai_codex,
|
|
_model_flow_xai_oauth,
|
|
_model_flow_qwen_oauth,
|
|
_model_flow_minimax_oauth,
|
|
_model_flow_custom,
|
|
_model_flow_azure_foundry,
|
|
_model_flow_named_custom,
|
|
_model_flow_copilot,
|
|
_model_flow_copilot_acp,
|
|
_model_flow_kimi,
|
|
_model_flow_stepfun,
|
|
_model_flow_bedrock,
|
|
_model_flow_vertex,
|
|
_model_flow_api_key_provider,
|
|
_model_flow_anthropic,
|
|
_model_flow_moa,
|
|
_model_flow_ai_gateway,
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
from hermes_cli.main_agent_cmds import (
|
|
cmd_acp,
|
|
cmd_insights,
|
|
cmd_memory,
|
|
cmd_monitoring,
|
|
cmd_skills,
|
|
cmd_tools,
|
|
)
|
|
from hermes_cli.main_platform_setup import (
|
|
cmd_slack,
|
|
cmd_sync,
|
|
cmd_whatsapp,
|
|
cmd_whatsapp_cloud,
|
|
)
|
|
from hermes_cli.main_dashboard import (
|
|
_finalize_update_output,
|
|
_find_stale_dashboard_pids,
|
|
_install_hangup_protection,
|
|
_is_electron_packaged_web_dist,
|
|
_maybe_setup_dashboard_auth_interactively,
|
|
_read_ssh_session_token_file,
|
|
_report_dashboard_status,
|
|
_resolve_dashboard_web_dist,
|
|
_route_named_profile_dashboard,
|
|
)
|
|
from hermes_cli.main_dashboard import ( # frozen updater surface: update_cmd*.py resolve these via _m()
|
|
_respawn_dashboard_processes,
|
|
)
|
|
from hermes_cli.main_provider_setup import (
|
|
_GENERIC_API_KEY_PROVIDERS,
|
|
_aux_config_menu,
|
|
_build_provider_picker_rows,
|
|
_clear_stale_openai_base_url,
|
|
_is_profile_api_key_provider,
|
|
_named_custom_provider_map,
|
|
_prompt_provider_choice,
|
|
_remove_custom_provider,
|
|
)
|
|
from hermes_cli.main_install_repair import (
|
|
_cleanup_quarantined_exes,
|
|
_recover_from_interrupted_install,
|
|
)
|
|
from hermes_cli.main_install_repair import ( # frozen updater surface: update_cmd*.py resolve these via _m()
|
|
ShimQuarantineError,
|
|
_UPDATE_REEXEC_ENV,
|
|
_clear_lazy_refresh_incomplete_marker,
|
|
_clear_marker_file,
|
|
_clear_update_incomplete_marker,
|
|
_install_python_dependencies_with_optional_fallback,
|
|
_is_termux_env,
|
|
_is_windows,
|
|
_is_windows_npm_path,
|
|
_lazy_refresh_marker_path,
|
|
_pytest_owns_live_checkout,
|
|
_reexec_dependency_sync_off_windows_shim,
|
|
_repair_venv_via_import_probes,
|
|
_resolve_install_target_python,
|
|
_resolve_node_runtime_npm,
|
|
_resolve_update_branch,
|
|
_run_install_with_heartbeat,
|
|
_run_package_only_install,
|
|
_update_marker_path,
|
|
_venv_scripts_dir,
|
|
_verify_console_scripts_installed,
|
|
_verify_core_dependencies_installed,
|
|
)
|
|
from hermes_cli.main_desktop import (
|
|
cmd_gui,
|
|
)
|
|
from hermes_cli.main_desktop import ( # frozen updater surface: update_cmd*.py resolve these via _m()
|
|
_desktop_build_needed,
|
|
_desktop_dist_exists,
|
|
_desktop_macos_relaunchable_fixup,
|
|
_desktop_packaged_executable,
|
|
)
|
|
from hermes_cli.main_web_build import (
|
|
_sweep_stale_bytecode_if_checkout_changed,
|
|
)
|
|
from hermes_cli.main_web_build import ( # frozen updater surface: update_cmd*.py resolve these via _m()
|
|
_build_web_ui,
|
|
_nixos_build_env,
|
|
_record_bytecode_fingerprint,
|
|
_run_npm_install_deterministic,
|
|
)
|
|
from hermes_cli.main_tui_launch import (
|
|
_launch_tui,
|
|
_pin_kanban_board_env,
|
|
_resolve_use_tui,
|
|
_sync_bundled_skills_quietly,
|
|
)
|
|
|
|
|
|
def _is_termux_startup_environment(env: dict[str, str] | None = None) -> bool:
|
|
"""Import-safe Termux check for cold-start-sensitive CLI paths."""
|
|
check = env or os.environ
|
|
prefix = str(check.get("PREFIX", ""))
|
|
return bool(
|
|
check.get("TERMUX_VERSION")
|
|
or "com.termux/files/usr" in prefix
|
|
or prefix.startswith("/data/data/com.termux/")
|
|
)
|
|
|
|
|
|
def _read_packed_ref(common_dir: Path, ref: str) -> str | None:
|
|
"""Look up a ref in .git/packed-refs without spawning git.
|
|
|
|
packed-refs lines look like ``<sha> <ref>`` with optional ``^<sha>``
|
|
peel lines and ``#``-prefixed comments / ``# pack-refs with:`` header.
|
|
"""
|
|
try:
|
|
text = (common_dir / "packed-refs").read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return None
|
|
for line in text.splitlines():
|
|
if not line or line.startswith("#") or line.startswith("^"):
|
|
continue
|
|
parts = line.split(" ", 1)
|
|
if len(parts) == 2 and parts[1].strip() == ref:
|
|
return parts[0].strip()
|
|
return None
|
|
|
|
|
|
def _read_git_revision_fingerprint(repo_root: Path) -> str | None:
|
|
"""Return a cheap checkout fingerprint without spawning git."""
|
|
git_dir = repo_root / ".git"
|
|
try:
|
|
if git_dir.is_file():
|
|
for line in git_dir.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
key, _, value = line.partition(":")
|
|
if key.strip() == "gitdir" and value.strip():
|
|
git_dir = (repo_root / value.strip()).resolve()
|
|
break
|
|
# Worktrees point HEAD at a per-worktree gitdir but pack their refs
|
|
# in the main repo's gitdir (referenced via ``commondir``). Resolve
|
|
# that up front so packed-refs lookups hit the right file.
|
|
common_dir = git_dir
|
|
commondir_file = git_dir / "commondir"
|
|
if commondir_file.exists():
|
|
try:
|
|
rel = commondir_file.read_text(encoding="utf-8", errors="replace").strip()
|
|
if rel:
|
|
common_dir = (git_dir / rel).resolve()
|
|
except OSError:
|
|
pass
|
|
head = (git_dir / "HEAD").read_text(encoding="utf-8", errors="replace").strip()
|
|
if head.startswith("ref:"):
|
|
ref = head.split(":", 1)[1].strip()
|
|
# Loose refs may live in the worktree gitdir OR the common dir
|
|
# (branches created via `git worktree add` typically live in the
|
|
# common dir's refs/heads/).
|
|
for candidate in (git_dir, common_dir):
|
|
ref_file = candidate / ref
|
|
if ref_file.exists():
|
|
return f"git:{ref}:{ref_file.read_text(encoding='utf-8', errors='replace').strip()}"
|
|
packed_sha = _read_packed_ref(common_dir, ref)
|
|
if packed_sha:
|
|
return f"git:{ref}:{packed_sha}"
|
|
# Ref name is known but unresolved — still stable across launches,
|
|
# and the version/release fallback in the caller will invalidate
|
|
# after `hermes update`.
|
|
return f"git:{ref}:unresolved"
|
|
return f"git:HEAD:{head}"
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _termux_bundled_skills_fingerprint() -> str:
|
|
"""Cheap invalidation key for Termux bundled-skill startup sync."""
|
|
git_fp = _read_git_revision_fingerprint(PROJECT_ROOT)
|
|
if git_fp:
|
|
return git_fp
|
|
skills_dir = PROJECT_ROOT / "skills"
|
|
try:
|
|
stat = skills_dir.stat()
|
|
return f"skills:{__version__}:{__release_date__}:{stat.st_mtime_ns}:{stat.st_size}"
|
|
except OSError:
|
|
return f"skills:{__version__}:{__release_date__}:missing"
|
|
|
|
|
|
def _termux_bundled_skills_stamp_path() -> Path:
|
|
return get_hermes_home() / "skills" / ".termux_bundled_sync_stamp"
|
|
|
|
|
|
def _termux_bundled_skills_sync_needed() -> bool:
|
|
if not _is_termux_startup_environment():
|
|
return True
|
|
if os.environ.get("HERMES_TERMUX_FORCE_SKILLS_SYNC") == "1":
|
|
return True
|
|
try:
|
|
stamp = _termux_bundled_skills_stamp_path()
|
|
return stamp.read_text(encoding="utf-8").strip() != _termux_bundled_skills_fingerprint()
|
|
except OSError:
|
|
return True
|
|
|
|
|
|
def _mark_termux_bundled_skills_synced() -> None:
|
|
if not _is_termux_startup_environment():
|
|
return
|
|
try:
|
|
stamp = _termux_bundled_skills_stamp_path()
|
|
stamp.parent.mkdir(parents=True, exist_ok=True)
|
|
stamp.write_text(_termux_bundled_skills_fingerprint() + "\n", encoding="utf-8")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _sync_bundled_skills_for_startup() -> bool:
|
|
"""Sync bundled skills, but skip unchanged Termux checkouts cheaply.
|
|
|
|
Hashing every bundled skill is safe but expensive on older Android
|
|
storage. The git/ref stamp keeps post-update correctness: a changed
|
|
checkout revision forces one real sync, then later starts skip it.
|
|
"""
|
|
if _is_termux_startup_environment() and not _termux_bundled_skills_sync_needed():
|
|
return False
|
|
|
|
from tools.skills_sync import sync_skills
|
|
|
|
sync_skills(quiet=True)
|
|
_mark_termux_bundled_skills_synced()
|
|
return True
|
|
|
|
|
|
def _termux_should_prefetch_update_check() -> bool:
|
|
if not _is_termux_startup_environment():
|
|
return True
|
|
return os.environ.get("HERMES_TERMUX_PREFETCH_UPDATES") == "1"
|
|
|
|
|
|
def _dotenv_has_provider_key(env_file: Path, provider_env_vars: set) -> bool:
|
|
"""True if ~/.hermes/.env assigns a non-empty value to any provider key."""
|
|
if not env_file.exists():
|
|
return False
|
|
try:
|
|
for line in env_file.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if line.startswith("#") or "=" not in line:
|
|
continue
|
|
if line.startswith("export "):
|
|
# Strip the bash-compatible ``export `` prefix so lines like ``export API_KEY=...`` parse as
|
|
# ``API_KEY`` rather than being stored under the wrong key ``"export API_KEY"`` (#6659).
|
|
line = line[7:]
|
|
key, _, val = line.partition("=")
|
|
if key.strip() in provider_env_vars and val.strip().strip("'\""):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _auth_store_logged_in(auth_file: Path, registry, strict_profile_scope: bool) -> bool:
|
|
"""True if auth.json's active provider is logged in (api_key providers ignored under strict scope)."""
|
|
from hermes_cli.auth import get_auth_status
|
|
|
|
if not auth_file.exists():
|
|
return False
|
|
try:
|
|
auth = json.loads(auth_file.read_text(encoding="utf-8-sig"))
|
|
active = auth.get("active_provider")
|
|
active_config = registry.get(str(active or "").strip().lower())
|
|
if active and not (
|
|
strict_profile_scope and active_config and active_config.auth_type == "api_key"
|
|
):
|
|
return bool(get_auth_status(active).get("logged_in"))
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _has_any_provider_configured(*, strict_profile_scope: bool = False) -> bool:
|
|
"""Check if at least one inference provider is usable. Never creates one: the Nous free tier
|
|
counts only once its identity exists, and the boot bootstrap (``hermes_cli.free_tier_bootstrap``)
|
|
is the only thing that creates it; ``cmd_chat`` runs the bootstrap before asking.
|
|
|
|
``strict_profile_scope``: the caller has bound a NAMED profile's home and
|
|
secret scope and wants an answer for that profile only — launch-process
|
|
env and host-wide fallbacks (gh auth, Claude Code credentials) must not
|
|
make it appear ready. Unscoped callers keep the legacy behavior.
|
|
"""
|
|
from hermes_cli.config import DEFAULT_CONFIG, get_env_path, get_hermes_home, load_config
|
|
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
|
|
|
|
cfg = load_config()
|
|
model_cfg = cfg.get("model")
|
|
_model_name = model_cfg if isinstance(model_cfg, str) else ""
|
|
if isinstance(model_cfg, dict):
|
|
_model_name = model_cfg.get("default") or ""
|
|
if isinstance(_model_name, dict):
|
|
from hermes_cli.config import split_model_config_default
|
|
_model_name, _ = split_model_config_default(_model_name)
|
|
_model_name = str(_model_name).strip()
|
|
# "Explicitly configured" = model differs from the hardcoded default; gates
|
|
# Claude Code credentials so they don't skip setup on a fresh install.
|
|
_has_hermes_config = _model_name and _model_name != DEFAULT_CONFIG.get("model", "")
|
|
|
|
# Env vars (.env or shell). OPENAI_BASE_URL alone counts — local models
|
|
# (vLLM, llama.cpp) often need no API key.
|
|
provider_env_vars = {
|
|
"OPENROUTER_API_KEY",
|
|
"OPENAI_API_KEY",
|
|
"ANTHROPIC_API_KEY",
|
|
"ANTHROPIC_TOKEN",
|
|
"OPENAI_BASE_URL",
|
|
}
|
|
for pconfig in PROVIDER_REGISTRY.values():
|
|
if pconfig.auth_type == "api_key":
|
|
provider_env_vars.update(pconfig.api_key_env_vars)
|
|
if strict_profile_scope:
|
|
from agent.secret_scope import current_secret_scope
|
|
|
|
read_provider_env = (current_secret_scope() or {}).get
|
|
else:
|
|
read_provider_env = os.getenv
|
|
if any(read_provider_env(v) for v in provider_env_vars):
|
|
return True
|
|
if _dotenv_has_provider_key(get_env_path(), provider_env_vars):
|
|
return True
|
|
|
|
# Cheap on-disk checks (auth.json, config.yaml) first: the PROVIDER_REGISTRY
|
|
# sweep below spawns subprocesses (gh) and can take 15-20s — long enough
|
|
# that desktop setup.status calls time out.
|
|
if _auth_store_logged_in(get_hermes_home() / "auth.json", PROVIDER_REGISTRY, strict_profile_scope):
|
|
return True
|
|
|
|
# model as a dict with provider/base_url/api_key means setup ran (fresh
|
|
# installs have a plain string); also covers custom endpoints kept in config.
|
|
if isinstance(model_cfg, dict) and any(
|
|
(model_cfg.get(k) or "").strip() for k in ("provider", "base_url", "api_key")
|
|
):
|
|
return True
|
|
|
|
# Provider-specific auth fallbacks (e.g. Copilot via gh auth).
|
|
if not strict_profile_scope:
|
|
try:
|
|
if any(
|
|
get_auth_status(pid).get("logged_in")
|
|
for pid, pconfig in PROVIDER_REGISTRY.items()
|
|
if pconfig.auth_type == "api_key"
|
|
):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
# Claude Code OAuth credentials count only once Hermes is explicitly
|
|
# configured — having Claude Code installed isn't consent to use its tokens.
|
|
if _has_hermes_config and not strict_profile_scope:
|
|
try:
|
|
from agent.anthropic_credentials import read_claude_code_credentials, is_claude_code_token_valid
|
|
|
|
creds = read_claude_code_credentials()
|
|
if creds and (
|
|
is_claude_code_token_valid(creds) or creds.get("refreshToken")
|
|
):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
# Nothing explicit anywhere: an existing Nous free-tier identity counts while the tier is on.
|
|
try:
|
|
from hermes_cli.anon_auth import guest_enabled, has_guest
|
|
return guest_enabled() and has_guest()
|
|
except Exception as exc:
|
|
logger.debug("free tier check on first run skipped: %s", exc)
|
|
return False
|
|
|
|
|
|
def _confirm_startup_expensive_model_override(args) -> None:
|
|
"""Guard startup -m/--provider overrides before the first API call."""
|
|
explicit_model = (getattr(args, "model", None) or "").strip()
|
|
explicit_provider = (getattr(args, "provider", None) or "").strip()
|
|
if not explicit_model and not explicit_provider:
|
|
return
|
|
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
from hermes_cli.model_selection_guards import (
|
|
combined_message,
|
|
selection_warnings,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("startup model cost guard unavailable: %s", exc)
|
|
return
|
|
|
|
try:
|
|
config = load_config()
|
|
except Exception as exc:
|
|
logger.warning("startup model cost guard could not load config: %s", exc)
|
|
config = {}
|
|
_dict = lambda v: v if isinstance(v, dict) else {} # noqa: E731
|
|
config = _dict(config)
|
|
model_cfg = _dict(config.get("model"))
|
|
security_cfg = _dict(config.get("security"))
|
|
|
|
model = explicit_model or (model_cfg.get("default") or "").strip()
|
|
if not model:
|
|
return
|
|
provider = (explicit_provider or model_cfg.get("provider") or "").strip()
|
|
try:
|
|
# Unified registry: cost guard + id-keyed guards (e.g. the
|
|
# data-training-tier warning) all fire at startup too.
|
|
warnings = selection_warnings(
|
|
model,
|
|
provider=provider,
|
|
base_url=(model_cfg.get("base_url") or ""),
|
|
api_key=(model_cfg.get("api_key") or ""),
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("startup model cost guard failed for %s/%s: %s", provider, model, exc)
|
|
return
|
|
if not warnings:
|
|
return
|
|
|
|
# Intentionally independent of --yolo / --accept-hooks: those approve local
|
|
# command risk, not paid aggregator spend or a surprising provider route.
|
|
is_interactive = sys.stdin.isatty()
|
|
if not is_interactive and security_cfg.get("allow_data_training_tiers_noninteractive") is True:
|
|
acknowledged = [w for w in warnings if w.kind == "data_policy"]
|
|
if acknowledged:
|
|
sys.stderr.write(combined_message(acknowledged) + "\n")
|
|
sys.stderr.write(
|
|
"Proceeding in non-interactive mode because "
|
|
"security.allow_data_training_tiers_noninteractive is true.\n"
|
|
)
|
|
warnings = [w for w in warnings if w.kind != "data_policy"]
|
|
if not warnings:
|
|
return
|
|
|
|
message = combined_message(warnings)
|
|
if not is_interactive:
|
|
sys.stderr.write(message + "\n")
|
|
if any(warning.kind == "data_policy" for warning in warnings):
|
|
sys.stderr.write(
|
|
"To acknowledge data-training tiers for unattended runs, set "
|
|
"security.allow_data_training_tiers_noninteractive to true "
|
|
"in config.yaml.\n"
|
|
)
|
|
sys.stderr.write(
|
|
"Refusing this startup model override in non-interactive mode. "
|
|
"Run interactively and confirm if you intend to use it.\n"
|
|
)
|
|
raise SystemExit(1)
|
|
|
|
sys.stderr.write(message + "\n")
|
|
try:
|
|
reply = input("Use this model for this invocation? [y/N] ").strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
reply = ""
|
|
if reply not in {"y", "yes"}:
|
|
sys.stderr.write("Model override cancelled.\n")
|
|
raise SystemExit(1)
|
|
|
|
|
|
def _resolve_workspace_key() -> Optional[str]:
|
|
"""The current workspace identity for cwd-scoped resume.
|
|
|
|
Git repo root when CWD is inside a repo (so all sessions across its
|
|
subdirs/worktrees group together), else the CWD itself. Returns None when
|
|
neither can be determined — callers fall back to the global MRU then.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "rev-parse", "--show-toplevel"],
|
|
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5,
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
return os.path.abspath(result.stdout.strip())
|
|
except Exception:
|
|
pass
|
|
try:
|
|
return os.getcwd()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _session_db():
|
|
"""Yield a ``SessionDB`` (lazy import, so test patches on ``hermes_state``
|
|
intercept). Open failures yield None and any error raised by the ``with``
|
|
body is swallowed — callers fall through to their ``return None``."""
|
|
db = None
|
|
try:
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
yield db # body errors (incl. AttributeError on a None db) are swallowed
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
if db is not None:
|
|
try:
|
|
db.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _latest_session_id(use_tui: bool) -> Optional[str]:
|
|
"""MRU session for the active interface; a TUI launch falls back to the CLI MRU."""
|
|
last_id = _resolve_last_session(source="tui" if use_tui else "cli")
|
|
if not last_id and use_tui:
|
|
last_id = _resolve_last_session(source="cli")
|
|
return last_id
|
|
|
|
|
|
def _resolve_last_session(source: str = "cli") -> Optional[str]:
|
|
"""Look up the most recently-used session ID for a source.
|
|
|
|
Scoped to the current workspace first (git repo root, else cwd) so
|
|
``hermes -c`` from repo A continues repo A's last session rather than the
|
|
global MRU. Falls back to the unscoped MRU when no session matches the
|
|
current workspace, preserving the old behaviour for fresh directories.
|
|
"""
|
|
with _session_db() as db:
|
|
ws_key = _resolve_workspace_key()
|
|
if ws_key:
|
|
sessions = db.search_sessions(source=source, limit=1, workspace_key=ws_key)
|
|
if sessions:
|
|
return sessions[0]["id"]
|
|
# Fallback: global MRU for this source.
|
|
sessions = db.search_sessions(source=source, limit=1)
|
|
return sessions[0]["id"] if sessions else None
|
|
return None
|
|
|
|
|
|
def _probe_container(cmd: list, backend: str, via_sudo: bool = False):
|
|
"""Run a container inspect probe, returning the CompletedProcess.
|
|
|
|
Catches TimeoutExpired specifically for a human-readable message;
|
|
all other exceptions propagate naturally.
|
|
"""
|
|
try:
|
|
return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15)
|
|
except subprocess.TimeoutExpired:
|
|
label = f"sudo {backend}" if via_sudo else backend
|
|
print(
|
|
f"Error: timed out waiting for {label} to respond.\n"
|
|
f"The {backend} daemon may be unresponsive or starting up.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def _exec_in_container(container_info: dict, cli_args: list):
|
|
"""Replace the current process with a command inside the managed container.
|
|
|
|
Probes whether sudo is needed (rootful containers), then os.execvp
|
|
into the container. On success the Python process is replaced entirely
|
|
and the container's exit code becomes the process exit code (OS semantics).
|
|
On failure, OSError propagates naturally.
|
|
|
|
Args:
|
|
container_info: dict with backend, container_name, exec_user, hermes_bin
|
|
cli_args: the original CLI arguments (everything after 'hermes')
|
|
"""
|
|
|
|
backend = container_info["backend"]
|
|
container_name = container_info["container_name"]
|
|
exec_user = container_info["exec_user"]
|
|
hermes_bin = container_info["hermes_bin"]
|
|
|
|
runtime = shutil.which(backend)
|
|
if not runtime:
|
|
print(
|
|
f"Error: {backend} not found on PATH. Cannot route to container.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
# Rootful containers (NixOS systemd service) are invisible to unprivileged
|
|
# users — Podman uses per-user namespaces, Docker needs group access.
|
|
# Probe whether the runtime can see the container; if not, try via sudo.
|
|
inspect_cmd = [runtime, "inspect", "--format", "ok", container_name]
|
|
cmd_prefix = [runtime]
|
|
if _probe_container(inspect_cmd, backend).returncode != 0:
|
|
sudo_path = shutil.which("sudo")
|
|
if not sudo_path:
|
|
print(
|
|
f"Error: container '{container_name}' not found via {backend}.\n"
|
|
f"The container may be running under root. Try: sudo hermes {' '.join(cli_args)}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
cmd_prefix = [sudo_path, "-n", runtime]
|
|
if _probe_container(cmd_prefix[:2] + inspect_cmd, backend, via_sudo=True).returncode != 0:
|
|
print(
|
|
f"Error: container '{container_name}' not found via {backend}.\n"
|
|
f"\n"
|
|
f"The container is likely running as root. Your user cannot see it\n"
|
|
f"because {backend} uses per-user namespaces. Grant passwordless\n"
|
|
f"sudo for {backend} — the -n (non-interactive) flag is required\n"
|
|
f"because a password prompt would hang or break piped commands.\n"
|
|
f"\n"
|
|
f"On NixOS:\n"
|
|
f"\n"
|
|
f" security.sudo.extraRules = [{{\n"
|
|
f' users = [ "{os.getenv("USER", "your-user")}" ];\n'
|
|
f' commands = [{{ command = "{runtime}"; options = [ "NOPASSWD" ]; }}];\n'
|
|
f" }}];\n"
|
|
f"\n"
|
|
f"Or run: sudo hermes {' '.join(cli_args)}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
env_flags = []
|
|
for var in ("TERM", "COLORTERM", "LANG", "LC_ALL"):
|
|
val = os.environ.get(var)
|
|
if val:
|
|
env_flags.extend(["-e", f"{var}={val}"])
|
|
|
|
exec_cmd = (
|
|
cmd_prefix
|
|
+ ["exec", "-it" if sys.stdin.isatty() else "-i", "-u", exec_user]
|
|
+ env_flags
|
|
+ [container_name, hermes_bin]
|
|
+ cli_args
|
|
)
|
|
os.execvp(exec_cmd[0], exec_cmd)
|
|
|
|
|
|
def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
|
|
"""Resolve a session title or ID to a session ID (None if neither matches).
|
|
|
|
A compression root is followed forward to its latest continuation so an
|
|
old root ID (exit summary, notes) resumes at the live tip.
|
|
"""
|
|
with _session_db() as db:
|
|
# Exact session ID first, then title (with auto-latest for lineage).
|
|
session = db.get_session(name_or_id)
|
|
resolved_id = session["id"] if session else 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
|
|
return resolved_id
|
|
return None
|
|
|
|
|
|
def _create_titled_session(title: str) -> Optional[str]:
|
|
"""Create a fresh titled session (``chat -c <title> --create-if-missing``).
|
|
|
|
Same timestamp+uuid id shape the CLI uses; the title is recorded with
|
|
user provenance so auto-titling never overwrites it.
|
|
|
|
Used by ``chat -c <title> --create-if-missing`` (#86794): programmatic callers (plugins, scripts) that
|
|
want "send to this named thread, making it if needed" get a deterministic outcome instead of a silent
|
|
no-op.
|
|
"""
|
|
db = None
|
|
try:
|
|
import uuid as _uuid
|
|
|
|
from hermes_state import SessionDB
|
|
|
|
new_session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{_uuid.uuid4().hex[:6]}"
|
|
db = SessionDB()
|
|
db.create_session(new_session_id, source="cli")
|
|
db.set_session_title(new_session_id, title)
|
|
return new_session_id
|
|
except Exception:
|
|
# Programmatic callers rely on --create-if-missing being deterministic;
|
|
# swallow the failure but log the cause so it lands in errors.log
|
|
# (DB lock, I/O error, import error — all otherwise invisible).
|
|
# See #86794.
|
|
logger.exception("Failed to create titled session %r", title)
|
|
return None
|
|
finally:
|
|
if db is not None:
|
|
try:
|
|
db.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _resolve_continue_arg(args, *, use_tui: bool) -> None:
|
|
"""Resolve ``-c/--continue`` into ``args.resume``.
|
|
|
|
``-c <name>``: resolve by title/ID; on miss fail loudly on **stderr** (exit
|
|
1) so programmatic callers see it even under quiet mode, or with
|
|
``--create-if-missing`` create a fresh titled session. Bare ``-c``: this
|
|
terminal's breadcrumb session if valid, else the MRU session.
|
|
|
|
Handles both forms: See #86794.
|
|
"""
|
|
continue_val = getattr(args, "continue_last", None)
|
|
if continue_val and not getattr(args, "resume", None):
|
|
if isinstance(continue_val, str):
|
|
resolved = _resolve_session_by_name_or_id(continue_val)
|
|
if resolved:
|
|
args.resume = resolved
|
|
elif getattr(args, "create_if_missing", False):
|
|
# "send to this named thread, making it if needed" — without it
|
|
# a quiet send to a not-yet-existing session silently no-ops.
|
|
# --create-if-missing: no session matches the title — create a new session with that title
|
|
# and proceed. See #86794.
|
|
new_sid = _create_titled_session(continue_val)
|
|
if new_sid:
|
|
args.resume = new_sid
|
|
else:
|
|
print(
|
|
f"No session found matching '{continue_val}' and "
|
|
"a new titled session could not be created.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
else:
|
|
print(f"No session found matching '{continue_val}'.", file=sys.stderr)
|
|
print(
|
|
"Use 'hermes sessions list' to see available sessions, or "
|
|
"pass --create-if-missing to start a new session with that title.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
else:
|
|
# Bare -c: this terminal's breadcrumb (so side-by-side terminals
|
|
# each continue their own conversation), else the MRU session
|
|
# (also when session.terminal_continue is false).
|
|
if getattr(args, "create_if_missing", False):
|
|
# Nothing to create without a name — surface the no-op.
|
|
print(
|
|
"--create-if-missing requires a session name: "
|
|
"`-c <name> --create-if-missing`",
|
|
file=sys.stderr,
|
|
)
|
|
try:
|
|
from hermes_cli.terminal_breadcrumbs import resolve_breadcrumb_session
|
|
|
|
_crumb_id = resolve_breadcrumb_session()
|
|
except Exception:
|
|
_crumb_id = None
|
|
if _crumb_id:
|
|
args.resume = _crumb_id
|
|
else:
|
|
# No valid breadcrumb — continue the most recent session
|
|
last_id = _latest_session_id(use_tui)
|
|
if last_id:
|
|
args.resume = last_id
|
|
else:
|
|
kind = "TUI" if use_tui else "CLI"
|
|
print(f"No previous {kind} session found to continue.")
|
|
sys.exit(1)
|
|
|
|
|
|
def _apply_in_dir(args) -> None:
|
|
"""--in DIR: chdir first so workspace-scoped lookups key off DIR; pins the session there."""
|
|
in_dir = getattr(args, "in_dir", None)
|
|
if not in_dir:
|
|
return
|
|
# Git Bash / MSYS hands us POSIX-style paths (`--in ~` → `/c/Users/x`);
|
|
# translate drive-root spellings to native Windows form. No-op elsewhere.
|
|
from tools.environments.local import _msys_to_windows_path
|
|
|
|
_target_dir = os.path.abspath(os.path.expanduser(_msys_to_windows_path(in_dir)))
|
|
if not os.path.isdir(_target_dir):
|
|
print(f"Error: --in directory not found: {in_dir}")
|
|
sys.exit(1)
|
|
try:
|
|
os.chdir(_target_dir)
|
|
except OSError as e:
|
|
print(f"Error: cannot enter --in directory {in_dir}: {e}")
|
|
sys.exit(1)
|
|
# Every cwd consumer (resolve_agent_cwd -> Codex app-server thread cwd, the
|
|
# terminal tool, context-file discovery) prefers TERMINAL_CWD over the process
|
|
# cwd, so a value inherited from a parent surface, the shell or .env outlives
|
|
# this chdir and re-homes the session in the old directory (#106220). Refresh
|
|
# it. An unset variable stays unset: the backends then derive from the new
|
|
# process cwd (local exports it at cli import, docker mounts it, ssh and
|
|
# container backends keep their own remote/sandbox default).
|
|
if os.environ.get("TERMINAL_CWD", "").strip():
|
|
os.environ["TERMINAL_CWD"] = _target_dir
|
|
args.no_restore_cwd = True
|
|
|
|
|
|
def _import_foreign_resume(args) -> None:
|
|
"""--resume @claude / @codex: import a foreign session and resume it."""
|
|
_resume_foreign = getattr(args, "resume", None)
|
|
if not (isinstance(_resume_foreign, str) and _resume_foreign.strip().lower() in ("@claude", "@codex")):
|
|
return
|
|
from hermes_cli.foreign_sessions import import_foreign_session, pick_foreign_session
|
|
|
|
_picked = pick_foreign_session(_resume_foreign.strip().lower().lstrip("@"))
|
|
if _picked is None:
|
|
sys.exit(1)
|
|
try:
|
|
_imported_id = import_foreign_session(_picked.source, _picked.path)
|
|
except ValueError as e:
|
|
print(f"Error: {e}")
|
|
sys.exit(1)
|
|
print(f"✓ Imported as {_imported_id} — resuming it now.")
|
|
print(f" (later: hermes --resume {_imported_id})")
|
|
args.resume = _imported_id
|
|
|
|
|
|
def _resolve_chat_session_args(args, use_tui: bool) -> None:
|
|
"""Normalize --in / --resume / --continue on ``args`` before agent init.
|
|
|
|
Order matters: ``--in DIR`` chdirs first so workspace-scoped "latest"/-c
|
|
lookups key off DIR (and pins the session there, skipping cwd restore);
|
|
then ``--resume latest`` → MRU id, ``--continue`` → ``--resume``,
|
|
``--resume @claude/@codex`` → imported session id, title → id; finally
|
|
cd back into a resumed session's recorded cwd (best-effort, opt-out via
|
|
--no-restore-cwd, skipped under --worktree).
|
|
"""
|
|
_apply_in_dir(args)
|
|
|
|
# --resume latest: same resolution as bare `-c`. The keyword wins over a
|
|
# session literally titled "latest" (still reachable by ID or `-c latest`).
|
|
_resume_raw = getattr(args, "resume", None)
|
|
if isinstance(_resume_raw, str) and _resume_raw.strip().lower() == "latest":
|
|
_last_id = _latest_session_id(use_tui)
|
|
if _last_id:
|
|
args.resume = _last_id
|
|
else:
|
|
kind = "TUI" if use_tui else "CLI"
|
|
print(f"No previous {kind} session found to resume.")
|
|
print("Use 'hermes sessions list' to see available sessions.")
|
|
sys.exit(1)
|
|
|
|
_resolve_continue_arg(args, use_tui=use_tui)
|
|
|
|
_import_foreign_resume(args)
|
|
|
|
resume_val = getattr(args, "resume", None)
|
|
if resume_val:
|
|
# On miss keep the original so _init_agent reports "Session not found" with it.
|
|
args.resume = _resolve_session_by_name_or_id(resume_val) or resume_val
|
|
|
|
# cd back into a resumed session's recorded cwd (opt out: --no-restore-cwd;
|
|
# --worktree owns its own dir). A missing dir warns and stays put.
|
|
if (
|
|
getattr(args, "resume", None)
|
|
and not getattr(args, "no_restore_cwd", False)
|
|
and not getattr(args, "worktree", False)
|
|
):
|
|
with _session_db() as db: # never let cwd-restore break a resume
|
|
_saved_cwd = ((db.get_session(args.resume) or {}).get("cwd") or "").strip()
|
|
if _saved_cwd and not os.path.isdir(_saved_cwd):
|
|
print(f"⚠ session's recorded dir is gone ({_saved_cwd}); staying in {os.getcwd()}")
|
|
elif _saved_cwd and os.path.realpath(_saved_cwd) != os.path.realpath(os.getcwd()):
|
|
os.chdir(_saved_cwd)
|
|
print(f"↪ restored workspace dir: {_saved_cwd}")
|
|
|
|
|
|
def _warn_retired_xai_models() -> None:
|
|
"""One-shot xAI retirement warning on stderr; non-blocking, never fails startup."""
|
|
try:
|
|
from hermes_cli.xai_retirement import (
|
|
MIGRATION_GUIDE_URL,
|
|
RETIREMENT_DATE,
|
|
find_retired_xai_refs,
|
|
format_issue,
|
|
)
|
|
from hermes_cli.config import load_config as _load_config_for_xai_check
|
|
|
|
_retired_xai_refs = find_retired_xai_refs(_load_config_for_xai_check())
|
|
if _retired_xai_refs:
|
|
sys.stderr.write(
|
|
f"\033[33m⚠ xAI retires {len(_retired_xai_refs)} model(s) "
|
|
f"in your config on {RETIREMENT_DATE}:\033[0m\n"
|
|
)
|
|
for _ref in _retired_xai_refs:
|
|
sys.stderr.write(f" \033[33m⚠\033[0m {format_issue(_ref)}\n")
|
|
sys.stderr.write(f" \033[2mMigration guide: {MIGRATION_GUIDE_URL}\033[0m\n")
|
|
sys.stderr.write(" \033[2mRun 'hermes doctor' for details.\033[0m\n\n")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _start_chat_background_prefetch() -> None:
|
|
"""Kick off the update-check/banner prefetch and the bundled-skills sync.
|
|
|
|
Update check is opt-in on Termux (it imports rich/prompt_toolkit in the
|
|
foreground and competes for CPU on single-core devices). The skills sync
|
|
is idempotent and hash-gated (~120-170ms of rglob/hashing) so it normally
|
|
runs in a daemon thread — skill loading happens at agent init, long after.
|
|
The ONE exception is an unseeded ~/.hermes/skills: there the banner
|
|
prefetch races the sync and caches an empty index ("No skills installed"
|
|
on the very first launch), so the first run syncs in the foreground and
|
|
drops the banner's skills cache.
|
|
"""
|
|
if _termux_should_prefetch_update_check():
|
|
try:
|
|
from hermes_cli.banner import prefetch_banner_data, prefetch_update_check
|
|
|
|
prefetch_update_check()
|
|
prefetch_banner_data() # git banner state + skills index off-thread
|
|
except Exception:
|
|
pass
|
|
|
|
def _skills_dir_is_unseeded() -> bool:
|
|
try:
|
|
from hermes_cli.config import get_hermes_home
|
|
skills_dir = Path(get_hermes_home()) / "skills"
|
|
if not skills_dir.is_dir():
|
|
return True
|
|
return next(skills_dir.rglob("SKILL.md"), None) is None
|
|
except Exception:
|
|
return False
|
|
|
|
def _skills_sync_bg() -> None:
|
|
try:
|
|
_sync_bundled_skills_for_startup()
|
|
except Exception:
|
|
pass
|
|
|
|
if _skills_dir_is_unseeded():
|
|
_skills_sync_bg()
|
|
# Drop the banner's possibly-empty skills cache so it recomputes.
|
|
try:
|
|
import hermes_cli.banner as _banner_mod
|
|
_banner_mod._available_skills_cache = None
|
|
except Exception:
|
|
pass
|
|
else:
|
|
threading.Thread(
|
|
target=_skills_sync_bg, name="bundled-skills-sync", daemon=True
|
|
).start()
|
|
|
|
|
|
def _first_run_setup_guard(args) -> None:
|
|
"""No provider configured: offer `hermes setup` (TTY) or exit 1 with guidance."""
|
|
print()
|
|
print(
|
|
"It looks like Hermes isn't configured yet -- no API keys or providers found."
|
|
)
|
|
print()
|
|
print(" Run: hermes setup")
|
|
print()
|
|
|
|
from hermes_cli.setup import (
|
|
is_interactive_stdin,
|
|
print_noninteractive_setup_guidance,
|
|
)
|
|
|
|
if not is_interactive_stdin():
|
|
print_noninteractive_setup_guidance(
|
|
"No interactive TTY detected for the first-run setup prompt."
|
|
)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
reply = input("Run setup now? [Y/n] ").strip().lower()
|
|
except (EOFError, KeyboardInterrupt):
|
|
reply = "n"
|
|
if reply in {"", "y", "yes"}:
|
|
cmd_setup(args)
|
|
return
|
|
print()
|
|
print("You can run 'hermes setup' at any time to configure.")
|
|
sys.exit(1)
|
|
|
|
|
|
def _read_query_file(args) -> None:
|
|
"""--query-file: read the single query from a file (or stdin via '-').
|
|
|
|
Callers never have to shell-quote message bodies — this is the transport
|
|
the Bot Mode DM protocol uses; interpolating arbitrary text into a
|
|
double-quoted shell argument truncates on quotes and executes $(...)
|
|
(see tools/bot_mode_probe.py).
|
|
"""
|
|
_qfile = getattr(args, "query_file", None)
|
|
if not _qfile:
|
|
return
|
|
if args.query:
|
|
# argparse's mutually-exclusive group catches the normal CLI path;
|
|
# this guards programmatic callers that fill the namespace directly.
|
|
print("Error: -q/--query and --query-file are mutually exclusive", file=sys.stderr)
|
|
sys.exit(2)
|
|
try:
|
|
if _qfile == "-":
|
|
args.query = sys.stdin.read()
|
|
else:
|
|
with open(_qfile, "r", encoding="utf-8", errors="replace") as _fh:
|
|
args.query = _fh.read()
|
|
except OSError as _e:
|
|
print(f"Error: cannot read --query-file {_qfile}: {_e}", file=sys.stderr)
|
|
sys.exit(2)
|
|
if not (args.query or "").strip():
|
|
print(f"Error: --query-file {_qfile} is empty", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
|
|
# args attr -> (kwarg, default) passed through to _launch_tui / cli.main.
|
|
_CHAT_PASSTHROUGH = (
|
|
("provider", None), ("toolsets", None), ("skills", None), ("verbose", None),
|
|
("quiet", False), ("query", None), ("image", None), ("resume", None),
|
|
("worktree", False), ("checkpoints", False), ("pass_session_id", False),
|
|
("max_turns", None),
|
|
)
|
|
|
|
|
|
def cmd_chat(args):
|
|
"""Run interactive chat CLI."""
|
|
_apply_safe_mode(args)
|
|
_apply_user_config_bypass(args)
|
|
_guard_noninteractive_user_config(args)
|
|
use_tui = _resolve_use_tui(args)
|
|
|
|
_resolve_chat_session_args(args, use_tui)
|
|
|
|
_warn_retired_xai_models()
|
|
|
|
# First-run guard: the free-tier bootstrap runs first (synchronously here; it is the only thing
|
|
# that may create the identity), then the inventory decides whether setup is needed.
|
|
from hermes_cli.free_tier_bootstrap import run_bootstrap
|
|
|
|
run_bootstrap(announce=False)
|
|
if not _has_any_provider_configured():
|
|
_first_run_setup_guard(args)
|
|
return
|
|
|
|
_start_chat_background_prefetch()
|
|
|
|
# --yolo: bypass all dangerous command approvals. main() also sets this
|
|
# before _prepare_agent_startup() — the authoritative site, since it runs
|
|
# before tool imports freeze _YOLO_MODE_FROZEN. This is a safety net for
|
|
# callers that invoke cmd_chat directly (e.g. subcommand dispatch).
|
|
if getattr(args, "yolo", False):
|
|
os.environ["HERMES_YOLO_MODE"] = "1"
|
|
# --ignore-rules: skip AGENTS.md/SOUL.md/.cursorrules injection, memory
|
|
# entries and preloaded skills (AIAgent(skip_context_files, skip_memory)).
|
|
if getattr(args, "ignore_rules", False):
|
|
os.environ["HERMES_IGNORE_RULES"] = "1"
|
|
# --source: tag session source for filtering (e.g. 'tool' for integrations)
|
|
if getattr(args, "source", None):
|
|
os.environ["HERMES_SESSION_SOURCE"] = args.source
|
|
|
|
_pin_kanban_board_env()
|
|
_confirm_startup_expensive_model_override(args)
|
|
|
|
passthrough = {k: getattr(args, k, d) for k, d in _CHAT_PASSTHROUGH}
|
|
if use_tui:
|
|
_launch_tui(
|
|
passthrough.pop("resume"),
|
|
tui_dev=getattr(args, "tui_dev", False),
|
|
model=getattr(args, "model", None),
|
|
accept_hooks=getattr(args, "accept_hooks", False),
|
|
**passthrough,
|
|
)
|
|
|
|
_read_query_file(args)
|
|
|
|
safe_mode = getattr(args, "safe_mode", False)
|
|
kwargs = {
|
|
"model": args.model,
|
|
"reasoning": getattr(args, "reasoning", None),
|
|
"toolsets": args.toolsets,
|
|
"query": args.query,
|
|
"oneshot": bool(getattr(args, "oneshot_exit", False)),
|
|
"run_budget": getattr(args, "run_budget", None),
|
|
"ignore_rules": getattr(args, "ignore_rules", False) or safe_mode,
|
|
"ignore_user_config": getattr(args, "ignore_user_config", False) or safe_mode,
|
|
"compact": getattr(args, "compact", False),
|
|
**{k: getattr(args, k, d) for k, d in _CHAT_PASSTHROUGH},
|
|
}
|
|
kwargs = {k: v for k, v in kwargs.items() if v is not None}
|
|
|
|
try:
|
|
from cli import main as cli_main
|
|
|
|
cli_main(**kwargs)
|
|
except ValueError as e:
|
|
print(f"Error: {e}")
|
|
sys.exit(1)
|
|
except ImportError as e:
|
|
# Mixed-version installs (new cli.py, older hermes_cli.config) crash
|
|
# here — e.g. missing resolve_turn_limit / split_model_config_default
|
|
# (#96900). The agent-setup mixin prints this hint too late: HermesCLI
|
|
# construction already failed. Fast-chat launch also goes through
|
|
# cmd_chat, so this one catch covers `hermes` / `hermes chat`.
|
|
from hermes_constants import emit_partial_update_hint
|
|
|
|
if emit_partial_update_hint(e):
|
|
sys.exit(1)
|
|
raise
|
|
|
|
|
|
def cmd_gateway(args):
|
|
"""Gateway management commands."""
|
|
_sync_bundled_skills_quietly()
|
|
|
|
from hermes_cli.gateway import gateway_command
|
|
|
|
gateway_command(args)
|
|
|
|
|
|
def cmd_proxy(args):
|
|
"""Local OpenAI-compatible proxy to OAuth providers."""
|
|
# aiohttp is an extras install; keep it off the common path.
|
|
from hermes_cli.proxy.cli import cmd_proxy as _cmd_proxy
|
|
|
|
rc = _cmd_proxy(args)
|
|
if isinstance(rc, int) and rc != 0:
|
|
raise SystemExit(rc)
|
|
|
|
|
|
def _forward_command(name: str, module: str, attr: str, *, forward_return: bool = False, doc: str = ""):
|
|
"""A ``hermes <cmd>`` handler that hands ``args`` to ``<module>.<attr>``.
|
|
|
|
Imports at CALL time so fast paths never pay for it and
|
|
``patch("<module>.<attr>")`` keeps intercepting. ``forward_return``
|
|
surfaces the return code to ``main()`` (only kanban/project propagate).
|
|
"""
|
|
|
|
def _cmd(args):
|
|
import importlib
|
|
|
|
result = getattr(importlib.import_module(module), attr)(args)
|
|
return result if forward_return else None
|
|
|
|
_cmd.__name__ = _cmd.__qualname__ = name
|
|
_cmd.__doc__ = doc or None
|
|
return _cmd
|
|
|
|
|
|
cmd_setup = _forward_command("cmd_setup", "hermes_cli.setup", "run_setup_wizard", doc='Interactive setup wizard.')
|
|
cmd_login = _forward_command("cmd_login", "hermes_cli.auth", "login_command", doc='Authenticate Hermes CLI with a provider.')
|
|
cmd_logout = _forward_command("cmd_logout", "hermes_cli.auth", "logout_command", doc='Clear provider authentication.')
|
|
cmd_auth = _forward_command("cmd_auth", "hermes_cli.auth_commands", "auth_command", doc='Manage pooled credentials.')
|
|
cmd_status = _forward_command("cmd_status", "hermes_cli.status", "show_status", doc='Show status of all components.')
|
|
cmd_cron = _forward_command("cmd_cron", "hermes_cli.cron", "cron_command", forward_return=True, doc='Cron job management.')
|
|
cmd_webhook = _forward_command("cmd_webhook", "hermes_cli.webhook", "webhook_command", doc='Webhook subscription management.')
|
|
cmd_kanban = _forward_command("cmd_kanban", "hermes_cli.kanban", "kanban_command", forward_return=True, doc='Multi-profile collaboration board.')
|
|
cmd_project = _forward_command("cmd_project", "hermes_cli.projects_cmd", "projects_command", forward_return=True, doc='Manage projects (named, multi-folder workspaces).')
|
|
cmd_hooks = _forward_command("cmd_hooks", "hermes_cli.hooks", "hooks_command", doc='Shell-hook inspection and management.')
|
|
cmd_doctor = _forward_command("cmd_doctor", "hermes_cli.doctor", "run_doctor", doc='Check configuration and dependencies.')
|
|
cmd_dump = _forward_command("cmd_dump", "hermes_cli.dump", "run_dump", doc='Dump setup summary for support/debugging.')
|
|
cmd_debug = _forward_command("cmd_debug", "hermes_cli.debug", "run_debug", doc='Debug tools (share report, etc.).')
|
|
cmd_skin = _forward_command("cmd_skin", "hermes_cli.skin_cmd", "skin_command", doc='Skin management (list / use / set).')
|
|
cmd_import = _forward_command("cmd_import", "hermes_cli.backup", "run_import", doc='Restore a Hermes backup from a zip file.')
|
|
cmd_dashboard_register = _forward_command("cmd_dashboard_register", "hermes_cli.dashboard_register", "cmd_dashboard_register", doc='Register a self-hosted dashboard OAuth client with Nous Portal.')
|
|
cmd_gateway_enroll = _forward_command("cmd_gateway_enroll", "hermes_cli.gateway_enroll", "cmd_gateway_enroll", doc='Enroll a self-hosted gateway with a relay connector.')
|
|
cmd_prompt_size = _forward_command("cmd_prompt_size", "hermes_cli.prompt_size", "cmd_prompt_size", doc='Show a byte/char breakdown of the system prompt + tool schemas.')
|
|
cmd_pairing = _forward_command("cmd_pairing", "hermes_cli.pairing", "pairing_command")
|
|
cmd_plugins = _forward_command("cmd_plugins", "hermes_cli.plugins_cmd", "plugins_command")
|
|
cmd_mcp = _forward_command("cmd_mcp", "hermes_cli.mcp_config", "mcp_command")
|
|
cmd_claw = _forward_command("cmd_claw", "hermes_cli.claw", "claw_command")
|
|
cmd_import_agent = _forward_command("cmd_import_agent", "hermes_cli.agent_import", "import_agent_command")
|
|
|
|
|
|
def cmd_model(args):
|
|
"""Select default model — starts with provider selection, then model picker."""
|
|
_require_tty("model")
|
|
if getattr(args, "refresh", False):
|
|
try:
|
|
from hermes_cli.models import clear_provider_models_cache
|
|
clear_provider_models_cache()
|
|
print(" Cleared model picker cache.")
|
|
except Exception:
|
|
pass
|
|
from hermes_cli.setup import run_setup_action_with_navigation
|
|
|
|
run_setup_action_with_navigation(
|
|
"Model & Provider",
|
|
lambda: select_provider_and_model(args=args),
|
|
cancelled_message="No change.",
|
|
)
|
|
|
|
|
|
# Provider id -> flow(config, current_model, args). Lambdas resolve the
|
|
# _model_flow_* names at call time so test monkeypatches keep intercepting.
|
|
# ``custom:*``, remove-custom and the generic API-key set are the fallthrough
|
|
# branches in select_provider_and_model.
|
|
_PROVIDER_MODEL_FLOWS = {
|
|
"openrouter": lambda c, m, a: _model_flow_openrouter(c, m),
|
|
"moa": lambda c, m, a: _model_flow_moa(c, m),
|
|
"ai-gateway": lambda c, m, a: _model_flow_ai_gateway(c, m),
|
|
"nous": lambda c, m, a: _model_flow_nous(c, m, args=a),
|
|
"openai-codex": lambda c, m, a: _model_flow_openai_codex(c, m),
|
|
"xai-oauth": lambda c, m, a: _model_flow_xai_oauth(c, m, args=a),
|
|
"qwen-oauth": lambda c, m, a: _model_flow_qwen_oauth(c, m),
|
|
"minimax-oauth": lambda c, m, a: _model_flow_minimax_oauth(c, m, args=a),
|
|
"copilot-acp": lambda c, m, a: _model_flow_copilot_acp(c, m),
|
|
"copilot": lambda c, m, a: _model_flow_copilot(c, m),
|
|
"custom": lambda c, m, a: _model_flow_custom(c),
|
|
"anthropic": lambda c, m, a: _model_flow_anthropic(c, m),
|
|
"kimi-coding": lambda c, m, a: _model_flow_kimi(c, m),
|
|
"stepfun": lambda c, m, a: _model_flow_stepfun(c, m),
|
|
"bedrock": lambda c, m, a: _model_flow_bedrock(c, m),
|
|
"vertex": lambda c, m, a: _model_flow_vertex(c, m),
|
|
"azure-foundry": lambda c, m, a: _model_flow_azure_foundry(c, m),
|
|
}
|
|
|
|
|
|
def _norm_base_url(url: str) -> str:
|
|
return str(url or "").strip().rstrip("/").lower()
|
|
|
|
|
|
def _resolve_active_provider(config, model_cfg, effective_provider, custom_provider_map):
|
|
"""Provider slug currently in effect (the picker's default row), or None.
|
|
|
|
Order: a saved custom provider whose base_url matches model.base_url →
|
|
the configured/env provider (named custom → canonical map key) → auto
|
|
detection. Unknown/unauthenticated providers warn and fall back to auto.
|
|
"""
|
|
from hermes_cli.auth import AuthError, format_auth_error, resolve_provider
|
|
from hermes_cli.config import get_compatible_custom_providers, get_env_value
|
|
from hermes_cli.providers import custom_provider_aliases, resolve_provider_full
|
|
|
|
active = ""
|
|
if effective_provider == "custom" and isinstance(model_cfg, dict):
|
|
current_base = _norm_base_url(model_cfg.get("base_url", ""))
|
|
if current_base:
|
|
active = next(
|
|
(k for k, info in custom_provider_map.items()
|
|
if _norm_base_url(info.get("base_url", "")) == current_base),
|
|
"",
|
|
)
|
|
if not active and effective_provider != "auto":
|
|
active_def = resolve_provider_full(
|
|
effective_provider,
|
|
config.get("providers"),
|
|
get_compatible_custom_providers(config),
|
|
)
|
|
if active_def is not None:
|
|
active = active_def.id
|
|
if active_def.source == "user-config":
|
|
requested = str(active or "").strip().lower()
|
|
active = next(
|
|
(k for k, info in custom_provider_map.items()
|
|
if requested in custom_provider_aliases(
|
|
info.get("name", ""), info.get("provider_key", ""))),
|
|
active,
|
|
)
|
|
else:
|
|
print(
|
|
f"Warning: Unknown provider '{effective_provider}'. Check 'hermes model' for "
|
|
"available providers, or run 'hermes doctor' to diagnose config "
|
|
"issues. Falling back to auto provider detection."
|
|
)
|
|
if not active:
|
|
try:
|
|
active = resolve_provider("auto")
|
|
except AuthError as exc:
|
|
if effective_provider == "auto":
|
|
print(f"Warning: {format_auth_error(exc)} Falling back to auto provider detection.")
|
|
active = None # no provider yet; default to first in list
|
|
|
|
# Detect custom endpoint
|
|
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
|
|
active = "custom"
|
|
return active
|
|
|
|
|
|
def _pick_provider(config, active, provider_labels, custom_provider_map):
|
|
"""Provider picker (+ group member sub-picker) -> concrete slug, or None on cancel."""
|
|
# Group rows drill into a member sub-picker that resolves back to a
|
|
# concrete slug, so the flow dispatch is unchanged.
|
|
ordered, default_idx = _build_provider_picker_rows(
|
|
config, active, provider_labels, custom_provider_map
|
|
)
|
|
provider_idx = _prompt_provider_choice(
|
|
[label for _, label, _ in ordered],
|
|
default=default_idx,
|
|
)
|
|
if provider_idx is None or ordered[provider_idx][0] == "cancel":
|
|
return None
|
|
selected_key, group_label, selected_members = ordered[provider_idx]
|
|
if not selected_members:
|
|
return selected_key
|
|
# Default to the active member when it lives in this group. The group row
|
|
# carries the descriptive text, so member rows show only their short label.
|
|
member_idx = _prompt_provider_choice(
|
|
[provider_labels.get(m, m) for m in selected_members],
|
|
default=selected_members.index(active) if active in selected_members else 0,
|
|
title=f"Select {group_label.split(' ▸', 1)[0]} provider:",
|
|
)
|
|
return None if member_idx is None else selected_members[member_idx]
|
|
|
|
|
|
def select_provider_and_model(args=None):
|
|
"""Core provider selection + model picking logic.
|
|
|
|
Shared by ``cmd_model`` (``hermes model``) and the setup wizard
|
|
(``setup_model_provider`` in setup.py). Handles the full flow:
|
|
provider picker, credential prompting, model selection, and config
|
|
persistence.
|
|
"""
|
|
from hermes_cli.config import load_config
|
|
|
|
config = load_config()
|
|
model_cfg = config.get("model")
|
|
current_model = model_cfg.get("default", "") if isinstance(model_cfg, dict) else model_cfg
|
|
current_model = current_model or "(not set)"
|
|
|
|
# Effective provider the same way the CLI resolves it at startup:
|
|
# config.yaml model.provider > env var > auto-detect
|
|
config_provider = model_cfg.get("provider") if isinstance(model_cfg, dict) else None
|
|
effective_provider = config_provider or os.getenv("HERMES_INFERENCE_PROVIDER") or "auto"
|
|
|
|
# User-defined custom providers from config.yaml: key → {name, base_url, api_key}
|
|
_custom_provider_map = _named_custom_provider_map(config)
|
|
active = _resolve_active_provider(config, model_cfg, effective_provider, _custom_provider_map)
|
|
|
|
from hermes_cli.models import _PROVIDER_LABELS
|
|
|
|
provider_labels = dict(_PROVIDER_LABELS) # derive from canonical list
|
|
if active and active in _custom_provider_map:
|
|
active_label = _custom_provider_map[active]["name"]
|
|
else:
|
|
active_label = provider_labels.get(active, active) if active else "none"
|
|
|
|
print()
|
|
print(f" Current model: {current_model}")
|
|
print(f" Active provider: {active_label}")
|
|
print()
|
|
|
|
selected_provider = _pick_provider(config, active, provider_labels, _custom_provider_map)
|
|
if selected_provider is None:
|
|
print("No change.")
|
|
return
|
|
if selected_provider == "aux-config":
|
|
_aux_config_menu()
|
|
return
|
|
|
|
# Provider-specific setup + model selection. Flows resolve the
|
|
# _model_flow_* names at call time so test monkeypatches on
|
|
# hermes_cli.main keep intercepting.
|
|
flow = _PROVIDER_MODEL_FLOWS.get(selected_provider)
|
|
if flow is not None:
|
|
flow(config, current_model, args)
|
|
elif (
|
|
selected_provider.startswith("custom:")
|
|
or selected_provider in _custom_provider_map
|
|
):
|
|
provider_info = _named_custom_provider_map(load_config()).get(selected_provider)
|
|
if provider_info is None:
|
|
print(
|
|
"Warning: the selected saved custom provider is no longer available. "
|
|
"It may have been removed from config.yaml. No change."
|
|
)
|
|
return
|
|
_model_flow_named_custom(config, provider_info)
|
|
elif selected_provider == "remove-custom":
|
|
_remove_custom_provider(config)
|
|
elif (
|
|
selected_provider in _GENERIC_API_KEY_PROVIDERS
|
|
or _is_profile_api_key_provider(selected_provider)
|
|
):
|
|
_model_flow_api_key_provider(config, selected_provider, current_model)
|
|
|
|
# Post-switch cleanup: switching to a named provider (anything except
|
|
# "custom") leaves a stale OPENAI_BASE_URL in ~/.hermes/.env that poisons
|
|
# auxiliary clients using provider:auto — clear it proactively. (#5161)
|
|
if selected_provider not in {
|
|
"custom",
|
|
"cancel",
|
|
"remove-custom",
|
|
} and not selected_provider.startswith("custom:"):
|
|
_clear_stale_openai_base_url()
|
|
|
|
|
|
# Frozen updater surface (PEP 562 ``__getattr__`` below): the frozen
|
|
# ``hermes_cli/update_cmd*.py`` files resolve these names via ``_m().<name>``
|
|
# on hermes_cli.main; importing update_cmd eagerly would cost every ``hermes``
|
|
# invocation ~50-100ms, so they resolve on first read. Nothing else may be
|
|
# added here — internal import paths are not a stable API.
|
|
_FROZEN_UPDATER_SURFACE: dict[str, tuple[str, ...]] = {
|
|
"hermes_cli.update_cmd": (
|
|
"_abort_dependency_sync_if_self_locked", "_assess_parked_branch_switch",
|
|
"_capture_active_lazy_features", "_capture_active_tool_dependencies",
|
|
"_cold_start_windows_gateway_after_update", "_defer_update_for_self_lock",
|
|
"_dependency_sync_would_rewrite", "_detect_self_loaded_native_modules",
|
|
"_detect_venv_python_processes", "_discard_stashed_changes",
|
|
"_filter_non_gateway_concurrent_instances", "_fleet_probe_expected_runtimes",
|
|
"_get_origin_url", "_handoff_reapable_backend_pids", "_ledger_manual_serve_holders",
|
|
"_ledger_reapable_backend_pids", "_leftover_pausable_gateway_pids", "_npm_lockfile_changed",
|
|
"_orphaned_desktop_backend_pids", "_park_stashed_changes",
|
|
"_pause_windows_gateways_for_update", "_print_parked_branch_kept_notice",
|
|
"_print_parked_branch_skip_warning", "_purge_stale_hermes_modules",
|
|
"_refresh_active_lazy_features", "_refresh_active_memory_provider_dependencies",
|
|
"_refresh_bootstrap_cache_scripts", "_refresh_windows_gateway_launchers",
|
|
"_relaunch_stopped_serves", "_reload_updated_runtime_modules",
|
|
"_restore_active_tool_dependencies", "_restore_stashed_changes",
|
|
"_resume_windows_gateways_after_update", "_run_logged_subprocess", "_run_pre_update_backup",
|
|
"_stash_local_changes_if_needed", "_stop_process_trees", "_sync_with_upstream_if_needed",
|
|
"_upgrade_pip_before_lazy_refresh", "_venv_launcher_ancestors",
|
|
"_wait_for_windows_update_gateway_exit", "_warn_orphaned_update_autostashes",
|
|
"_write_update_incomplete_marker",
|
|
),
|
|
"hermes_cli.dashboard_procs": (
|
|
"_detect_concurrent_hermes_instances", "_kill_stale_dashboard_processes",
|
|
),
|
|
}
|
|
_FROZEN_ATTR_SOURCES: dict[str, str] = {
|
|
attr: module for module, attrs in _FROZEN_UPDATER_SURFACE.items() for attr in attrs
|
|
}
|
|
|
|
|
|
def __getattr__(name):
|
|
"""Resolve the frozen updater surface on first read (see _FROZEN_UPDATER_SURFACE)."""
|
|
module = _FROZEN_ATTR_SOURCES.get(name)
|
|
if module is None:
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
import importlib
|
|
|
|
value = getattr(importlib.import_module(module), name)
|
|
globals()[name] = value # cache: later accesses skip __getattr__
|
|
return value
|
|
|
|
|
|
def cmd_verify(args):
|
|
"""Detect a project's run recipe and smoke-test it."""
|
|
from hermes_cli.verify_cmd import run_verify_command
|
|
|
|
sys.exit(run_verify_command(args))
|
|
|
|
|
|
def cmd_security(args):
|
|
"""Dispatch `hermes security <subcmd>`."""
|
|
sub = getattr(args, "security_command", None)
|
|
if sub in ("audit", None):
|
|
from hermes_cli.security_audit import cmd_security_audit
|
|
|
|
# Default subcommand is `audit` when no subcmd is given.
|
|
code = cmd_security_audit(args)
|
|
sys.exit(int(code or 0))
|
|
print(f"unknown security subcommand: {sub}", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
|
|
def cmd_approvals(args):
|
|
"""Dispatch `hermes approvals <subcmd>`."""
|
|
from hermes_cli.approvals_suggest import approvals_command
|
|
|
|
status = approvals_command(args)
|
|
if status:
|
|
sys.exit(status)
|
|
return status
|
|
|
|
|
|
def cmd_config(args):
|
|
"""Configuration management."""
|
|
from hermes_cli.config import config_command
|
|
|
|
try:
|
|
config_command(args)
|
|
except RuntimeError as exc:
|
|
# Fail-closed config write guard (require_readable_config_before_write);
|
|
# covers migrate and future write subcommands so none end in a traceback.
|
|
print(f"✗ {exc}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def cmd_backup(args):
|
|
"""Back up Hermes home directory to a zip file."""
|
|
from hermes_cli import backup
|
|
|
|
(backup.run_quick_backup if getattr(args, "quick", False) else backup.run_backup)(args)
|
|
|
|
|
|
def _print_version_info(*, check_updates: bool = True) -> None:
|
|
# Shared with the `hermes --version` pre-import fast path.
|
|
_startup_fast.print_fast_version_info(check_updates=check_updates)
|
|
|
|
|
|
def cmd_version(args):
|
|
"""Show version (--version/-V flag)."""
|
|
_print_version_info(check_updates=True)
|
|
|
|
|
|
def cmd_uninstall(args):
|
|
"""Uninstall Hermes Agent (or just the Chat GUI with --gui).
|
|
|
|
``--yes`` paths run from the desktop app's non-interactive cleanup scripts,
|
|
so the TTY gate applies only when we actually need to prompt.
|
|
"""
|
|
# Machine-readable snapshot for the desktop uninstall UI; before any TTY gate.
|
|
if getattr(args, "gui_summary", False):
|
|
from hermes_cli.gui_uninstall import gui_install_summary
|
|
|
|
print(json.dumps(gui_install_summary()))
|
|
return
|
|
|
|
if getattr(args, "gui", False):
|
|
if not getattr(args, "yes", False):
|
|
_require_tty("uninstall --gui")
|
|
from hermes_cli.uninstall import run_gui_uninstall
|
|
|
|
run_gui_uninstall(args)
|
|
return
|
|
|
|
if not getattr(args, "yes", False):
|
|
_require_tty("uninstall")
|
|
from hermes_cli.uninstall import run_uninstall
|
|
|
|
run_uninstall(args)
|
|
|
|
|
|
def _clear_bytecode_cache(root: Path) -> int:
|
|
"""Remove all __pycache__ dirs under *root* (stale .pyc → ImportError after updates).
|
|
|
|
Returns the number of directories removed.
|
|
"""
|
|
removed = 0
|
|
for dirpath, dirnames, _ in os.walk(root):
|
|
dirnames[:] = [
|
|
d
|
|
for d in dirnames
|
|
if d not in {"venv", ".venv", "node_modules", ".git", ".worktrees"}
|
|
]
|
|
if os.path.basename(dirpath) == "__pycache__":
|
|
try:
|
|
shutil.rmtree(dirpath)
|
|
removed += 1
|
|
except OSError:
|
|
pass
|
|
dirnames.clear() # nothing left to recurse into
|
|
return removed
|
|
|
|
|
|
def _finalize_update_receipt(code: int, reason: str) -> None:
|
|
"""Best-effort receipt close at the command boundary; no-op if already finalized."""
|
|
try:
|
|
# Receipt boundary (#91283 review): the impl has many early sys.exit paths (concurrent-instance
|
|
# preflight, venv-holder refusal, head-pinned no-op, fetch failure) that never reach an inner
|
|
# finalize. Persist any still-open receipt with the real exit code, then let the exit proceed
|
|
# unchanged. No-op when an inner path already finalized (exactly-once by construction).
|
|
from hermes_cli.update_receipt import finalize_pending_update_receipt
|
|
|
|
finalize_pending_update_receipt(code, reason)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _update_preflight_handled(args) -> bool:
|
|
"""Managed-install refusal, --plan, admission gate, --check. True = nothing more to do."""
|
|
from hermes_cli.config import is_managed, managed_error
|
|
|
|
if is_managed():
|
|
managed_error("update Hermes Agent")
|
|
return True
|
|
|
|
# --plan is read-only and deployment-kind aware, so it runs BEFORE the
|
|
# docker/nix/apt refusal gates: on an image/package-managed install the
|
|
# plan itself reports "not updatable in place" plus the right mechanism.
|
|
if getattr(args, "plan", False):
|
|
# Read-only plan phase (#91277 Phase 2): inventory every running Hermes runtime across profiles, its
|
|
# supervisor, and its running code version — without mutating anything. Safe on a live fleet.
|
|
from hermes_cli.update_inventory import (
|
|
collect_runtime_inventory,
|
|
print_update_plan,
|
|
)
|
|
|
|
print_update_plan(collect_runtime_inventory())
|
|
return True
|
|
|
|
# Image/package-managed admission gate: baked provenance marker first
|
|
# (fail-closed on malformed), then docker/nix/apt heuristics. Records a
|
|
# `refused` receipt and exits 2 (refused-by-contract, distinct from errors).
|
|
# Image-managed / package-managed admission gate (#91277 Phase 3): one shared decision for every
|
|
# mutation surface. Prints the real update command, records a `refused` receipt so fleet tooling sees
|
|
# the blocked attempt, and exits 2 (refused-by-contract, distinct from exit 1 errors).
|
|
# Shared admission gate (#91277 Phase 3): same marker-first decision as the apply path, so --check can
|
|
# never report git state for an install whose real update mechanism is an image pull.
|
|
# The response keeps the pre-existing per-kind error codes the dashboard UI already keys on. See #91277.
|
|
from hermes_cli.update_contract import (
|
|
evaluate_update_admission,
|
|
record_refusal_receipt,
|
|
)
|
|
|
|
refusal = evaluate_update_admission(PROJECT_ROOT)
|
|
if refusal is not None:
|
|
print(refusal.message)
|
|
record_refusal_receipt(refusal)
|
|
sys.exit(2)
|
|
|
|
if getattr(args, "check", False):
|
|
# --check honors --branch so its answer matches what update would pull.
|
|
branch = _resolve_update_branch(args)
|
|
from hermes_cli.update_cmd import _cmd_update_check
|
|
|
|
_cmd_update_check(
|
|
branch=branch,
|
|
branch_explicit=bool(getattr(args, "branch", None)),
|
|
)
|
|
return True
|
|
return False
|
|
|
|
|
|
def cmd_update(args):
|
|
"""Update Hermes Agent: hangup protection + update lock around ``_cmd_update_impl``."""
|
|
if _update_preflight_handled(args):
|
|
return
|
|
gateway_mode = getattr(args, "gateway", False)
|
|
|
|
_update_io_state = _install_hangup_protection(gateway_mode=gateway_mode)
|
|
# Cross-process mutual exclusion: dashboard Update button, Tauri updater
|
|
# and this command all mutate one checkout; two at once strand it
|
|
# half-updated. Shares the marker the Tauri/Electron updaters already use.
|
|
from hermes_cli.update_lock import (
|
|
UPDATE_EXIT_CONCURRENT,
|
|
UpdateLock,
|
|
describe_holder,
|
|
)
|
|
|
|
_update_lock = UpdateLock()
|
|
if not _update_lock.acquire():
|
|
print(describe_holder(_update_lock.holder))
|
|
_finalize_update_output(_update_io_state)
|
|
sys.exit(UPDATE_EXIT_CONCURRENT)
|
|
|
|
# Exit code for the Windows hand-off child's hard exit (see finally); None
|
|
# = not SystemExit-shaped, so real exceptions keep their traceback.
|
|
_update_handoff_exit_code: int | None = None
|
|
from hermes_cli.update_cmd import _cmd_update_impl
|
|
|
|
try:
|
|
_cmd_update_impl(args, gateway_mode=gateway_mode)
|
|
except SystemExit as _update_exit:
|
|
# Receipt boundary: the impl has many early sys.exit paths that never
|
|
# reach an inner finalize. Persist any still-open receipt with the real
|
|
# exit code (no-op if already finalized), then let the exit proceed.
|
|
_code = _update_exit.code if isinstance(_update_exit.code, int) else 1
|
|
_finalize_update_receipt(_code, f"sys.exit({_code})")
|
|
_update_handoff_exit_code = (
|
|
_update_exit.code if isinstance(_update_exit.code, int) else 0
|
|
)
|
|
raise
|
|
except BaseException as _update_exc:
|
|
_finalize_update_receipt(1, f"{type(_update_exc).__name__}: {_update_exc}")
|
|
raise
|
|
else:
|
|
from hermes_cli.update_receipt import COMMAND_BOUNDARY_STOP_REASON
|
|
|
|
_finalize_update_receipt(0, COMMAND_BOUNDARY_STOP_REASON)
|
|
_update_handoff_exit_code = 0
|
|
finally:
|
|
_update_lock.release()
|
|
_finalize_update_output(_update_io_state)
|
|
# Windows hand-off child: a leftover non-daemon thread from the update
|
|
# tail would freeze the PowerShell window for minutes after the receipt
|
|
# is durable. Every durable step is done by now, so on the hand-off
|
|
# path only (marker env set solely by
|
|
# _reexec_dependency_sync_off_windows_shim) flush and exit hard.
|
|
# By this point every durable step is done (receipt finalized above, lock released, stdio restored),
|
|
# so on the hand-off path only, flush and exit hard instead of waiting for the interpreter to unwind
|
|
# — the same treatment #79040's cron workaround applies.
|
|
if _update_handoff_exit_code is not None and os.environ.get(_UPDATE_REEXEC_ENV) == "1":
|
|
logger.debug(
|
|
"Update hand-off child %s exiting via os._exit(%s)",
|
|
os.getpid(), _update_handoff_exit_code,
|
|
)
|
|
sys.stdout.flush()
|
|
sys.stderr.flush()
|
|
os._exit(_update_handoff_exit_code)
|
|
|
|
|
|
def _coalesce_session_name_args(argv: list) -> list:
|
|
"""Join unquoted multi-word session names after -c/--continue and -r/--resume.
|
|
|
|
``hermes -c Pokemon Agent Dev`` → ``['-c', 'Pokemon Agent Dev']``; tokens
|
|
are collected until the next flag (``-*``) or known top-level subcommand.
|
|
"""
|
|
_SUBCOMMANDS = {
|
|
"chat", "model", "gateway", "setup", "whatsapp", "whatsapp-cloud", "login", "logout",
|
|
"auth", "status", "cron", "doctor", "config", "pairing", "skills", "tools", "mcp",
|
|
"sessions", "insights", "update", "uninstall", "profile", "dashboard", "serve",
|
|
"desktop", "gui", "honcho", "claw", "plugins", "security", "acp", "webhook", "peer",
|
|
"memory", "dump", "debug", "backup", "import", "completion", "logs",
|
|
}
|
|
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
|
|
|
|
result = []
|
|
i = 0
|
|
while i < len(argv):
|
|
token = argv[i]
|
|
if token in _SESSION_FLAGS:
|
|
result.append(token)
|
|
i += 1
|
|
# 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
|
|
):
|
|
parts.append(argv[i])
|
|
i += 1
|
|
if parts:
|
|
result.append(" ".join(parts))
|
|
else:
|
|
result.append(token)
|
|
i += 1
|
|
return result
|
|
|
|
|
|
from hermes_cli.profile_cmd import cmd_profile
|
|
|
|
|
|
def _dashboard_lifecycle_flags(args, token_file) -> None:
|
|
"""--status / --stop: report or kill running dashboards and exit (no deps needed)."""
|
|
if token_file and (getattr(args, "status", False) or getattr(args, "stop", False)):
|
|
raise SystemExit("--ssh-session-token-file cannot be used with --status or --stop")
|
|
if getattr(args, "status", False):
|
|
_report_dashboard_status()
|
|
sys.exit(0) # status is informational, always 0
|
|
if getattr(args, "stop", False):
|
|
if not _find_stale_dashboard_pids():
|
|
print("No hermes dashboard processes running.")
|
|
sys.exit(0)
|
|
# Reuse the same SIGTERM-grace-SIGKILL path used after `hermes update`;
|
|
# it prints outcomes itself. Exit 1 only if every pid was unkillable.
|
|
from hermes_cli.dashboard_procs import _kill_stale_dashboard_processes
|
|
|
|
_kill_stale_dashboard_processes(reason="requested via --stop")
|
|
sys.exit(1 if _find_stale_dashboard_pids() else 0)
|
|
|
|
|
|
def _dashboard_validate_serve_args(args, headless_backend, token_file):
|
|
"""Headless-serve argument checks -> ssh_owner_nonce (or None)."""
|
|
# `hermes serve` is headless/non-interactive: fail closed on a corrupt
|
|
# config.yaml instead of silently starting on defaults where provider
|
|
# auto-detection can adopt unnamed .env credentials (issue #81952).
|
|
# Same policy + escape hatch as _guard_noninteractive_user_config.
|
|
if headless_backend:
|
|
from hermes_cli.config import (
|
|
InvalidUserConfigError,
|
|
require_parseable_user_config,
|
|
)
|
|
|
|
try:
|
|
require_parseable_user_config(
|
|
ignore_user_config=bool(getattr(args, "ignore_user_config", False))
|
|
)
|
|
except InvalidUserConfigError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
raise SystemExit(2) from exc
|
|
ssh_owner_nonce = getattr(args, "ssh_owner_nonce", None)
|
|
if ssh_owner_nonce and not re.fullmatch(r"[0-9a-f]{16}", ssh_owner_nonce):
|
|
raise SystemExit("--ssh-owner-nonce must be 16 lowercase hex characters")
|
|
if token_file and not headless_backend:
|
|
raise SystemExit("--ssh-session-token-file is only valid with hermes serve")
|
|
return ssh_owner_nonce
|
|
|
|
|
|
def _dashboard_sanitize_desktop_env(headless_backend) -> None:
|
|
"""Strip Desktop-inherited env that hijacks a standalone launch.
|
|
|
|
Desktop Electron spawns its backend with HERMES_DESKTOP=1 plus
|
|
HERMES_WEB_DIST=<packaged app.asar[/unpacked]/dist> (and often
|
|
HERMES_SERVE_HEADLESS=1). A shell inheriting those then running
|
|
`hermes dashboard` would serve the desktop renderer ("Desktop IPC bridge
|
|
is unavailable", #52945) or disable the SPA. Only Electron-packaged
|
|
WEB_DIST contamination is stripped — caller-managed overrides (dev /
|
|
custom builds) must still work, and the desktop-spawned backend itself
|
|
(HERMES_DESKTOP=1) keeps its dist. Headless `serve` re-sets
|
|
HERMES_SERVE_HEADLESS itself.
|
|
"""
|
|
if os.environ.get("HERMES_DESKTOP") != "1":
|
|
if _is_electron_packaged_web_dist(os.environ.get("HERMES_WEB_DIST", "")):
|
|
os.environ.pop("HERMES_WEB_DIST", None)
|
|
if not headless_backend:
|
|
os.environ.pop("HERMES_SERVE_HEADLESS", None)
|
|
|
|
|
|
def _dashboard_prepare_runtime(args, headless_backend) -> bool:
|
|
"""Deps check, skills seed, terminal env bridge, plugins, MCP discovery.
|
|
|
|
Returns ``start_mcp_discovery_after_bind`` for start_server.
|
|
"""
|
|
# Attach gui.log early so dashboard startup/build failures are captured in
|
|
# the same logs directory as every other Hermes surface.
|
|
try:
|
|
from hermes_logging import setup_logging as _setup_logging_gui
|
|
_setup_logging_gui(mode="gui")
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
import fastapi # noqa: F401
|
|
import uvicorn # noqa: F401
|
|
except ImportError as e:
|
|
print("Web UI dependencies not installed (need fastapi + uvicorn).")
|
|
print(
|
|
f"Re-install the package into this interpreter so metadata updates apply:\n"
|
|
f" cd {PROJECT_ROOT}\n"
|
|
f" {sys.executable} -m pip install -e .\n"
|
|
"If `pip` is missing in this venv, use: uv pip install -e ."
|
|
)
|
|
print(f"Import error: {e}")
|
|
sys.exit(1)
|
|
|
|
# Seed bundled skills on first dashboard launch so the desktop GUI's
|
|
# skills picker / agent skill discovery sees the bundled library.
|
|
_sync_bundled_skills_quietly()
|
|
|
|
# Bridge terminal.* config into TERMINAL_* env for THIS process, like the
|
|
# CLI (cli.py env_mappings) and gateway (_terminal_env_map) do. The
|
|
# dashboard/serve backend runs agents in-process (tui_gateway.ws →
|
|
# server._make_agent) and ticks cron itself when desktop-spawned; without
|
|
# this those consumers saw an unset TERMINAL_ENV and ran every command on
|
|
# the host even under `terminal.backend: docker` (#63141, #54449).
|
|
try:
|
|
# PTY chat spawns already bridge their child env copy; this covers the in-process consumers. See
|
|
# #61115, #65696.
|
|
from hermes_cli.config import apply_terminal_config_to_env
|
|
|
|
apply_terminal_config_to_env()
|
|
except Exception:
|
|
logger.debug("terminal config → env bridge failed for dashboard/serve",
|
|
exc_info=True)
|
|
|
|
_resolve_dashboard_web_dist(args, headless_backend)
|
|
# Load plugins so any DashboardAuthProvider plugin registers BEFORE
|
|
# start_server's fail-closed gate check. Argparse setup skips discovery
|
|
# for built-in subcommands (~500ms), but the dashboard's server-side
|
|
# runtime depends on plugin-registered providers (image_gen, web,
|
|
# dashboard_auth, …).
|
|
try:
|
|
from hermes_cli.plugins import discover_plugins
|
|
discover_plugins()
|
|
except Exception as exc:
|
|
# Must not block startup; the gate's fail-closed branch surfaces a
|
|
# missing provider if it matters.
|
|
print(f"⚠ Plugin discovery failed: {exc}", file=sys.stderr)
|
|
|
|
# Desktop chat uses the in-process /api/ws gateway (tui_gateway.server
|
|
# ._make_agent), which only snapshots the tool registry and never starts
|
|
# MCP discovery — so configured MCP servers would never connect. Spawn
|
|
# discovery in the background here so a slow/dead server can't block
|
|
# startup. Desktop-spawned headless backends start it AFTER the socket
|
|
# binds instead (start_server's ready path): the thread's first act is the
|
|
# ~350ms `mcp` SDK import, which holds the GIL against the web_server
|
|
# import and delays the READY sentinel; _make_agent's bounded
|
|
# wait_for_mcp_discovery covers a server still connecting at first turn.
|
|
mcp_discovery_after_bind = headless_backend and os.environ.get("HERMES_DESKTOP") == "1"
|
|
if not mcp_discovery_after_bind:
|
|
try:
|
|
from hermes_cli.mcp_startup import start_background_mcp_discovery
|
|
|
|
start_background_mcp_discovery(
|
|
logger=logger,
|
|
thread_name="dashboard-mcp-discovery",
|
|
)
|
|
except Exception:
|
|
logger.debug(
|
|
"Background MCP tool discovery failed at dashboard startup",
|
|
exc_info=True,
|
|
)
|
|
return mcp_discovery_after_bind
|
|
|
|
|
|
def cmd_dashboard(args):
|
|
"""Start the web UI server, or (with --stop/--status) manage running ones."""
|
|
_token_file = getattr(args, "ssh_session_token_file", None)
|
|
_dashboard_lifecycle_flags(args, _token_file)
|
|
|
|
# `serve` is the headless backend: no UI build, no SPA mount, neutral
|
|
# ready sentinel. Resolved once and threaded through the re-exec, the
|
|
# build gate, and start_server.
|
|
_headless_backend = getattr(args, "headless_backend", False)
|
|
_ssh_owner_nonce = _dashboard_validate_serve_args(args, _headless_backend, _token_file)
|
|
_dashboard_sanitize_desktop_env(_headless_backend)
|
|
|
|
_route_named_profile_dashboard(args, _headless_backend, _ssh_owner_nonce, _token_file)
|
|
|
|
# Apply the final process/profile policy after dashboard routing, but before
|
|
# importing the web server or opening dashboard state. Applying it before a
|
|
# named-profile re-exec could leak that profile's higher limit into the
|
|
# machine/default dashboard, whose lower policy intentionally cannot undo it.
|
|
# This also covers Desktop SSH's isolated `serve` child, which does not route.
|
|
from hermes_cli.resource_limits import apply_nofile_soft_limit
|
|
|
|
apply_nofile_soft_limit()
|
|
|
|
_ssh_session_token = _read_ssh_session_token_file(_token_file) if _token_file else None
|
|
_mcp_discovery_after_bind = _dashboard_prepare_runtime(args, _headless_backend)
|
|
|
|
from hermes_cli.web_server import start_server
|
|
|
|
# Interactive auth setup: if this bind will engage the auth gate but no
|
|
# provider is registered yet, offer to configure one here (TTY only)
|
|
# instead of hard-failing inside start_server. Non-interactive callers
|
|
# (Docker/s6, CI, --no-open pipelines) fall through to start_server's
|
|
# fail-closed SystemExit unchanged.
|
|
_maybe_setup_dashboard_auth_interactively(args)
|
|
|
|
# The in-browser Chat tab (embedded TUI over PTY/WebSocket) is always
|
|
# available — desktop and dashboard both rely on `/api/ws` + `/api/pty`.
|
|
start_server(
|
|
host=args.host,
|
|
port=args.port,
|
|
open_browser=not args.no_open,
|
|
allow_public=getattr(args, "insecure", False),
|
|
initial_profile=getattr(args, "open_profile", "") or "",
|
|
headless=_headless_backend,
|
|
ssh_session_token=_ssh_session_token,
|
|
ssh_owner_nonce=_ssh_owner_nonce,
|
|
start_mcp_discovery_after_bind=_mcp_discovery_after_bind,
|
|
)
|
|
|
|
|
|
def cmd_completion(args, parser=None):
|
|
"""Print shell completion script."""
|
|
from hermes_cli import completion
|
|
|
|
shell = getattr(args, "shell", "bash")
|
|
generate = {"zsh": completion.generate_zsh, "fish": completion.generate_fish}.get(
|
|
shell, completion.generate_bash
|
|
)
|
|
print(generate(parser))
|
|
|
|
|
|
def cmd_logs(args):
|
|
"""View and filter Hermes log files."""
|
|
from hermes_cli.logs import tail_log, list_logs
|
|
|
|
log_name = getattr(args, "log_name", "agent") or "agent"
|
|
|
|
if log_name == "list":
|
|
list_logs()
|
|
return
|
|
|
|
tail_log(
|
|
log_name,
|
|
num_lines=getattr(args, "lines", 50),
|
|
follow=getattr(args, "follow", False),
|
|
level=getattr(args, "level", None),
|
|
session=getattr(args, "session", None),
|
|
since=getattr(args, "since", None),
|
|
component=getattr(args, "component", None),
|
|
)
|
|
|
|
|
|
def cmd_console(args):
|
|
"""Open the safe Hermes command console."""
|
|
from hermes_cli.console_engine import run_console_repl
|
|
|
|
return run_console_repl()
|
|
|
|
|
|
# Top-level subcommands known WITHOUT plugin discovery (which costs 500ms+ of
|
|
# eager plugin imports). Keep in sync with the add_parser calls in
|
|
# _build_cli_parser: a missing entry only costs a one-time discovery; an extra
|
|
# entry would let a plugin command silently fail to parse.
|
|
_BUILTIN_SUBCOMMANDS = frozenset(
|
|
{
|
|
"acp", "approvals", "auth", "backup", "bundles", "checkpoints", "claw", "completion",
|
|
"computer-use",
|
|
"config", "console", "cron", "curator", "dashboard", "serve", "debug", "doctor",
|
|
"dump", "egress", "fallback", "gateway", "hooks", "import", "import-agent", "insights",
|
|
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa",
|
|
"journey", "memory-graph", "learning",
|
|
"model", "monitoring", "pairing", "pause", "peer", "pets", "plugins", "portal", "profile",
|
|
"project", "proxy",
|
|
"prompt-size",
|
|
"resume",
|
|
"send", "sessions", "setup",
|
|
"skin", "skills", "slack", "status", "sync", "tools", "uninstall", "update",
|
|
"vault",
|
|
"webhook", "whatsapp", "whatsapp-cloud", "worktree", "chat", "secrets", "security",
|
|
"browser",
|
|
"verify",
|
|
# Plugin commands missing from top-level --help is an accepted trade-off.
|
|
"help",
|
|
}
|
|
)
|
|
|
|
|
|
def _first_positional_argv() -> str | None:
|
|
"""First non-flag, non-flag-value token in ``sys.argv[1:]`` (skips values of known flags).
|
|
|
|
Not a full argparse simulation: an unknown ``--foo bar`` may classify
|
|
``bar`` as positional, which at worst forces a one-time plugin discovery.
|
|
"""
|
|
from hermes_cli._parser import top_level_value_flag_sets
|
|
|
|
required_value_flags, optional_value_flags = top_level_value_flag_sets()
|
|
value_flags = required_value_flags | optional_value_flags
|
|
argv = sys.argv[1:]
|
|
i = 0
|
|
while i < len(argv):
|
|
tok = argv[i]
|
|
if tok == "--": # everything after is positional
|
|
return argv[i + 1] if i + 1 < len(argv) else None
|
|
if not tok.startswith("-"):
|
|
return tok
|
|
# ``--flag=value`` is a single token; a known value flag consumes the next.
|
|
i += 2 if ("=" not in tok and tok in value_flags and i + 1 < len(argv)) else 1
|
|
return None
|
|
|
|
|
|
def _plugin_cli_discovery_needed() -> bool:
|
|
"""True when the CLI might be invoking a plugin-registered subcommand.
|
|
|
|
False skips plugin discovery at argparse setup (~500-650ms). An unknown
|
|
first token could be a plugin command OR a chat prompt — either way
|
|
discovery is needed; for a prompt its cost amortizes over the agent run.
|
|
"""
|
|
first = _first_positional_argv() # None = bare ``hermes`` → chat
|
|
return first is not None and first not in _BUILTIN_SUBCOMMANDS
|
|
|
|
|
|
def _resolve_deferred_platform_cli_command(command_name: str | None) -> None:
|
|
"""Materialize the deferred platform whose top-level CLI command matches.
|
|
|
|
Bundled platforms are *deferred* entries (no gateway SDK imports at
|
|
startup), so a platform's ``register_cli_command`` side effect only runs
|
|
on import; ``discover_plugins()`` alone leaves ``hermes photon`` failing
|
|
with ``invalid choice``. Importing just the matching platform keeps
|
|
startup cheap.
|
|
|
|
On the unknown-top-level-command slow path, ``discover_plugins()`` records the deferred loader but does
|
|
not import it, so the CLI registration never happens and ``hermes photon`` fails with argparse ``invalid
|
|
choice`` (issue #54678).
|
|
"""
|
|
if not command_name:
|
|
return
|
|
try:
|
|
from gateway.platform_registry import platform_registry
|
|
|
|
platform_registry.get(command_name)
|
|
except Exception as exc:
|
|
logging.getLogger(__name__).debug(
|
|
"Deferred platform CLI resolution failed for %s: %s",
|
|
command_name,
|
|
exc,
|
|
)
|
|
|
|
|
|
_AGENT_COMMANDS = {None, "chat", "acp", "rl"}
|
|
_AGENT_SUBCOMMANDS = {
|
|
"cron": ("cron_command", {"run", "tick"}),
|
|
"gateway": ("gateway_command", {"run"}),
|
|
"mcp": ("mcp_action", {"serve"}),
|
|
}
|
|
|
|
|
|
def _is_tui_chat_launch(args) -> bool:
|
|
if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1":
|
|
return True
|
|
# The chat path decides TUI-vs-classic via _resolve_use_tui (--cli/--tui
|
|
# flags, TTY gate, HERMES_TUI env, display.interface config). Bare
|
|
# `hermes`/`hermes chat` with a TUI display config was previously missed
|
|
# here, so the wrapper pre-warmed its own MCP discovery while the TUI
|
|
# gateway (spawned moments later) ran a second one — an idle stdio MCP
|
|
# server copy held dead for the whole session. Only chat commands can
|
|
# launch the TUI; other commands (mcp serve, gateway, acp, cron) keep
|
|
# their own discovery behavior untouched.
|
|
if getattr(args, "command", None) not in {None, "chat"}:
|
|
return False
|
|
return _resolve_use_tui(args)
|
|
|
|
|
|
def _agent_subcommand_selected(args) -> bool:
|
|
"""True for ``cron run/tick``, ``gateway run``, ``mcp serve`` (see _AGENT_SUBCOMMANDS)."""
|
|
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
|
|
return bool(_sub_attr and getattr(args, _sub_attr, None) in _sub_set)
|
|
|
|
|
|
def _command_has_dedicated_mcp_startup(args) -> bool:
|
|
"""acp / gateway run / cron run|tick own their MCP startup on the runtime path."""
|
|
return args.command == "acp" or (
|
|
args.command != "mcp" and _agent_subcommand_selected(args)
|
|
)
|
|
|
|
|
|
def _should_background_mcp_startup(args) -> bool:
|
|
return not _is_tui_chat_launch(args) and args.command in {None, "chat", "rl"}
|
|
|
|
|
|
def _prepare_agent_startup(args) -> None:
|
|
"""Discover plugins/MCP/hooks for commands that can run an agent turn."""
|
|
# --yolo chokepoint: HERMES_YOLO_MODE must be set before any discovery
|
|
# below imports tools.approval, which freezes _YOLO_MODE_FROZEN at import.
|
|
# main() sets it earlier too, but other launchers (Termux fast-CLI) reach
|
|
# here directly, so the guarantee lives where the import is triggered.
|
|
# See #7994.
|
|
if getattr(args, "yolo", False):
|
|
os.environ["HERMES_YOLO_MODE"] = "1"
|
|
_apply_safe_mode(args)
|
|
_apply_user_config_bypass(args)
|
|
_guard_noninteractive_user_config(args)
|
|
|
|
if not (args.command in _AGENT_COMMANDS or _agent_subcommand_selected(args)):
|
|
return
|
|
|
|
_accept_hooks = bool(getattr(args, "accept_hooks", False))
|
|
if not _is_tui_chat_launch(args):
|
|
# The TUI backend does its own discovery; the launcher only spawns Node.
|
|
try:
|
|
from hermes_cli.plugins import start_background_plugin_discovery
|
|
|
|
# Daemon thread: ~150ms of manifest scanning overlaps the rest of
|
|
# startup. Every synchronous reader goes through discover_plugins(),
|
|
# which joins this thread first (incl. model_tools at import time).
|
|
start_background_plugin_discovery()
|
|
except Exception:
|
|
logger.warning(
|
|
"plugin discovery failed at CLI startup",
|
|
exc_info=True,
|
|
)
|
|
# -t/--toolsets narrows which configured MCP servers get spawned, on
|
|
# every discovery path (inline below, background thread, TUI/desktop
|
|
# deferred start). Built-in toolset names never match a server key, so
|
|
# `-t terminal` simply spawns nothing; `-t all` keeps the full set.
|
|
try:
|
|
from hermes_cli.mcp_startup import set_mcp_server_filter
|
|
|
|
set_mcp_server_filter(getattr(args, "toolsets", None))
|
|
except Exception:
|
|
logger.debug("MCP server filter setup failed", exc_info=True)
|
|
|
|
# TUI launches hand off to a startup path that backgrounds MCP discovery
|
|
# with a bounded join; acp/gateway/cron do their own on the runtime path.
|
|
_run_inline_mcp_discovery = not (
|
|
_is_tui_chat_launch(args) or _command_has_dedicated_mcp_startup(args)
|
|
)
|
|
if _run_inline_mcp_discovery and _should_background_mcp_startup(args):
|
|
try:
|
|
from hermes_cli.mcp_startup import start_background_mcp_discovery
|
|
|
|
start_background_mcp_discovery(
|
|
logger=logger,
|
|
thread_name="cli-mcp-discovery",
|
|
)
|
|
except Exception:
|
|
logger.debug(
|
|
"Background MCP tool discovery failed at CLI startup",
|
|
exc_info=True,
|
|
)
|
|
_run_inline_mcp_discovery = False
|
|
if _run_inline_mcp_discovery:
|
|
try: # synchronous for entrypoints without a later bounded startup path
|
|
from hermes_cli.mcp_startup import get_mcp_server_filter
|
|
from tools.mcp_tool_discovery import discover_mcp_tools
|
|
|
|
_mcp_filter = get_mcp_server_filter()
|
|
if _mcp_filter is None:
|
|
discover_mcp_tools()
|
|
else:
|
|
discover_mcp_tools(allowed_mcp_names=_mcp_filter)
|
|
except Exception:
|
|
logger.debug(
|
|
"MCP tool discovery failed at CLI startup",
|
|
exc_info=True,
|
|
)
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
from agent.shell_hooks import register_from_config
|
|
|
|
_hooks_cfg = load_config()
|
|
register_from_config(_hooks_cfg, accept_hooks=_accept_hooks)
|
|
|
|
from agent.outbound_webhooks import (
|
|
register_from_config as register_outbound_webhooks,
|
|
)
|
|
|
|
register_outbound_webhooks(_hooks_cfg)
|
|
except Exception:
|
|
logger.debug(
|
|
"shell-hook registration failed at CLI startup",
|
|
exc_info=True,
|
|
)
|
|
|
|
|
|
def _apply_safe_mode(args) -> None:
|
|
if not getattr(args, "safe_mode", False):
|
|
return
|
|
os.environ["HERMES_SAFE_MODE"] = "1"
|
|
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
|
|
os.environ["HERMES_IGNORE_RULES"] = "1"
|
|
|
|
|
|
def _apply_user_config_bypass(args) -> None:
|
|
"""Apply the explicit config bypass before any startup config reads."""
|
|
if getattr(args, "ignore_user_config", False):
|
|
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
|
|
|
|
|
|
def _guard_noninteractive_user_config(args) -> None:
|
|
"""Fail closed before a non-interactive invocation initializes providers."""
|
|
if getattr(args, "_noninteractive_config_validated", False):
|
|
return
|
|
|
|
is_noninteractive = (
|
|
bool(getattr(args, "oneshot", None))
|
|
or bool(getattr(args, "query", None))
|
|
)
|
|
if not is_noninteractive:
|
|
return
|
|
|
|
from hermes_cli.config import (
|
|
InvalidUserConfigError,
|
|
require_parseable_user_config,
|
|
)
|
|
|
|
try:
|
|
require_parseable_user_config(
|
|
ignore_user_config=bool(
|
|
getattr(args, "ignore_user_config", False)
|
|
or getattr(args, "safe_mode", False)
|
|
)
|
|
)
|
|
except InvalidUserConfigError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
raise SystemExit(2) from exc
|
|
|
|
setattr(args, "_noninteractive_config_validated", True)
|
|
|
|
|
|
def _set_chat_arg_defaults(args) -> None:
|
|
"""Fill the chat-parser attrs cmd_chat reads when chat was not parsed."""
|
|
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)
|
|
|
|
|
|
def _run_oneshot_from_args(args) -> None:
|
|
"""Top-level --oneshot / -z: single-shot mode, stdout = final response only.
|
|
|
|
Bypasses cli.py entirely; _run_and_exit_oneshot never returns.
|
|
"""
|
|
_confirm_startup_expensive_model_override(args)
|
|
# -z honors --resume/-c/--in exactly like chat (#105892): normalize BEFORE the
|
|
# oneshot exit path takes over, else the flags parse fine but silently do nothing
|
|
# and the turn starts a fresh session (every wire request loses all history).
|
|
_resolve_chat_session_args(args, use_tui=False)
|
|
_run_and_exit_oneshot(
|
|
args.oneshot,
|
|
model=getattr(args, "model", None),
|
|
provider=getattr(args, "provider", None),
|
|
toolsets=getattr(args, "toolsets", None),
|
|
skills=getattr(args, "skills", None),
|
|
usage_file=getattr(args, "usage_file", None),
|
|
resume=getattr(args, "resume", None),
|
|
reasoning=getattr(args, "reasoning", None),
|
|
)
|
|
|
|
|
|
def _light_chat_parser():
|
|
"""Top-level + chat parser only (no subcommand tree); chat dispatches to cmd_chat."""
|
|
from hermes_cli._parser import build_top_level_parser
|
|
|
|
parser, _subparsers, chat_parser = build_top_level_parser()
|
|
chat_parser.set_defaults(func=cmd_chat)
|
|
return parser
|
|
|
|
|
|
def _promote_top_level_resume(args) -> None:
|
|
"""Top-level --resume/--continue with no subcommand is a chat shortcut."""
|
|
if (args.resume or args.continue_last) and args.command is None:
|
|
args.command = "chat"
|
|
|
|
|
|
def _try_fast_serve_launch() -> bool:
|
|
"""Dispatch an unambiguous built-in ``serve`` without the full CLI tree.
|
|
|
|
Desktop runs this on every cold start; building every other parser costs
|
|
thousands of filesystem-backed lookups on Windows. Unknown or global
|
|
arguments fall back to normal parsing so error reporting is unchanged.
|
|
"""
|
|
if os.environ.get("HERMES_DISABLE_FAST_SERVE_LAUNCH") == "1":
|
|
return False
|
|
|
|
argv = sys.argv[1:]
|
|
if not argv or argv[0] != "serve" or "-h" in argv or "--help" in argv:
|
|
return False
|
|
|
|
# Container routing is top-level policy and must run before host dispatch.
|
|
try:
|
|
from hermes_cli.config import get_container_exec_info
|
|
|
|
if get_container_exec_info():
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
parser = build_serve_parser(
|
|
cmd_dashboard=cmd_dashboard,
|
|
add_help=False,
|
|
exit_on_error=False,
|
|
)
|
|
try:
|
|
args, unknown = parser.parse_known_args(argv[1:])
|
|
except (argparse.ArgumentError, ValueError):
|
|
return False
|
|
if unknown:
|
|
return False
|
|
|
|
cmd_dashboard(args)
|
|
return True
|
|
|
|
|
|
def _try_fast_chat_launch() -> bool:
|
|
"""Fast path for unambiguous interactive chat launches (all hosts).
|
|
|
|
Building all ~40 subcommand parsers costs ~140ms the chat path never
|
|
uses. Bails out (False) whenever the invocation is not certainly a chat
|
|
launch — subcommand positional, ``--help``, unknown flags. Mirrors
|
|
``_try_termux_fast_cli_launch`` minus the Termux deferred startup; kept
|
|
separate so phone-tuned behavior doesn't leak to desktops.
|
|
"""
|
|
if os.environ.get("HERMES_DISABLE_FAST_CHAT_LAUNCH") == "1":
|
|
return False
|
|
argv = sys.argv[1:]
|
|
if "-h" in argv or "--help" in argv:
|
|
return False
|
|
# Container routing must win: NixOS container mode forwards EVERY invocation.
|
|
try:
|
|
from hermes_cli.config import get_container_exec_info
|
|
if get_container_exec_info():
|
|
return False
|
|
except Exception:
|
|
return False
|
|
# TUI launches keep full dispatch outside Termux (own startup path).
|
|
if _wants_tui_early(argv):
|
|
return False
|
|
if _first_positional_argv() not in {None, "chat"}:
|
|
return False
|
|
|
|
parser = _light_chat_parser()
|
|
try:
|
|
args, unknown = parser.parse_known_args(_coalesce_session_name_args(argv))
|
|
except SystemExit:
|
|
return False
|
|
if unknown: # plugin subcommand or full-parser-only flag → full dispatch
|
|
return False
|
|
if getattr(args, "version", False):
|
|
return False
|
|
if getattr(args, "command", None) not in {None, "chat"}:
|
|
return False
|
|
|
|
if getattr(args, "yolo", False):
|
|
os.environ["HERMES_YOLO_MODE"] = "1"
|
|
_prepare_agent_startup(args)
|
|
|
|
if getattr(args, "oneshot", None):
|
|
_run_oneshot_from_args(args)
|
|
|
|
_promote_top_level_resume(args)
|
|
_set_chat_arg_defaults(args)
|
|
cmd_chat(args)
|
|
return True
|
|
|
|
|
|
def _try_termux_fast_cli_launch() -> bool:
|
|
"""Run obvious Termux non-TUI chat/oneshot/version paths on a light parser."""
|
|
if not _is_termux_startup_environment():
|
|
return False
|
|
if os.environ.get("HERMES_TERMUX_DISABLE_FAST_CLI") == "1":
|
|
return False
|
|
|
|
argv = sys.argv[1:]
|
|
if "-h" in argv or "--help" in argv:
|
|
return False
|
|
if _wants_tui_early(argv): # TUI fast path / full dispatch owns those
|
|
return False
|
|
|
|
if _startup_fast.is_termux_fast_version_argv(argv):
|
|
_print_version_info(check_updates=True)
|
|
return True
|
|
|
|
first = _first_positional_argv()
|
|
has_oneshot = any(
|
|
arg == "-z" or arg == "--oneshot" or arg.startswith("--oneshot=")
|
|
for arg in argv
|
|
)
|
|
if not has_oneshot and first not in {None, "chat"}:
|
|
return False
|
|
|
|
parser = _light_chat_parser()
|
|
args = parser.parse_args(_coalesce_session_name_args(argv))
|
|
|
|
if getattr(args, "version", False):
|
|
_print_version_info(check_updates=True)
|
|
return True
|
|
|
|
if getattr(args, "oneshot", None):
|
|
_prepare_agent_startup(args)
|
|
_run_oneshot_from_args(args)
|
|
|
|
_promote_top_level_resume(args)
|
|
if args.command in {None, "chat"}:
|
|
_set_chat_arg_defaults(args)
|
|
interactive_prompt = not getattr(args, "query", None) and not getattr(args, "image", None)
|
|
if interactive_prompt:
|
|
# Reach the prompt first; agent-only discovery on the first turn.
|
|
setattr(args, "compact", True)
|
|
os.environ["HERMES_DEFER_AGENT_STARTUP"] = "1"
|
|
os.environ["HERMES_FAST_STARTUP_BANNER"] = "1"
|
|
if getattr(args, "accept_hooks", False):
|
|
os.environ["HERMES_ACCEPT_HOOKS"] = "1"
|
|
else:
|
|
_prepare_agent_startup(args)
|
|
cmd_chat(args)
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _try_termux_fast_tui_launch() -> bool:
|
|
"""Launch obvious Termux TUI invocations before building every subparser.
|
|
|
|
`hermes --tui` is the hot path on phones and the TUI immediately execs
|
|
Node, so the full parser's command-module imports are pure waste there.
|
|
"""
|
|
if not _is_termux_startup_environment():
|
|
return False
|
|
|
|
if "-h" in sys.argv[1:] or "--help" in sys.argv[1:]:
|
|
return False
|
|
|
|
wants_tui = _wants_tui_early(sys.argv[1:])
|
|
if not wants_tui:
|
|
return False
|
|
|
|
first = _first_positional_argv()
|
|
if first not in {None, "chat"}:
|
|
return False
|
|
|
|
parser = _light_chat_parser()
|
|
args = parser.parse_args(_coalesce_session_name_args(sys.argv[1:]))
|
|
|
|
# Preserve top-level behaviours whose semantics are not "launch chat/TUI".
|
|
if getattr(args, "version", False) or getattr(args, "oneshot", None):
|
|
return False
|
|
if getattr(args, "command", None) not in {None, "chat"}:
|
|
return False
|
|
if not _resolve_use_tui(args):
|
|
return False
|
|
|
|
cmd_chat(args)
|
|
return True
|
|
|
|
|
|
def _advertise_agent_env() -> None:
|
|
"""Advertise the agent harness to child processes.
|
|
|
|
``AI_AGENT`` is the cross-agent standard (huggingface_hub reads it); the
|
|
value must be our id in the public agent-harness registry
|
|
(``hermes-agent``) — matching is exact. ``HERMES_AGENT`` is the
|
|
Hermes-specific marker. setdefault: never clobber an outer harness.
|
|
|
|
``AI_AGENT`` is the emerging cross-agent standard (huggingface_hub's agent detection reads it; pi and
|
|
other agents set it — earendil-works/pi#7493) so generic tooling can attribute subprocesses to the
|
|
harness that spawned them. Hermes running inside another agent's terminal).
|
|
"""
|
|
os.environ.setdefault("AI_AGENT", "hermes-agent")
|
|
os.environ.setdefault("HERMES_AGENT", "true")
|
|
|
|
|
|
def _attach_plugin_cli_command(subparsers, cmd_info) -> None:
|
|
"""Register one plugin-provided top-level command from its descriptor."""
|
|
plugin_parser = subparsers.add_parser(
|
|
cmd_info["name"],
|
|
help=cmd_info["help"],
|
|
description=cmd_info.get("description", ""),
|
|
formatter_class=__import__("argparse").RawDescriptionHelpFormatter,
|
|
)
|
|
cmd_info["setup_fn"](plugin_parser)
|
|
if cmd_info.get("handler_fn") is not None:
|
|
plugin_parser.set_defaults(func=cmd_info["handler_fn"])
|
|
|
|
|
|
def _register_plugin_cli_commands(subparsers) -> None:
|
|
"""Register plugin-provided top-level commands (each plugin builds its own argparse tree).
|
|
|
|
Skipped when the invocation targets a known built-in — eagerly importing
|
|
every bundled plugin module costs 500-650ms.
|
|
"""
|
|
if not _plugin_cli_discovery_needed():
|
|
return
|
|
try:
|
|
from plugins.memory import discover_plugin_cli_commands
|
|
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
|
|
|
seen_plugin_commands = set()
|
|
for cmd_info in discover_plugin_cli_commands():
|
|
_attach_plugin_cli_command(subparsers, cmd_info)
|
|
seen_plugin_commands.add(cmd_info["name"])
|
|
|
|
discover_plugins()
|
|
# The invoked platform may still be a deferred entry; import it so its
|
|
# register_cli_command side effect runs before we read _cli_commands.
|
|
# See #54678.
|
|
_resolve_deferred_platform_cli_command(_first_positional_argv())
|
|
for cmd_info in get_plugin_manager()._cli_commands.values():
|
|
if cmd_info["name"] not in seen_plugin_commands:
|
|
_attach_plugin_cli_command(subparsers, cmd_info)
|
|
except Exception as _exc:
|
|
logging.getLogger(__name__).debug("Plugin CLI discovery failed: %s", _exc)
|
|
|
|
|
|
def _cmd_sessions_lazy(args, **kwargs):
|
|
"""``hermes sessions`` handler; sessions_cmd imports only when the subcommand runs."""
|
|
from hermes_cli.sessions_cmd import cmd_sessions
|
|
|
|
return cmd_sessions(args, **kwargs)
|
|
|
|
|
|
def _build_cli_parser():
|
|
"""Build the full ``hermes`` argparse tree -> ``(parser, subparsers)``.
|
|
|
|
Registration ORDER is the ``hermes --help`` order; keep it stable. Groups
|
|
live in ``hermes_cli/subcommands/<group>.py`` with handlers injected so
|
|
those modules never import main.
|
|
"""
|
|
from hermes_cli._parser import build_top_level_parser
|
|
|
|
parser, subparsers, chat_parser = build_top_level_parser()
|
|
chat_parser.set_defaults(func=cmd_chat)
|
|
|
|
build_model_parser(subparsers, cmd_model=cmd_model)
|
|
build_moa_parser(subparsers)
|
|
build_fallback_parser(subparsers)
|
|
build_worktree_parser(subparsers)
|
|
build_browser_parser(subparsers)
|
|
build_secrets_parser(subparsers)
|
|
# OUTBOUND egress firewall; ``hermes proxy`` (gateway group) is the INBOUND one.
|
|
build_egress_parser(subparsers)
|
|
build_migrate_parser(subparsers)
|
|
build_gateway_parser(
|
|
subparsers, cmd_gateway=cmd_gateway, cmd_proxy=cmd_proxy, cmd_gateway_enroll=cmd_gateway_enroll
|
|
)
|
|
|
|
# LSP is optional — a registration failure must not break the CLI.
|
|
try:
|
|
from agent.lsp.cli import register_subparser as _lsp_register
|
|
_lsp_register(subparsers)
|
|
except Exception as _lsp_err: # noqa: BLE001
|
|
logger.debug("LSP CLI registration failed: %s", _lsp_err)
|
|
|
|
build_setup_parser(subparsers, cmd_setup=cmd_setup)
|
|
build_whatsapp_parser(subparsers, cmd_whatsapp=cmd_whatsapp)
|
|
build_whatsapp_cloud_parser(subparsers, cmd_whatsapp_cloud=cmd_whatsapp_cloud)
|
|
build_slack_parser(subparsers, cmd_slack=cmd_slack)
|
|
|
|
from hermes_cli.send_cmd import register_send_subparser
|
|
register_send_subparser(subparsers)
|
|
|
|
build_login_parser(subparsers, cmd_login=cmd_login)
|
|
build_logout_parser(subparsers, cmd_logout=cmd_logout)
|
|
build_auth_parser(subparsers, cmd_auth=cmd_auth)
|
|
build_status_parser(subparsers, cmd_status=cmd_status)
|
|
build_pause_parser(subparsers)
|
|
build_cron_parser(subparsers, cmd_cron=cmd_cron)
|
|
build_sync_parser(subparsers, cmd_sync=cmd_sync)
|
|
build_webhook_parser(subparsers, cmd_webhook=cmd_webhook)
|
|
|
|
from hermes_cli.subcommands.peer import build_peer_parser
|
|
build_peer_parser(subparsers)
|
|
|
|
from hermes_cli.portal_cli import add_parser as _add_portal_parser
|
|
_add_portal_parser(subparsers)
|
|
|
|
from hermes_cli.kanban import build_parser as _build_kanban_parser
|
|
_build_kanban_parser(subparsers).set_defaults(func=cmd_kanban)
|
|
|
|
from hermes_cli.projects_cmd import build_parser as _build_project_parser
|
|
_build_project_parser(subparsers).set_defaults(func=cmd_project)
|
|
|
|
build_hooks_parser(subparsers, cmd_hooks=cmd_hooks)
|
|
build_doctor_parser(subparsers, cmd_doctor=cmd_doctor)
|
|
build_verify_parser(subparsers, cmd_verify=cmd_verify)
|
|
build_security_parser(subparsers, cmd_security=cmd_security)
|
|
build_approvals_parser(subparsers, cmd_approvals=cmd_approvals)
|
|
build_dump_parser(subparsers, cmd_dump=cmd_dump)
|
|
build_debug_parser(subparsers, cmd_debug=cmd_debug)
|
|
build_backup_parser(subparsers, cmd_backup=cmd_backup)
|
|
build_checkpoints_parser(subparsers)
|
|
build_import_cmd_parser(subparsers, cmd_import=cmd_import)
|
|
build_import_agent_parser(subparsers, cmd_import_agent=cmd_import_agent)
|
|
build_config_parser(subparsers, cmd_config=cmd_config)
|
|
build_skin_parser(subparsers, cmd_skin=cmd_skin)
|
|
build_console_parser(subparsers, cmd_console=cmd_console)
|
|
build_pairing_parser(subparsers, cmd_pairing=cmd_pairing)
|
|
build_skills_parser(subparsers, cmd_skills=cmd_skills)
|
|
build_bundles_parser(subparsers)
|
|
build_plugins_parser(subparsers, cmd_plugins=cmd_plugins)
|
|
|
|
_register_plugin_cli_commands(subparsers)
|
|
|
|
build_curator_parser(subparsers)
|
|
build_pets_parser(subparsers)
|
|
build_journey_parser(subparsers)
|
|
build_memory_parser(subparsers, cmd_memory=cmd_memory)
|
|
build_tools_parser(subparsers, cmd_tools=cmd_tools)
|
|
build_computer_use_parser(subparsers)
|
|
build_mcp_parser(subparsers, cmd_mcp=cmd_mcp)
|
|
build_sessions_parser(subparsers, cmd_sessions=_cmd_sessions_lazy)
|
|
build_insights_parser(subparsers, cmd_insights=cmd_insights)
|
|
build_monitoring_parser(subparsers, cmd_monitoring=cmd_monitoring)
|
|
build_claw_parser(subparsers, cmd_claw=cmd_claw)
|
|
build_vault_parser(subparsers)
|
|
build_update_parser(subparsers, cmd_update=cmd_update)
|
|
build_uninstall_parser(subparsers, cmd_uninstall=cmd_uninstall)
|
|
build_acp_parser(subparsers, cmd_acp=cmd_acp)
|
|
build_profile_parser(subparsers, cmd_profile=cmd_profile)
|
|
build_completion_parser(subparsers, cmd_completion=cmd_completion, parser=parser)
|
|
build_dashboard_parser(
|
|
subparsers,
|
|
cmd_dashboard=cmd_dashboard,
|
|
cmd_dashboard_register=cmd_dashboard_register,
|
|
)
|
|
# "desktop" is canonical (Hermes-Setup.exe tells users to run it, so it
|
|
# must be the name --help shows); "gui" is a deprecated alias.
|
|
build_gui_parser(subparsers, cmd_gui=cmd_gui)
|
|
build_logs_parser(subparsers, cmd_logs=cmd_logs)
|
|
build_prompt_size_parser(subparsers, cmd_prompt_size=cmd_prompt_size)
|
|
return parser, subparsers
|
|
|
|
|
|
def _parse_cli_args(parser, subparsers, argv):
|
|
"""Parse ``argv`` with the bpo-9338 subparser-routing workaround.
|
|
|
|
On Python <3.11 argparse fails to route subcommand tokens when the parent
|
|
has nargs='?' optionals (--continue): "unrecognized arguments: model". When
|
|
argv holds a known subcommand token, set subparsers.required=True to force
|
|
routing; if that fails (``hermes -c model`` — 'model' is the session name)
|
|
fall back to the default behaviour.
|
|
"""
|
|
import io as _io
|
|
|
|
_processed_argv = _coalesce_session_name_args(argv)
|
|
_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 not _has_cmd_token:
|
|
subparsers.required = False
|
|
return parser.parse_args(_processed_argv)
|
|
|
|
subparsers.required = True
|
|
_saved_stderr = sys.stderr
|
|
try:
|
|
sys.stderr = _io.StringIO()
|
|
args = parser.parse_args(_processed_argv)
|
|
sys.stderr = _saved_stderr
|
|
except SystemExit as exc:
|
|
sys.stderr = _saved_stderr
|
|
if exc.code == 0: # help/version already printed; don't print twice
|
|
raise
|
|
# Subcommand consumed as a flag value (e.g. -c model): normal parse.
|
|
subparsers.required = False
|
|
args = parser.parse_args(_processed_argv)
|
|
return args
|
|
|
|
|
|
def _default_to_chat(args) -> None:
|
|
"""No subcommand given: run chat."""
|
|
_promote_top_level_resume(args)
|
|
_set_chat_arg_defaults(args)
|
|
cmd_chat(args)
|
|
|
|
|
|
def main():
|
|
"""Main entry point for hermes CLI."""
|
|
_set_process_title()
|
|
_advertise_agent_env()
|
|
|
|
# Force UTF-8 stdio on Windows before anything prints. No-op elsewhere.
|
|
try:
|
|
from hermes_cli.stdio import configure_windows_stdio
|
|
configure_windows_stdio()
|
|
except Exception:
|
|
pass
|
|
|
|
# Sweep stale ``hermes.exe.old.*`` quarantine files from previous Windows
|
|
# updates (see ``_quarantine_running_hermes_exe``). No-op elsewhere.
|
|
try:
|
|
_cleanup_quarantined_exes()
|
|
except Exception:
|
|
pass
|
|
|
|
# Checkout changed since last launch → sweep stale __pycache__ once so no
|
|
# process resolves fresh source against old bytecode. Never raises.
|
|
_sweep_stale_bytecode_if_checkout_changed()
|
|
|
|
# Self-heal a venv left half-built by an interrupted ``hermes update``, and
|
|
# hint (never restart) about a fleet the interrupted update never
|
|
# restarted. Both skipped while the user is *running* update — that flow
|
|
# owns its marker and a recovery install must not race the real one. The
|
|
# substring match is deliberately loose: over-matching (``hermes skills
|
|
# install update``) only defers recovery one launch; under-matching
|
|
# (``hermes -p work update``) would race. Never raises.
|
|
# See #95294.
|
|
if "update" not in sys.argv[1:]:
|
|
try:
|
|
_recover_from_interrupted_install()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
from hermes_cli.update_cmd_fleet import _warn_pending_fleet_restart_on_startup
|
|
|
|
_warn_pending_fleet_restart_on_startup()
|
|
except Exception:
|
|
pass
|
|
|
|
if _try_termux_fast_tui_launch():
|
|
return
|
|
if _try_termux_fast_cli_launch():
|
|
return
|
|
if _try_fast_serve_launch():
|
|
return
|
|
if _try_fast_chat_launch():
|
|
return
|
|
|
|
parser, subparsers = _build_cli_parser()
|
|
|
|
# NixOS container mode routes ALL invocations into the managed container.
|
|
# MUST run before parse_args() so --help, unrecognised flags and every
|
|
# subcommand are forwarded instead of 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:])
|
|
sys.exit(1) # unreachable: execvp replaces the process or raises
|
|
|
|
args = _parse_cli_args(parser, subparsers, sys.argv[1:])
|
|
|
|
if args.version:
|
|
cmd_version(args)
|
|
return
|
|
|
|
# --yolo must be set *before* plugin discovery: tools.approval freezes
|
|
# _YOLO_MODE_FROZEN at import; set later (inside cmd_chat) it does nothing.
|
|
if getattr(args, "yolo", False):
|
|
os.environ["HERMES_YOLO_MODE"] = "1"
|
|
|
|
# Plugin discovery + shell hooks once, gated so introspection commands
|
|
# (hooks list, cron list, gateway status, ...) pay no discovery cost and
|
|
# trigger no consent prompts for hooks the user is still inspecting.
|
|
_prepare_agent_startup(args)
|
|
|
|
if getattr(args, "oneshot", None):
|
|
_run_oneshot_from_args(args)
|
|
|
|
# No subcommand (optionally with top-level --resume / --continue) → chat.
|
|
if args.command is None:
|
|
_default_to_chat(args)
|
|
return
|
|
|
|
# A handler's int return code becomes the exit code (None = success).
|
|
if hasattr(args, "func"):
|
|
rc = args.func(args)
|
|
if isinstance(rc, int) and rc != 0:
|
|
sys.exit(rc)
|
|
else:
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
import hashlib # noqa: F401,E402
|
|
import shlex # noqa: F401,E402
|
|
import stat # noqa: F401,E402
|
|
import tempfile # noqa: F401,E402
|
|
|
|
|
|
_PLUGIN_COMPAT_LAZY = {
|
|
'line_input': ('hermes_cli.cli_output', 'line_input'),
|
|
}
|
|
|
|
_plugin_compat_prev_getattr = __getattr__
|
|
|
|
|
|
def __getattr__(name): # PEP 562 — chained onto the module's own __getattr__
|
|
target = _PLUGIN_COMPAT_LAZY.get(name)
|
|
if target is None:
|
|
return _plugin_compat_prev_getattr(name)
|
|
import importlib
|
|
from hermes_cli.plugin_compat import warn_once
|
|
warn_once(__name__, name, *target)
|
|
return getattr(importlib.import_module(target[0]), target[1])
|
|
# ---- END PLUGIN-COMPAT ----
|