refactor(cli): extract 25 more subcommand parsers into hermes_cli/subcommands/

Batch extraction of every remaining subcommand whose handler is top-level and
whose parser block is pure argparse: model, setup, postinstall, whatsapp, slack,
login, logout, auth, status, webhook, hooks, doctor, security, dump, debug,
backup, import, config, version, update, uninstall, dashboard, gui, logs,
prompt-size.

Each becomes hermes_cli/subcommands/<name>.py with build_<name>_parser() and an
injected handler (no main import). dashboard also injects cmd_dashboard_register
for its nested 'register' action.

Behavior-neutral: all 25 subcommands' --help output (and nested subaction help)
diff-verified byte-identical to pre-extraction. Two RawDescriptionHelpFormatter
epilogs (debug, logs) needed their multi-line string interiors preserved at
column 0 — caught by the --help diff, not compile.

main() 3297 -> 1798 LOC across this PR; add_parser calls in main.py 179 -> 89.

Validation: tests/hermes_cli/ 6476 passed / 0 failed under per-file process
isolation; new test_subcommands_batch.py smoke-tests all 25 builders + the
dashboard two-handler case.
This commit is contained in:
teknium1 2026-06-07 21:50:44 -07:00 committed by Teknium
parent 4da45e8727
commit 568e127612
27 changed files with 1541 additions and 984 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,109 @@
"""``hermes auth`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None:
"""Attach the ``auth`` subcommand to ``subparsers``."""
auth_parser = subparsers.add_parser(
"auth",
help="Manage pooled provider credentials",
)
auth_subparsers = auth_parser.add_subparsers(dest="auth_action")
auth_add = auth_subparsers.add_parser("add", help="Add a pooled credential")
auth_add.add_argument(
"provider",
help="Provider id (for example: anthropic, openai-codex, openrouter)",
)
auth_add.add_argument(
"--type",
dest="auth_type",
choices=["oauth", "api-key", "api_key"],
help="Credential type to add",
)
auth_add.add_argument("--label", help="Optional display label")
auth_add.add_argument(
"--api-key", help="API key value (otherwise prompted securely)"
)
auth_add.add_argument("--portal-url", help="Nous portal base URL")
auth_add.add_argument("--inference-url", help="Nous inference base URL")
auth_add.add_argument("--client-id", help="OAuth client id")
auth_add.add_argument("--scope", help="OAuth scope override")
auth_add.add_argument(
"--no-browser",
action="store_true",
help="Do not auto-open a browser for OAuth login",
)
auth_add.add_argument(
"--manual-paste",
action="store_true",
help=(
"Skip the loopback callback listener and paste the failed "
"callback URL from your browser instead. Use this on "
"browser-only remotes (GCP Cloud Shell, GitHub Codespaces, "
"EC2 Instance Connect, ...) where 127.0.0.1 on the remote "
"isn't reachable from your laptop. See #26923."
),
)
auth_add.add_argument(
"--timeout", type=float, help="OAuth/network timeout in seconds"
)
auth_add.add_argument(
"--insecure",
action="store_true",
help="Disable TLS verification for OAuth login",
)
auth_add.add_argument("--ca-bundle", help="Custom CA bundle for OAuth login")
auth_list = auth_subparsers.add_parser("list", help="List pooled credentials")
auth_list.add_argument("provider", nargs="?", help="Optional provider filter")
auth_remove = auth_subparsers.add_parser(
"remove", help="Remove a pooled credential by index, id, or label"
)
auth_remove.add_argument("provider", help="Provider id")
auth_remove.add_argument(
"target", help="Credential index, entry id, or exact label"
)
auth_reset = auth_subparsers.add_parser(
"reset", help="Clear exhaustion status for all credentials for a provider"
)
auth_reset.add_argument("provider", help="Provider id")
auth_status = auth_subparsers.add_parser(
"status", help="Show auth status for a provider"
)
auth_status.add_argument("provider", help="Provider id")
auth_logout = auth_subparsers.add_parser(
"logout", help="Log out a provider and clear stored auth state"
)
auth_logout.add_argument("provider", help="Provider id")
auth_spotify = auth_subparsers.add_parser(
"spotify", help="Authenticate Hermes with Spotify via PKCE"
)
auth_spotify.add_argument(
"spotify_action",
nargs="?",
choices=["login", "status", "logout"],
default="login",
)
auth_spotify.add_argument(
"--client-id", help="Spotify app client_id (or set HERMES_SPOTIFY_CLIENT_ID)"
)
auth_spotify.add_argument(
"--redirect-uri",
help="Allow-listed localhost redirect URI for your Spotify app",
)
auth_spotify.add_argument("--scope", help="Override requested Spotify scopes")
auth_spotify.add_argument(
"--no-browser",
action="store_true",
help="Do not attempt to open the browser automatically",
)
auth_spotify.add_argument(
"--timeout", type=float, help="Callback/token exchange timeout in seconds"
)
auth_parser.set_defaults(func=cmd_auth)

View file

@ -0,0 +1,38 @@
"""``hermes backup`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_backup_parser(subparsers, *, cmd_backup: Callable) -> None:
"""Attach the ``backup`` subcommand to ``subparsers``."""
# =========================================================================
# backup command
# =========================================================================
backup_parser = subparsers.add_parser(
"backup",
help="Back up Hermes home directory to a zip file",
description="Create a zip archive of your entire Hermes configuration, "
"skills, sessions, and data (excludes the hermes-agent codebase). "
"Use --quick for a fast snapshot of just critical state files.",
)
backup_parser.add_argument(
"-o",
"--output",
help="Output path for the zip file (default: ~/hermes-backup-<timestamp>.zip)",
)
backup_parser.add_argument(
"-q",
"--quick",
action="store_true",
help="Quick snapshot: only critical state files (config, state.db, .env, auth, cron)",
)
backup_parser.add_argument(
"-l", "--label", help="Label for the snapshot (only used with --quick)"
)
backup_parser.set_defaults(func=cmd_backup)

View file

@ -0,0 +1,49 @@
"""``hermes config`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_config_parser(subparsers, *, cmd_config: Callable) -> None:
"""Attach the ``config`` subcommand to ``subparsers``."""
# =========================================================================
# config command
# =========================================================================
config_parser = subparsers.add_parser(
"config",
help="View and edit configuration",
description="Manage Hermes Agent configuration",
)
config_subparsers = config_parser.add_subparsers(dest="config_command")
# config show (default)
config_subparsers.add_parser("show", help="Show current configuration")
# config edit
config_subparsers.add_parser("edit", help="Open config file in editor")
# config set
config_set = config_subparsers.add_parser("set", help="Set a configuration value")
config_set.add_argument(
"key", nargs="?", help="Configuration key (e.g., model, terminal.backend)"
)
config_set.add_argument("value", nargs="?", help="Value to set")
# config path
config_subparsers.add_parser("path", help="Print config file path")
# config env-path
config_subparsers.add_parser("env-path", help="Print .env file path")
# config check
config_subparsers.add_parser("check", help="Check for missing/outdated config")
# config migrate
config_subparsers.add_parser("migrate", help="Update config with new options")
config_parser.set_defaults(func=cmd_config)

View file

@ -0,0 +1,123 @@
"""``hermes dashboard`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
import argparse
from typing import Callable
def build_dashboard_parser(
subparsers, *, cmd_dashboard: Callable, cmd_dashboard_register: Callable
) -> None:
"""Attach the ``dashboard`` subcommand (and its ``register`` action)."""
# =========================================================================
# dashboard command
# =========================================================================
dashboard_parser = subparsers.add_parser(
"dashboard",
help="Start the web UI dashboard",
description="Launch the Hermes Agent web dashboard for managing config, API keys, and sessions",
)
dashboard_parser.add_argument(
"--port", type=int, default=9119, help="Port (default 9119)"
)
dashboard_parser.add_argument(
"--host", default="127.0.0.1", help="Host (default 127.0.0.1)"
)
dashboard_parser.add_argument(
"--no-open", action="store_true", help="Don't open browser automatically"
)
dashboard_parser.add_argument(
"--insecure",
action="store_true",
help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)",
)
dashboard_parser.add_argument(
"--skip-build",
action="store_true",
help=(
"Skip the web UI build step and serve the existing dist directly. "
"Useful for non-interactive contexts (Windows Scheduled Tasks, CI) "
"where npm may not be available. Pre-build with: cd web && npm run build"
),
)
# Lifecycle flags — mutually exclusive with each other and with the
# start-a-server flags above (if both are passed, --stop / --status win
# because they exit before the server is started). The dashboard has
# no service manager and no PID file, so these scan the process table
# for `hermes dashboard` cmdlines and SIGTERM them directly — the same
# path `hermes update` uses to clean up stale dashboards.
dashboard_parser.add_argument(
"--stop",
action="store_true",
help="Stop all running hermes dashboard processes and exit",
)
dashboard_parser.add_argument(
"--status",
action="store_true",
help="List running hermes dashboard processes and exit",
)
# Backward-compat shim: older Hermes desktop app shells (<= 0.15.x) spawn the
# backend as `hermes dashboard --no-open --tui --host ... --port ...`. The
# `--tui` flag was removed from this subcommand in cae6b5486 (embedded chat is
# always on now). When a user's CLI updates past that commit but their desktop
# app binary has not, argparse used to hard-error with "unrecognized arguments:
# --tui" and exit(2) — the backend died before becoming ready and the GUI just
# showed "Hermes couldn't start" with no actionable cause. Accept and silently
# ignore the flag so an old app + new CLI degrades gracefully instead of
# bricking. Hidden from --help; safe to delete once the floor app version is
# well past 0.16.0.
dashboard_parser.add_argument(
"--tui",
action="store_true",
help=argparse.SUPPRESS,
)
dashboard_parser.set_defaults(func=cmd_dashboard)
# `hermes dashboard register` — register a self-hosted dashboard OAuth
# client with Nous Portal and write the client_id into ~/.hermes/.env.
# Nested subparser so bare `hermes dashboard` keeps launching the server
# (set_defaults(func=cmd_dashboard) above remains the default).
dashboard_subparsers = dashboard_parser.add_subparsers(
dest="dashboard_subcommand"
)
dashboard_register_parser = dashboard_subparsers.add_parser(
"register",
help="Register a self-hosted dashboard with Nous Portal (writes the OAuth client ID to .env)",
description=(
"Register this install as a self-hosted dashboard with your Nous "
"Portal account. Creates an OAuth client, writes "
"HERMES_DASHBOARD_OAUTH_CLIENT_ID into ~/.hermes/.env, and prints "
"how to engage the login gate. Requires being logged in (hermes setup)."
),
)
dashboard_register_parser.add_argument(
"--name",
default=None,
help="Human-readable label for the dashboard (default: an auto-generated name)",
)
dashboard_register_parser.add_argument(
"--redirect-uri",
dest="redirect_uri",
default=None,
help=(
"Optional public HTTPS OAuth redirect URI for the dashboard, e.g. "
"https://hermes.example.com/auth/callback. Omit for localhost-only use."
),
)
dashboard_register_parser.add_argument(
"--portal-url",
dest="portal_url",
default=None,
help=(
"Override the Nous Portal base URL for registration (default: the "
"portal you logged into). The access token must be valid at this "
"portal. Also settable via HERMES_DASHBOARD_PORTAL_URL. Mainly for "
"testing against a staging/preview portal."
),
)
dashboard_register_parser.set_defaults(func=cmd_dashboard_register)

View file

@ -0,0 +1,77 @@
"""``hermes debug`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
import argparse
from typing import Callable
def build_debug_parser(subparsers, *, cmd_debug: Callable) -> None:
"""Attach the ``debug`` subcommand to ``subparsers``."""
# =========================================================================
# debug command
# =========================================================================
debug_parser = subparsers.add_parser(
"debug",
help="Debug tools — upload logs and system info for support",
description="Debug utilities for Hermes Agent. Use 'hermes debug share' to "
"upload a debug report (system info + recent logs) to a paste "
"service and get a shareable URL.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
Examples:
hermes debug share Upload debug report and print URL
hermes debug share --lines 500 Include more log lines
hermes debug share --expire 30 Keep paste for 30 days
hermes debug share --local Print report locally (no upload)
hermes debug share --no-redact Disable upload-time secret redaction
hermes debug delete <url> Delete a previously uploaded paste
""",
)
debug_sub = debug_parser.add_subparsers(dest="debug_command")
share_parser = debug_sub.add_parser(
"share",
help="Upload debug report to a paste service and print a shareable URL",
)
share_parser.add_argument(
"--lines",
type=int,
default=200,
help="Number of log lines to include per log file (default: 200)",
)
share_parser.add_argument(
"--expire",
type=int,
default=7,
help="Paste expiry in days (default: 7)",
)
share_parser.add_argument(
"--local",
action="store_true",
help="Print the report locally instead of uploading",
)
share_parser.add_argument(
"--no-redact",
action="store_true",
help=(
"Disable upload-time secret redaction (default: redact). Logs "
"are normally run through agent.redact.redact_sensitive_text "
"with force=True before upload so credentials are not leaked "
"into the public paste service."
),
)
delete_parser = debug_sub.add_parser(
"delete",
help="Delete a paste uploaded by 'hermes debug share'",
)
delete_parser.add_argument(
"urls",
nargs="*",
default=[],
help="One or more paste URLs to delete (e.g. https://paste.rs/abc123)",
)
debug_parser.set_defaults(func=cmd_debug)

View file

@ -0,0 +1,35 @@
"""``hermes doctor`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_doctor_parser(subparsers, *, cmd_doctor: Callable) -> None:
"""Attach the ``doctor`` subcommand to ``subparsers``."""
# =========================================================================
# doctor command
# =========================================================================
doctor_parser = subparsers.add_parser(
"doctor",
help="Check configuration and dependencies",
description="Diagnose issues with Hermes Agent setup",
)
doctor_parser.add_argument(
"--fix", action="store_true", help="Attempt to fix issues automatically"
)
doctor_parser.add_argument(
"--ack",
metavar="ADVISORY_ID",
default=None,
help=(
"Acknowledge a security advisory by ID and exit. After ack, the "
"advisory will no longer trigger startup banners. Run `hermes "
"doctor` first to see active advisories and their IDs."
),
)
doctor_parser.set_defaults(func=cmd_doctor)

View file

@ -0,0 +1,28 @@
"""``hermes dump`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_dump_parser(subparsers, *, cmd_dump: Callable) -> None:
"""Attach the ``dump`` subcommand to ``subparsers``."""
# =========================================================================
# dump command
# =========================================================================
dump_parser = subparsers.add_parser(
"dump",
help="Dump setup summary for support/debugging",
description="Output a compact, plain-text summary of your Hermes setup "
"that can be copy-pasted into Discord/GitHub for support context",
)
dump_parser.add_argument(
"--show-keys",
action="store_true",
help="Show redacted API key prefixes (first/last 4 chars) instead of just set/not set",
)
dump_parser.set_defaults(func=cmd_dump)

View file

@ -0,0 +1,63 @@
"""``hermes gui`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_gui_parser(subparsers, *, cmd_gui: Callable) -> None:
"""Attach the ``gui`` subcommand to ``subparsers``."""
# =========================================================================
gui_parser = subparsers.add_parser(
"desktop",
aliases=["gui"],
help="Build and launch the native desktop app",
description=(
"Launch the Hermes Electron desktop app. By default this installs "
"workspace Node dependencies, builds the current OS's unpacked "
"Electron app, then launches that packaged artifact."
),
)
gui_parser.add_argument(
"--source",
action="store_true",
help="Launch via `electron .` against apps/desktop/dist instead of the packaged app",
)
gui_parser.add_argument(
"--build-only",
action="store_true",
help="Build the desktop app but do not launch it (used by the installer's --update flow)",
)
gui_parser.add_argument(
"--fake-boot",
action="store_true",
help="Enable deterministic desktop boot delays for validating startup UI",
)
gui_parser.add_argument(
"--ignore-existing",
action="store_true",
help="Force Desktop to ignore any hermes CLI already on PATH during backend resolution",
)
gui_parser.add_argument(
"--hermes-root",
help="Override the Hermes source root used by Desktop (sets HERMES_DESKTOP_HERMES_ROOT)",
)
gui_parser.add_argument(
"--cwd",
help="Initial project directory for Desktop chat sessions (sets HERMES_DESKTOP_CWD)",
)
gui_parser.add_argument(
"--skip-build",
action="store_true",
help="Skip npm install/package and launch the existing unpacked app from apps/desktop/release",
)
gui_parser.add_argument(
"--force-build",
action="store_true",
help="Force a full rebuild even if the content stamp matches",
)
gui_parser.set_defaults(func=cmd_gui)

View file

@ -0,0 +1,77 @@
"""``hermes hooks`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_hooks_parser(subparsers, *, cmd_hooks: Callable) -> None:
"""Attach the ``hooks`` subcommand to ``subparsers``."""
# =========================================================================
hooks_parser = subparsers.add_parser(
"hooks",
help="Inspect and manage shell-script hooks",
description=(
"Inspect shell-script hooks declared in ~/.hermes/config.yaml, "
"test them against synthetic payloads, and manage the first-use "
"consent allowlist at ~/.hermes/shell-hooks-allowlist.json."
),
)
hooks_subparsers = hooks_parser.add_subparsers(dest="hooks_action")
hooks_subparsers.add_parser(
"list",
aliases=["ls"],
help="List configured hooks with matcher, timeout, and consent status",
)
_hk_test = hooks_subparsers.add_parser(
"test",
help="Fire every hook matching <event> against a synthetic payload",
)
_hk_test.add_argument(
"event",
help="Hook event name (e.g. pre_tool_call, pre_llm_call, subagent_stop)",
)
_hk_test.add_argument(
"--for-tool",
dest="for_tool",
default=None,
help=(
"Only fire hooks whose matcher matches this tool name "
"(used for pre_tool_call / post_tool_call)"
),
)
_hk_test.add_argument(
"--payload-file",
dest="payload_file",
default=None,
help=(
"Path to a JSON file whose contents are merged into the "
"synthetic payload before execution"
),
)
_hk_revoke = hooks_subparsers.add_parser(
"revoke",
aliases=["remove", "rm"],
help="Remove a command's allowlist entries (takes effect on next restart)",
)
_hk_revoke.add_argument(
"command",
help="The exact command string to revoke (as declared in config.yaml)",
)
hooks_subparsers.add_parser(
"doctor",
help=(
"Check each configured hook: exec bit, allowlist, mtime drift, "
"JSON validity, and synthetic run timing"
),
)
hooks_parser.set_defaults(func=cmd_hooks)

View file

@ -0,0 +1,31 @@
"""``hermes import`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_import_cmd_parser(subparsers, *, cmd_import: Callable) -> None:
"""Attach the ``import`` subcommand to ``subparsers``."""
# =========================================================================
# import command
# =========================================================================
import_parser = subparsers.add_parser(
"import",
help="Restore a Hermes backup from a zip file",
description="Extract a previously created Hermes backup into your "
"Hermes home directory, restoring configuration, skills, "
"sessions, and data",
)
import_parser.add_argument("zipfile", help="Path to the backup zip file")
import_parser.add_argument(
"--force",
"-f",
action="store_true",
help="Overwrite existing files without confirmation",
)
import_parser.set_defaults(func=cmd_import)

View file

@ -0,0 +1,58 @@
"""``hermes login`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_login_parser(subparsers, *, cmd_login: Callable) -> None:
"""Attach the ``login`` subcommand to ``subparsers``."""
# =========================================================================
# login command
# =========================================================================
login_parser = subparsers.add_parser(
"login",
help="Authenticate with an inference provider",
description="Run OAuth device authorization flow for Hermes CLI",
)
login_parser.add_argument(
"--provider",
choices=["nous", "openai-codex", "xai-oauth"],
default=None,
help="Provider to authenticate with (default: nous)",
)
login_parser.add_argument(
"--portal-url", help="Portal base URL (default: production portal)"
)
login_parser.add_argument(
"--inference-url",
help="Inference API base URL (default: production inference API)",
)
login_parser.add_argument(
"--client-id", default=None, help="OAuth client id to use (default: hermes-cli)"
)
login_parser.add_argument("--scope", default=None, help="OAuth scope to request")
login_parser.add_argument(
"--no-browser",
action="store_true",
help="Do not attempt to open the browser automatically",
)
login_parser.add_argument(
"--timeout",
type=float,
default=15.0,
help="HTTP request timeout in seconds (default: 15)",
)
login_parser.add_argument(
"--ca-bundle", help="Path to CA bundle PEM file for TLS verification"
)
login_parser.add_argument(
"--insecure",
action="store_true",
help="Disable TLS verification (testing only)",
)
login_parser.set_defaults(func=cmd_login)

View file

@ -0,0 +1,28 @@
"""``hermes logout`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_logout_parser(subparsers, *, cmd_logout: Callable) -> None:
"""Attach the ``logout`` subcommand to ``subparsers``."""
# =========================================================================
# logout command
# =========================================================================
logout_parser = subparsers.add_parser(
"logout",
help="Clear authentication for an inference provider",
description="Remove stored credentials and reset provider config",
)
logout_parser.add_argument(
"--provider",
choices=["nous", "openai-codex", "xai-oauth", "spotify"],
default=None,
help="Provider to log out from (default: active provider)",
)
logout_parser.set_defaults(func=cmd_logout)

View file

@ -0,0 +1,78 @@
"""``hermes logs`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
import argparse
from typing import Callable
def build_logs_parser(subparsers, *, cmd_logs: Callable) -> None:
"""Attach the ``logs`` subcommand to ``subparsers``."""
# =========================================================================
# logs command
# =========================================================================
logs_parser = subparsers.add_parser(
"logs",
help="View and filter Hermes log files",
description="View, tail, and filter agent.log / errors.log / gateway.log / gui.log / desktop.log",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
Examples:
hermes logs Show last 50 lines of agent.log
hermes logs -f Follow agent.log in real time
hermes logs errors Show last 50 lines of errors.log
hermes logs gateway -n 100 Show last 100 lines of gateway.log
hermes logs gui -f Follow gui.log in real time
hermes logs desktop -f Follow desktop.log (Electron app boot/backend)
hermes logs --level WARNING Only show WARNING and above
hermes logs --session abc123 Filter by session ID
hermes logs --component tools Only show tool-related lines
hermes logs --since 1h Lines from the last hour
hermes logs --since 30m -f Follow, starting from 30 min ago
hermes logs list List available log files with sizes
""",
)
logs_parser.add_argument(
"log_name",
nargs="?",
default="agent",
help="Log to view: agent (default), errors, gateway, gui, or 'list' to show available files",
)
logs_parser.add_argument(
"-n",
"--lines",
type=int,
default=50,
help="Number of lines to show (default: 50)",
)
logs_parser.add_argument(
"-f",
"--follow",
action="store_true",
help="Follow the log in real time (like tail -f)",
)
logs_parser.add_argument(
"--level",
metavar="LEVEL",
help="Minimum log level to show (DEBUG, INFO, WARNING, ERROR)",
)
logs_parser.add_argument(
"--session",
metavar="ID",
help="Filter lines containing this session ID substring",
)
logs_parser.add_argument(
"--since",
metavar="TIME",
help="Show lines since TIME ago (e.g. 1h, 30m, 2d)",
)
logs_parser.add_argument(
"--component",
metavar="NAME",
help="Filter by component: gateway, agent, tools, cli, cron, gui",
)
logs_parser.set_defaults(func=cmd_logs)

View file

@ -0,0 +1,72 @@
"""``hermes model`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_model_parser(subparsers, *, cmd_model: Callable) -> None:
"""Attach the ``model`` subcommand to ``subparsers``."""
# =========================================================================
# model command
# =========================================================================
model_parser = subparsers.add_parser(
"model",
help="Select default model and provider",
description="Interactively select your inference provider and default model",
)
model_parser.add_argument(
"--refresh",
action="store_true",
help="Wipe the model picker disk cache and re-fetch every provider's live /v1/models list.",
)
model_parser.add_argument(
"--portal-url",
help="Portal base URL for Nous login (default: production portal)",
)
model_parser.add_argument(
"--inference-url",
help="Inference API base URL for Nous login (default: production inference API)",
)
model_parser.add_argument(
"--client-id",
default=None,
help="OAuth client id to use for Nous login (default: hermes-cli)",
)
model_parser.add_argument(
"--scope", default=None, help="OAuth scope to request for Nous login"
)
model_parser.add_argument(
"--no-browser",
action="store_true",
help="Do not attempt to open the browser automatically during Nous login",
)
model_parser.add_argument(
"--manual-paste",
action="store_true",
help=(
"For loopback OAuth providers (xai-oauth, ...): skip the local "
"callback listener and paste the failed callback URL from your "
"browser instead. Use on browser-only remotes (Cloud Shell, "
"Codespaces, EC2 Instance Connect, ...). See #26923."
),
)
model_parser.add_argument(
"--timeout",
type=float,
default=15.0,
help="HTTP request timeout in seconds for Nous login (default: 15)",
)
model_parser.add_argument(
"--ca-bundle", help="Path to CA bundle PEM file for Nous TLS verification"
)
model_parser.add_argument(
"--insecure",
action="store_true",
help="Disable TLS verification for Nous login (testing only)",
)
model_parser.set_defaults(func=cmd_model)

View file

@ -0,0 +1,23 @@
"""``hermes postinstall`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_postinstall_parser(subparsers, *, cmd_postinstall: Callable) -> None:
"""Attach the ``postinstall`` subcommand to ``subparsers``."""
# =========================================================================
# postinstall command
# =========================================================================
postinstall_parser = subparsers.add_parser(
"postinstall",
help="Bootstrap non-Python deps for pip installs (node, browser, ripgrep, ffmpeg)",
description="One-shot post-install for pip users. Installs system "
"dependencies that pip cannot provide, then runs setup if needed.",
)
postinstall_parser.set_defaults(func=cmd_postinstall)

View file

@ -0,0 +1,36 @@
"""``hermes prompt-size`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_prompt_size_parser(subparsers, *, cmd_prompt_size: Callable) -> None:
"""Attach the ``prompt-size`` subcommand to ``subparsers``."""
# =========================================================================
# prompt-size command
# =========================================================================
prompt_size_parser = subparsers.add_parser(
"prompt-size",
help="Show a byte breakdown of the system prompt + tool schemas",
description=(
"Report the fixed prompt budget for a fresh session: system "
"prompt total, skills index, memory, user profile, and tool-schema "
"JSON. Runs offline (no API call)."
),
)
prompt_size_parser.add_argument(
"--platform",
default="cli",
help="Platform to simulate (cli, telegram, discord, ...). Default: cli",
)
prompt_size_parser.add_argument(
"--json",
action="store_true",
help="Emit the breakdown as JSON",
)
prompt_size_parser.set_defaults(func=cmd_prompt_size)

View file

@ -0,0 +1,62 @@
"""``hermes security`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_security_parser(subparsers, *, cmd_security: Callable) -> None:
"""Attach the ``security`` subcommand to ``subparsers``."""
# =========================================================================
security_parser = subparsers.add_parser(
"security",
help="Supply-chain audit (OSV.dev) for venv, plugins, and MCP servers",
description=(
"On-demand vulnerability scan against OSV.dev. Covers the Hermes "
"venv (installed PyPI dists), Python deps declared by plugins under "
"~/.hermes/plugins/, and pinned npx/uvx MCP servers in config.yaml. "
"Does NOT scan globally-installed packages or editor/browser extensions."
),
)
security_subparsers = security_parser.add_subparsers(
dest="security_command",
metavar="<subcommand>",
)
audit_parser = security_subparsers.add_parser(
"audit",
help="Run a one-shot supply-chain audit",
description="Query OSV.dev for known vulnerabilities in installed components.",
)
audit_parser.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON instead of human-readable text",
)
audit_parser.add_argument(
"--fail-on",
default="critical",
choices=["low", "moderate", "high", "critical"],
help="Exit non-zero when any finding meets this severity (default: critical)",
)
audit_parser.add_argument(
"--skip-venv",
action="store_true",
help="Skip scanning the Hermes Python venv",
)
audit_parser.add_argument(
"--skip-plugins",
action="store_true",
help="Skip scanning plugin requirements files",
)
audit_parser.add_argument(
"--skip-mcp",
action="store_true",
help="Skip scanning pinned MCP servers in config.yaml",
)
audit_parser.set_defaults(func=cmd_security)
security_parser.set_defaults(func=cmd_security)

View file

@ -0,0 +1,58 @@
"""``hermes setup`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_setup_parser(subparsers, *, cmd_setup: Callable) -> None:
"""Attach the ``setup`` subcommand to ``subparsers``."""
# =========================================================================
# setup command
# =========================================================================
setup_parser = subparsers.add_parser(
"setup",
help="Interactive setup wizard",
description="Configure Hermes Agent with an interactive wizard. "
"Run a specific section: hermes setup model|tts|terminal|gateway|tools|agent",
)
setup_parser.add_argument(
"section",
nargs="?",
choices=["model", "tts", "terminal", "gateway", "tools", "agent"],
default=None,
help="Run a specific setup section instead of the full wizard",
)
setup_parser.add_argument(
"--non-interactive",
action="store_true",
help="Non-interactive mode (use defaults/env vars)",
)
setup_parser.add_argument(
"--reset", action="store_true", help="Reset configuration to defaults"
)
setup_parser.add_argument(
"--reconfigure",
action="store_true",
help="(Default on existing installs.) Re-run the full wizard, "
"showing current values as defaults. Kept for backwards "
"compatibility — a bare 'hermes setup' now does this.",
)
setup_parser.add_argument(
"--quick",
action="store_true",
help="On existing installs: only prompt for items that are missing "
"or unset, instead of running the full reconfigure wizard.",
)
setup_parser.add_argument(
"--portal",
action="store_true",
help="One-shot Nous Portal setup: log in via OAuth, pick a Nous "
"model, set Nous as the inference provider, and opt into the Tool "
"Gateway. Skips the rest of the wizard.",
)
setup_parser.set_defaults(func=cmd_setup)

View file

@ -0,0 +1,60 @@
"""``hermes slack`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_slack_parser(subparsers, *, cmd_slack: Callable) -> None:
"""Attach the ``slack`` subcommand to ``subparsers``."""
# =========================================================================
# slack command
# =========================================================================
slack_parser = subparsers.add_parser(
"slack",
help="Slack integration helpers (manifest generation, etc.)",
description="Slack integration helpers for Hermes.",
)
slack_sub = slack_parser.add_subparsers(dest="slack_command")
slack_manifest = slack_sub.add_parser(
"manifest",
help="Print or write a Slack app manifest with every gateway command "
"registered as a native slash (/btw, /stop, /model, ...)",
description=(
"Generate a Slack app manifest that registers every gateway "
"command in COMMAND_REGISTRY as a first-class Slack slash "
"command (matching Discord and Telegram parity). Paste the "
"output into Slack app config → Features → App Manifest → "
"Edit, then Save. Reinstall the app if Slack prompts for it."
),
)
slack_manifest.add_argument(
"--write",
nargs="?",
const=True,
default=None,
metavar="PATH",
help="Write manifest to a file instead of stdout. With no PATH "
"writes to $HERMES_HOME/slack-manifest.json.",
)
slack_manifest.add_argument(
"--name",
default=None,
help='Bot display name (default: "Hermes")',
)
slack_manifest.add_argument(
"--description",
default=None,
help="Bot description shown in Slack's app directory.",
)
slack_manifest.add_argument(
"--slashes-only",
action="store_true",
help="Emit only the features.slash_commands array (for merging "
"into an existing manifest manually).",
)
slack_parser.set_defaults(func=cmd_slack)

View file

@ -0,0 +1,28 @@
"""``hermes status`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_status_parser(subparsers, *, cmd_status: Callable) -> None:
"""Attach the ``status`` subcommand to ``subparsers``."""
# =========================================================================
# status command
# =========================================================================
status_parser = subparsers.add_parser(
"status",
help="Show status of all components",
description="Display status of Hermes Agent components",
)
status_parser.add_argument(
"--all", action="store_true", help="Show all details (redacted for sharing)"
)
status_parser.add_argument(
"--deep", action="store_true", help="Run deep checks (may take longer)"
)
status_parser.set_defaults(func=cmd_status)

View file

@ -0,0 +1,41 @@
"""``hermes uninstall`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_uninstall_parser(subparsers, *, cmd_uninstall: Callable) -> None:
"""Attach the ``uninstall`` subcommand to ``subparsers``."""
# =========================================================================
# uninstall command
# =========================================================================
uninstall_parser = subparsers.add_parser(
"uninstall",
help="Uninstall Hermes Agent",
description="Remove Hermes Agent from your system. Can keep configs/data for reinstall.",
)
uninstall_parser.add_argument(
"--full",
action="store_true",
help="Full uninstall - remove everything including configs and data",
)
uninstall_parser.add_argument(
"--gui",
action="store_true",
help="Uninstall only the desktop Chat GUI, leaving the agent intact",
)
uninstall_parser.add_argument(
"--gui-summary",
action="store_true",
help="Print a JSON summary of installed GUI/agent artifacts and exit "
"(used by the desktop app to gate uninstall options)",
)
uninstall_parser.add_argument(
"--yes", "-y", action="store_true", help="Skip confirmation prompts"
)
uninstall_parser.set_defaults(func=cmd_uninstall)

View file

@ -0,0 +1,70 @@
"""``hermes update`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_update_parser(subparsers, *, cmd_update: Callable) -> None:
"""Attach the ``update`` subcommand to ``subparsers``."""
# =========================================================================
# update command
# =========================================================================
update_parser = subparsers.add_parser(
"update",
help="Update Hermes Agent to the latest version",
description="Pull the latest changes from git and reinstall dependencies",
)
update_parser.add_argument(
"--gateway",
action="store_true",
default=False,
help="Gateway mode: use file-based IPC for prompts instead of stdin (used internally by /update)",
)
update_parser.add_argument(
"--check",
action="store_true",
default=False,
help="Check whether an update is available without installing anything",
)
update_parser.add_argument(
"--no-backup",
action="store_true",
default=False,
help="Skip the pre-update backup for this run (overrides updates.pre_update_backup)",
)
update_parser.add_argument(
"--backup",
action="store_true",
default=False,
help="Force a pre-update backup for this run (off by default; overrides updates.pre_update_backup)",
)
update_parser.add_argument(
"--yes",
"-y",
action="store_true",
default=False,
help="Assume yes for interactive prompts (config migration, stash restore). API-key entry is skipped; run 'hermes config migrate' separately for those.",
)
update_parser.add_argument(
"--branch",
default=None,
metavar="NAME",
help=(
"Update against this branch instead of the default (main). "
"If the local checkout is on a different branch, hermes will "
"switch to the requested branch first (auto-stashing any "
"uncommitted changes)."
),
)
update_parser.add_argument(
"--force",
action="store_true",
default=False,
help="Windows: proceed with the update even when another hermes.exe is detected. The concurrent process will likely cause WinError 32 warnings and may leave a reboot-deferred .exe replacement.",
)
update_parser.set_defaults(func=cmd_update)

View file

@ -0,0 +1,18 @@
"""``hermes version`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_version_parser(subparsers, *, cmd_version: Callable) -> None:
"""Attach the ``version`` subcommand to ``subparsers``."""
# =========================================================================
# version command
# =========================================================================
version_parser = subparsers.add_parser("version", help="Show version information")
version_parser.set_defaults(func=cmd_version)

View file

@ -0,0 +1,76 @@
"""``hermes webhook`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None:
"""Attach the ``webhook`` subcommand to ``subparsers``."""
# =========================================================================
# webhook command
# =========================================================================
webhook_parser = subparsers.add_parser(
"webhook",
help="Manage dynamic webhook subscriptions",
description="Create, list, and remove webhook subscriptions for event-driven agent activation",
)
webhook_subparsers = webhook_parser.add_subparsers(dest="webhook_action")
wh_sub = webhook_subparsers.add_parser(
"subscribe", aliases=["add"], help="Create a webhook subscription"
)
wh_sub.add_argument("name", help="Route name (used in URL: /webhooks/<name>)")
wh_sub.add_argument(
"--prompt", default="", help="Prompt template with {dot.notation} payload refs"
)
wh_sub.add_argument(
"--events", default="", help="Comma-separated event types to accept"
)
wh_sub.add_argument("--description", default="", help="What this subscription does")
wh_sub.add_argument(
"--skills", default="", help="Comma-separated skill names to load"
)
wh_sub.add_argument(
"--deliver",
default="log",
help="Delivery target: log, telegram, discord, slack, etc.",
)
wh_sub.add_argument(
"--deliver-chat-id",
default="",
help="Target chat ID for cross-platform delivery",
)
wh_sub.add_argument(
"--secret", default="", help="HMAC secret (auto-generated if omitted)"
)
wh_sub.add_argument(
"--deliver-only",
action="store_true",
help="Skip the agent — deliver the rendered prompt directly as the "
"message. Zero LLM cost. Requires --deliver to be a real target "
"(not 'log').",
)
webhook_subparsers.add_parser(
"list", aliases=["ls"], help="List all dynamic subscriptions"
)
wh_rm = webhook_subparsers.add_parser(
"remove", aliases=["rm"], help="Remove a subscription"
)
wh_rm.add_argument("name", help="Subscription name to remove")
wh_test = webhook_subparsers.add_parser(
"test", help="Send a test POST to a webhook route"
)
wh_test.add_argument("name", help="Subscription name to test")
wh_test.add_argument(
"--payload", default="", help="JSON payload to send (default: test payload)"
)
webhook_parser.set_defaults(func=cmd_webhook)

View file

@ -0,0 +1,22 @@
"""``hermes whatsapp`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_whatsapp_parser(subparsers, *, cmd_whatsapp: Callable) -> None:
"""Attach the ``whatsapp`` subcommand to ``subparsers``."""
# =========================================================================
# whatsapp command
# =========================================================================
whatsapp_parser = subparsers.add_parser(
"whatsapp",
help="Set up WhatsApp integration",
description="Configure WhatsApp and pair via QR code",
)
whatsapp_parser.set_defaults(func=cmd_whatsapp)

View file

@ -0,0 +1,97 @@
"""Smoke tests for the batch-extracted subcommand parser builders.
Each ``build_<group>_parser`` should attach its subcommand to a subparsers
group and wire ``func`` to the injected handler. These are intentionally
light the byte-identical ``--help`` verification done at extraction time is
the real behavioral guarantee; this just guards against a module failing to
import or a builder raising.
"""
from __future__ import annotations
import argparse
import pytest
from hermes_cli.subcommands.auth import build_auth_parser
from hermes_cli.subcommands.backup import build_backup_parser
from hermes_cli.subcommands.config import build_config_parser
from hermes_cli.subcommands.dashboard import build_dashboard_parser
from hermes_cli.subcommands.debug import build_debug_parser
from hermes_cli.subcommands.doctor import build_doctor_parser
from hermes_cli.subcommands.dump import build_dump_parser
from hermes_cli.subcommands.gui import build_gui_parser
from hermes_cli.subcommands.hooks import build_hooks_parser
from hermes_cli.subcommands.import_cmd import build_import_cmd_parser
from hermes_cli.subcommands.login import build_login_parser
from hermes_cli.subcommands.logout import build_logout_parser
from hermes_cli.subcommands.logs import build_logs_parser
from hermes_cli.subcommands.model import build_model_parser
from hermes_cli.subcommands.postinstall import build_postinstall_parser
from hermes_cli.subcommands.prompt_size import build_prompt_size_parser
from hermes_cli.subcommands.security import build_security_parser
from hermes_cli.subcommands.setup import build_setup_parser
from hermes_cli.subcommands.slack import build_slack_parser
from hermes_cli.subcommands.status import build_status_parser
from hermes_cli.subcommands.uninstall import build_uninstall_parser
from hermes_cli.subcommands.update import build_update_parser
from hermes_cli.subcommands.version import build_version_parser
from hermes_cli.subcommands.webhook import build_webhook_parser
from hermes_cli.subcommands.whatsapp import build_whatsapp_parser
def _h(name):
def handler(args): # pragma: no cover - identity only
return name
handler.__name__ = f"cmd_{name}"
return handler
# (subcommand_name, builder, handler_kwargs, sample_argv)
SINGLE_HANDLER_CASES = [
("model", build_model_parser, "cmd_model", ["model"]),
("setup", build_setup_parser, "cmd_setup", ["setup"]),
("postinstall", build_postinstall_parser, "cmd_postinstall", ["postinstall"]),
("whatsapp", build_whatsapp_parser, "cmd_whatsapp", ["whatsapp"]),
("slack", build_slack_parser, "cmd_slack", ["slack"]),
("login", build_login_parser, "cmd_login", ["login"]),
("logout", build_logout_parser, "cmd_logout", ["logout"]),
("auth", build_auth_parser, "cmd_auth", ["auth"]),
("status", build_status_parser, "cmd_status", ["status"]),
("webhook", build_webhook_parser, "cmd_webhook", ["webhook"]),
("hooks", build_hooks_parser, "cmd_hooks", ["hooks"]),
("doctor", build_doctor_parser, "cmd_doctor", ["doctor"]),
("security", build_security_parser, "cmd_security", ["security"]),
("dump", build_dump_parser, "cmd_dump", ["dump"]),
("debug", build_debug_parser, "cmd_debug", ["debug"]),
("backup", build_backup_parser, "cmd_backup", ["backup"]),
("import", build_import_cmd_parser, "cmd_import", ["import", "/tmp/x.zip"]),
("config", build_config_parser, "cmd_config", ["config"]),
("version", build_version_parser, "cmd_version", ["version"]),
("update", build_update_parser, "cmd_update", ["update"]),
("uninstall", build_uninstall_parser, "cmd_uninstall", ["uninstall"]),
("gui", build_gui_parser, "cmd_gui", ["gui"]),
("logs", build_logs_parser, "cmd_logs", ["logs"]),
("prompt-size", build_prompt_size_parser, "cmd_prompt_size", ["prompt-size"]),
]
@pytest.mark.parametrize("name,builder,kw,argv", SINGLE_HANDLER_CASES, ids=[c[0] for c in SINGLE_HANDLER_CASES])
def test_single_handler_builders(name, builder, kw, argv):
parser = argparse.ArgumentParser(prog="hermes")
sub = parser.add_subparsers(dest="command")
handler = _h(name)
builder(sub, **{kw: handler})
ns = parser.parse_args(argv)
assert ns.func is handler
def test_dashboard_builder_two_handlers():
parser = argparse.ArgumentParser(prog="hermes")
sub = parser.add_subparsers(dest="command")
dash, reg = _h("dashboard"), _h("dashboard_register")
build_dashboard_parser(sub, cmd_dashboard=dash, cmd_dashboard_register=reg)
# bare dashboard -> launch handler
assert parser.parse_args(["dashboard"]).func is dash
# dashboard register -> register handler
assert parser.parse_args(["dashboard", "register"]).func is reg