# hermes_cli/ + cli.py — CLI, slash commands, config, skins, updater, profiles Applies on top of the root `AGENTS.md`. Long-form: `website/docs/developer-guide/cli-internals.md`. ## CLI architecture `cli.py` holds `HermesCLI` (REPL loop, config, slash dispatch); behaviour lives in mixins `hermes_cli/cli_commands_mixin.py`, `cli_stream_mixin.py`, `cli_status_bar_mixin.py`, `cli_billing_mixin.py`, `cli_tui_mixin.py` (widgets, keybindings, panels), `cli_tui_runtime_mixin.py` (run-loop phases: input dispatch, startup, signals, shutdown), `cli_init_mixin.py` (the `__init__` phases), ... Module-level helpers live in topical siblings that `cli.py` re-exports: `cli_config_load.py` (defaults + YAML merge, env mirroring), `cli_render.py` (ANSI/skin colours, light mode, markdown, `_cprint`, panel wrap), `cli_terminal_input.py` (file drops, paste/Enter-key sequences, CPR guards), `cli_shutdown.py` (exit watchdog, cleanup steps, one-shot finalize), `cli_single_query.py` (`-q` runner, exit codes, kanban loops), `cli_auto_maintenance.py` (state-db/checkpoint maintenance). Moved bodies late-bind cli-level names via `from cli import ...` at call time, so patch seams on the `cli` facade still intercept them; mutable module state (`_cleanup_done`, `_OUTPUT_HISTORY`, `_LIGHT_MODE_CACHE`, ...) and every `global`-writing function stay in `cli.py`. **Rich** renders banner/panels; **prompt_toolkit** handles input + autocomplete; `KawaiiSpinner` (`agent/display.py`) animates API calls and prints the `┊` activity feed. `load_cli_config()` in `cli.py` merges CLI defaults + user YAML. `hermes_cli/gateway.py` is the `hermes gateway` facade (process discovery, PM-aware systemd unit generation/refresh, command dispatch); topical siblings re-exported by the facade include `gateway_launchd.py` (macOS LaunchAgent backend), `gateway_setup_wizard.py` (`hermes gateway setup`: `_PLATFORMS` registry, status table, per-platform prompts, service offer), `gateway_windows*.py`, `gateway_supervised_restart.py`, `gateway_migrate*.py`, `gateway_multiplex_*.py`, `gateway_enroll.py`, `gateway_command_errors.py`. Sibling bodies read facade names through `_gw()` (late binding on `hermes_cli.gateway`), so monkeypatch on the facade; mutable state such as `_resolved_launchd_domain` stays a facade global. `process_command()` resolves the canonical name via `resolve_command()` then dispatches through `HermesCLI._SLASH_DISPATCH` (`canonical -> (method name, pass_arg)`), falling back to a `_handle__command` method by naming convention. **There is no `elif` ladder — do not add one.** Skill slash commands (`agent/skill_commands.py`) scan `~/.hermes/skills/` and inject as a **user message**, never into the system prompt (prompt caching). Rules: all interactive menu-pickers use curses (`hermes_cli/curses_ui.py`; example `hermes_cli/tools_config.py`). Never emit `\033[K` (ANSI erase-to-EOL) in spinner/display code — it leaks as literal `?[K` under prompt_toolkit's `patch_stdout`; space-pad instead: `f"\r{line}{' ' * pad}"`. Wrapper CLIs extend via the protected hooks in `cli_tui_mixin.py` (`website/docs/developer-guide/extending-the-cli.md`), not by overriding `run()`. ## Slash command registry (`hermes_cli/commands.py`) `COMMAND_REGISTRY` (list of `CommandDef`) is the single source; everything derives from it: CLI dispatch (`resolve_command()`), gateway `GATEWAY_KNOWN_COMMANDS` + dispatch, `gateway_help_lines()`, `telegram_bot_commands()` (BotCommand menu), `slack_subcommand_map()`, `COMMANDS` (autocomplete), `COMMANDS_BY_CATEGORY` (`show_help()`). Fields: `name` (no slash), `description`, `category` (`Session | Configuration | Tools & Skills | Info | Exit`), `aliases` tuple, `args_hint`, `cli_only`, `gateway_only`, `gateway_config_gate` (config dotpath; a `cli_only` command becomes gateway-available when truthy — `GATEWAY_KNOWN_COMMANDS` always includes gated commands so the gateway can dispatch them; help/menus show them only when the gate is open). **Adding a command:** (1) `CommandDef("mycommand", "What it does", "Session", aliases=("mc",), args_hint="[arg]")` in `COMMAND_REGISTRY`; (2) `_handle_mycommand_command(self, cmd_original)` on the relevant `cli_*_mixin.py` (picked up by convention) or an explicit `_SLASH_DISPATCH` entry `"mycommand": ("_handle_mycommand", True)` when the method name/arg-passing differs; (3) for the gateway, `_handle_mycommand_command(self, event)` on the matching `gateway/slash_commands_*.py` mixin and list it in `_IDLE_COMMANDS` (or `_PLAIN_COMMANDS` if it must work mid-run) in `gateway/run_busy.py` — handlers resolve by name via `_command_handler_table`; (4) persistent settings via `save_config_value()` in `cli.py`. **Adding an alias** = add to `aliases`; every surface updates automatically. Commands that mutate system-prompt state default to deferred invalidation with `--now` opt-in (root invariant). ### Shared goal commands `hermes_cli/goal_command.py::dispatch_goal_command` owns `/goal` parsing and manager mutations. CLI, messaging gateway, TUI/Desktop/dashboard and Desktop goal controls all delegate there; adapters only resolve sessions, authorize gate creation, render results, and schedule kickoff/continuation prompts. Async callers preserve ContextVars when running dispatch off-loop (drafting uses profile-scoped auxiliary credentials). Do not add a surface-specific goal parser. ACP has no goal command or goal loop yet. ## Config system (`hermes_cli/config.py`) - **config.yaml option:** add to `DEFAULT_CONFIG`. Bump `_config_version` ONLY to actively migrate/transform existing config (rename keys, restructure); new keys deep-merge automatically. Top-level sections (non-exhaustive): `model, agent, terminal, compression, display, stt, tts, memory, security, delegation, smart_model_routing, checkpoints, auxiliary, curator, skills, gateway, logging, cron, profiles, plugins, honcho`. `auxiliary` = per-task side-LLM overrides (`agent/AGENTS.md`); `curator` = `enabled, interval_hours, min_idle_hours, stale_after_days, archive_after_days, backup.*`. - **.env = SECRETS ONLY** (keys, tokens, passwords): add to `OPTIONAL_ENV_VARS` with `{"description", "prompt", "url", "password": True, "category": provider|tool|messaging|setting}`. Non-secret settings go in config.yaml; if internal code needs an env mirror, bridge it in code (`gateway_timeout`; `terminal.cwd` → `TERMINAL_CWD`). `MESSAGING_CWD` is removed and `TERMINAL_CWD` in `.env` is deprecated — the loader warns; canonical is `terminal.cwd`. `hermes config set/get/unset ` route any bare name registered in `OPTIONAL_ENV_VARS` / `_EXTRA_ENV_KEYS` (or carrying a `setup_hidden_env` platform suffix) to `.env` via `config_env_routing.py` — the file the platform setup flows write — never to the top level of config.yaml. - **One writer seam.** Every write of a `config.yaml` (main or profile) goes through `hermes_cli.config.atomic_config_write` (refuses deletion by omission) or the explicit `atomic_config_replace` full-state path (→ `utils.atomic_roundtrip_yaml_save`, ruamel round-trip): comments, key order, quoting and blank lines survive, and the fail-closed unreadable-file guard runs first. Deliberate `pop()`/unset/migration paths use `atomic_config_replace`; additive writers stay on `atomic_config_write`. Never call `atomic_yaml_write` / `yaml.dump` / `yaml.safe_dump` on a config path — `scripts/check_config_yaml_writers.py` (CI lint) rejects it, and `tests/hermes_cli/test_config_yaml_comment_preservation.py` guards each path (#92554). The commented example blocks are appended only when the file is created. - **Three loaders — know which you're in:** `load_cli_config()` (CLI, `cli.py`); `load_config()` (`hermes tools/setup`, most subcommands, `hermes_cli/config.py`, merges `DEFAULT_CONFIG`); `hermes_cli/config_effective.py::load_user_config_effective()` (gateway runtime via `gateway/run.py::_load_gateway_config`, TUI gateway `_load_cfg`, cron, `hermes send`, doctor, `hermes_time`/`hermes_logging`: user file + managed overlay + `${VAR}` expansion + model-key canon, NO defaults — for presence-sensitive readers). If the CLI sees a key and the gateway doesn't (or vice versa), you're on the wrong loader — check `DEFAULT_CONFIG` coverage. Never hand-roll raw-read → overlay → expand; `read_user_config_raw` is for write-back round-trips only. - **Every `DEFAULT_CONFIG` key has a runtime reader, and every reader a registry entry.** Both drift modes are silent: a registered key nothing reads (a knob that does nothing) and a reader of a key never registered (never shown, never migrated; new roots also go in `_KNOWN_ROOT_KEYS`). For a new key: `rg -n '""' hermes_cli/config_defaults.py` AND `rg -n '' --glob '!tests' .` both hit, and one invariant test sets it in a temp `config.yaml` and asserts the behaviour through the loader the consuming surface uses. Under multiplex the process env never overrides a profile's YAML. - **Working directory:** CLI uses `os.getcwd()`; messaging uses `terminal.cwd`, bridged to `TERMINAL_CWD` for child tools. ## Skin engine (`hermes_cli/skin_engine.py`) Skins are **pure data** (`SkinConfig`); no code change to add one. `init_skin_from_config()` reads `display.skin` at startup; `get_active_skin()` (cached), `set_active_skin(name)` (`/skin`), `load_skin(name)` (user `~/.hermes/skins/*.yaml` → built-ins → default; missing values inherit from `default`). Built-ins in `_BUILTIN_SKINS`: `default`, `ares`, `mono`, `slate`. Keys: `colors.*` (banner border/title/accent/dim/text, response_border), `spinner.*` (waiting/thinking faces, thinking_verbs, wings), `tool_prefix`, `tool_emojis`, `branding.*` (agent_name, welcome, response_label, prompt_symbol). Consumers: `banner.py`, `display.py`, `cli.py`. Key-by-key table and YAML template: `website/docs/user-guide/features/skins.md`. ## Update pipeline (`hermes update`) Transactional, `plan → snapshot → apply → restart-per-kind → verify → report`; every stage guards a real field failure (fleet campaign #91277), and a PR that weakens a stage answers for that class. The stage-by-stage contract: `website/docs/developer-guide/cli-internals.md` § Update pipeline. - Snapshots cover EVERY profile with one identical file set (never partial or tiered); they are file-loss recovery, `--backup` owns rollback. - The Windows ZIP fallback fires only when git itself failed, refuses a dirty tree, and grafts the `_ZIP_PRESERVED_NESTED` build outputs into the swap. - Restarts are fleet-wide and drain-first, bounded by the home each unit runs on (`update_fleet_scope.py`); another install's runtime is never restarted. - Post-commit failures are owed follow-ups, exit 0 (stash left 1, Ctrl-C 130); receipts in ROOT `logs/update_receipts/`. - Nothing runs pulled code in the pre-pull interpreter. `update_handoff.py` and `update_serve_obligations.py` are a FROZEN compat surface (`tests/compat/old_updater_surface.json`): removing a name bricks releases mid-update. - Read #92091 (gateway-owned control socket) before adding any process-scan heuristic. ## Profiles (multi-instance) `_apply_profile_override()` in `hermes_cli/main.py` sets `HERMES_HOME` before any module import for single-profile commands (`hermes -p x `), so there `get_hermes_home()` scopes to the active profile. The multiplex gateway and the Desktop/dashboard `serve` backend instead bind the active profile per activity via a contextvar override while `os.environ["HERMES_HOME"]` keeps the launch profile — a module constant or import-time read there freezes to the launch profile (rules in root). Profiles are independent islands by design — no live config inheritance; `--clone` copies at creation, minus messaging channels (`profile_channels.py`: ownership-based inventory evaluated in the SOURCE's plugin scope — adapter-declared keys + canonical/alias prefixes + `GATEWAY_ALLOW*`/`GATEWAY_RELAY_*`; prefixes shared with tools (`TWILIO_`/`EMAIL_`, plus a plugin platform's `shared_env_prefixes`) are stripped only when the source runs that adapter; never a hand list; a platform that left core keeps its ownership from its `LEFT_CORE` row while the plugin is absent). `--clone-channels` opts in and its live-multiplexer refusal lives in `create_profile` (CLI, REST and TUI all go through it). Clones are built in `profiles/..staging-` (hidden → invisible to `_iter_named_profile_dirs` and the hot-serve rescan) and published by one `os.rename` after the strip; symlinked `.env`/`config.yaml` are materialized first so a clone never writes through to its source. Multiplex (`gateway.multiplex_profiles`) secret-scope rules: `gateway/AGENTS.md`. The served set is `profiles.py::profiles_to_serve(multiplex=True)` = default + every live dir under `profiles/` — live = carries an identity marker (`hermes_constants.named_profile_has_identity`: `config.yaml`/`.env`/`SOUL.md`/ `profile.yaml`/`auth.json`/`state.db`) and is not tombstoned. A marker-less dir (cron/log side-effect shell, stray infrastructure dir) is never listed, served, ticked, `.env`-backfilled or resolvable via `-p` (#95188, #99392); `profile create` replaces it only when it is also tombstoned (a live marker-less dir may hold user files — fail closed, never rmtree). A dangling symlinked marker still counts as identity (`is_symlink()`). `tools/bot_mode_probe._roster` (Bot Mode teammate roster, `bot_relay.deliver` target check) applies the same predicate. There is no allowlist (`gateway.multiplex_profile_allowlist` was retired in config v43). Enumeration is a pure read: never `mkdir` a profile home from a served path (`SessionDB`, logging, cron all go through `mkdir_under_hermes_home` / `_ensure_cron_dir`, which refuse a deleted or missing named profile, #94590). Process-global per-profile slots (MCP discovery in `mcp_startup.py`, tool registry overlays) key on `hermes_constants.hermes_home_key()`, never a single flag. `gateway.multiplex_profiles` defaults to **on**, but `GatewayConfig` keeps an unset flag `None` and `gateway_multiplex_mode.resolve_multiplex_mode` settles it once per boot (called from `load_gateway_config_for_runner`): default profile, >= 2 profiles, no standalone secondary gateway, no preflight blocker, migratable host → `True`; else `False` + a logged reason. Explicit values pass through. CLI/dashboard readers use `default_gateway_multiplexes` (live `served_profiles` record, then the explicit flag) — never the merged default, which would guess a verdict only the gateway makes. Migration from per-profile gateways: `hermes_cli/gateway_migrate.py` (`hermes gateway migrate --multiplex`, the only mode — `--standalone` is deleted and a per-profile fleet is not a supported target; table-driven `_PREFLIGHT_CHECKS`; manifest `/gateway_migration.json` = UNFINISHED, a re-run resumes from it; a named profile's `gateway install|start|run` refuse without `--force` via `gateway.py::_named_profile_refused_under_multiplexer`, dashboard twin `web_server_gateway.py::multiplexed_profile_refusal`); `update_cmd_fleet_verify._verify_fleet_after_update` calls `maybe_auto_migrate_after_update` on the success path only; `gateway_migrate_guards.py` holds the auto-path-only refusals (table `_AUTO_MIGRATION_GUARDS`: other service domain / UNIX user / HERMES_HOME outside `profiles/` — notices for the explicit command, blockers for the hook) and the `gateway.auto_multiplex_migration` opt-out (#109954). Blockers reuse `GatewayRunner._adapter_credential_fingerprint` and `platform_binds_port`; "has a `/p//` ingress" is the adapter class attribute `serves_profile_prefix` — set it on a new HTTP-inbound adapter when it answers the prefix, never extend a list here. `hermes gateway restart` for a gateway Hermes did not install (custom launchd agent / unit running `gateway run --external-supervisor`): `gateway_supervised_restart.py` — the gateway's SELF-declared supervisor (control-socket `identify` answering anything but `manual`, OR the argv marker) decides; hand back via SIGUSR1 and wait for a fresh supervised PID, never stop + foreground `run_gateway` (that stamps the CLI's PID and wedges every KeepAlive respawn, #110637). Service installs are a matrix, not a unit file: `gateway.py::generate_systemd_unit(system=, run_as_user=)` (systemd unit generation / `systemd_unit_is_current` / `refresh_systemd_unit_if_needed` are PM-aware facade owners; user unit AND `--system` unit with `User=`; an unresolvable `User=` is a blocker, never a dir-owner fallback), `gateway_launchd.py::generate_launchd_plist` (`gui/` then `user/` domains, never a `~/Library/LaunchAgents` glob; the whole launchd backend — plist refresh, `launchctl` bootstrap/kickstart, `launchd_start/stop/restart/status`, detached-process degrade — lives in that sibling, with the domain cache `_resolved_launchd_domain` staying a facade global), Windows Scheduled Task and the Desktop-spawned backend all carry the profile's `HERMES_HOME` (and `HOME` for the service user) explicitly — a supervisor starts with an empty environment, so the env override that makes `-p` work interactively does not exist there. A change to install/restart/status regenerates and diffs every kind; both user and system units are recorded when both exist. Process liveness is `(pid, start_time)` or the canonical matchers (`gateway.status.live_gateway_pid_for_home`), never bare PID existence. ## Nous free tier (`hermes_cli/anon_auth.py`) `anon_auth.run_sign_in()` is the one sign-in composition: it yields `SignInState`s that carry their own `.copy` / `.copy_terminal`, persists only after a completed promotion AND a token grant, and runs `settle_after_upgrade` exactly once per completion. `hermes auth upgrade`, the CLI and gateway `/login` and the desktop poller are renderers over it; the desktop's plain device-code login (`_nous_plain_poller`) stays a separate path. Long form: `cli-internals.md` § Nous free tier sign-in.