From 568e1276124a08f11cafa84e69879c64ec01c563 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:50:44 -0700 Subject: [PATCH] refactor(cli): extract 25 more subcommand parsers into hermes_cli/subcommands/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.py with build__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. --- hermes_cli/main.py | 1068 ++------------------ hermes_cli/subcommands/auth.py | 109 ++ hermes_cli/subcommands/backup.py | 38 + hermes_cli/subcommands/config.py | 49 + hermes_cli/subcommands/dashboard.py | 123 +++ hermes_cli/subcommands/debug.py | 77 ++ hermes_cli/subcommands/doctor.py | 35 + hermes_cli/subcommands/dump.py | 28 + hermes_cli/subcommands/gui.py | 63 ++ hermes_cli/subcommands/hooks.py | 77 ++ hermes_cli/subcommands/import_cmd.py | 31 + hermes_cli/subcommands/login.py | 58 ++ hermes_cli/subcommands/logout.py | 28 + hermes_cli/subcommands/logs.py | 78 ++ hermes_cli/subcommands/model.py | 72 ++ hermes_cli/subcommands/postinstall.py | 23 + hermes_cli/subcommands/prompt_size.py | 36 + hermes_cli/subcommands/security.py | 62 ++ hermes_cli/subcommands/setup.py | 58 ++ hermes_cli/subcommands/slack.py | 60 ++ hermes_cli/subcommands/status.py | 28 + hermes_cli/subcommands/uninstall.py | 41 + hermes_cli/subcommands/update.py | 70 ++ hermes_cli/subcommands/version.py | 18 + hermes_cli/subcommands/webhook.py | 76 ++ hermes_cli/subcommands/whatsapp.py | 22 + tests/hermes_cli/test_subcommands_batch.py | 97 ++ 27 files changed, 1541 insertions(+), 984 deletions(-) create mode 100644 hermes_cli/subcommands/auth.py create mode 100644 hermes_cli/subcommands/backup.py create mode 100644 hermes_cli/subcommands/config.py create mode 100644 hermes_cli/subcommands/dashboard.py create mode 100644 hermes_cli/subcommands/debug.py create mode 100644 hermes_cli/subcommands/doctor.py create mode 100644 hermes_cli/subcommands/dump.py create mode 100644 hermes_cli/subcommands/gui.py create mode 100644 hermes_cli/subcommands/hooks.py create mode 100644 hermes_cli/subcommands/import_cmd.py create mode 100644 hermes_cli/subcommands/login.py create mode 100644 hermes_cli/subcommands/logout.py create mode 100644 hermes_cli/subcommands/logs.py create mode 100644 hermes_cli/subcommands/model.py create mode 100644 hermes_cli/subcommands/postinstall.py create mode 100644 hermes_cli/subcommands/prompt_size.py create mode 100644 hermes_cli/subcommands/security.py create mode 100644 hermes_cli/subcommands/setup.py create mode 100644 hermes_cli/subcommands/slack.py create mode 100644 hermes_cli/subcommands/status.py create mode 100644 hermes_cli/subcommands/uninstall.py create mode 100644 hermes_cli/subcommands/update.py create mode 100644 hermes_cli/subcommands/version.py create mode 100644 hermes_cli/subcommands/webhook.py create mode 100644 hermes_cli/subcommands/whatsapp.py create mode 100644 tests/hermes_cli/test_subcommands_batch.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 21bdca9b361..6020fca1db1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -266,6 +266,31 @@ from hermes_cli.subcommands._shared import add_accept_hooks_flag as _add_accept_ from hermes_cli.subcommands.cron import build_cron_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.postinstall import build_postinstall_parser +from hermes_cli.subcommands.whatsapp import build_whatsapp_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.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.security import build_security_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.config import build_config_parser +from hermes_cli.subcommands.version import build_version_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 +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 def _require_tty(command_name: str) -> None: @@ -12872,64 +12897,9 @@ def main(): chat_parser.set_defaults(func=cmd_chat) # ========================================================================= - # model command + # model command (parser built in hermes_cli/subcommands/model.py) # ========================================================================= - 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) + build_model_parser(subparsers, cmd_model=cmd_model) # ========================================================================= # fallback command — manage the fallback provider chain @@ -13058,119 +13028,24 @@ def main(): logger.debug("LSP CLI registration failed: %s", _lsp_err) # ========================================================================= - # setup command + # setup command (parser built in hermes_cli/subcommands/setup.py) # ========================================================================= - 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) + build_setup_parser(subparsers, cmd_setup=cmd_setup) # ========================================================================= - # postinstall command + # postinstall command (parser built in hermes_cli/subcommands/postinstall.py) # ========================================================================= - 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) + build_postinstall_parser(subparsers, cmd_postinstall=cmd_postinstall) # ========================================================================= - # whatsapp command + # whatsapp command (parser built in hermes_cli/subcommands/whatsapp.py) # ========================================================================= - 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) + build_whatsapp_parser(subparsers, cmd_whatsapp=cmd_whatsapp) # ========================================================================= - # slack command + # slack command (parser built in hermes_cli/subcommands/slack.py) # ========================================================================= - 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) + build_slack_parser(subparsers, cmd_slack=cmd_slack) # ========================================================================= # send command — pipe shell-script output to any configured platform @@ -13179,179 +13054,24 @@ def main(): register_send_subparser(subparsers) # ========================================================================= - # login command + # login command (parser built in hermes_cli/subcommands/login.py) # ========================================================================= - 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) + build_login_parser(subparsers, cmd_login=cmd_login) # ========================================================================= - # logout command + # logout command (parser built in hermes_cli/subcommands/logout.py) # ========================================================================= - 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) - - 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) + build_logout_parser(subparsers, cmd_logout=cmd_logout) # ========================================================================= - # status command + # auth command (parser built in hermes_cli/subcommands/auth.py) # ========================================================================= - 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) + build_auth_parser(subparsers, cmd_auth=cmd_auth) + + # ========================================================================= + # status command (parser built in hermes_cli/subcommands/status.py) + # ========================================================================= + build_status_parser(subparsers, cmd_status=cmd_status) # ========================================================================= # cron command (parser built in hermes_cli/subcommands/cron.py) @@ -13359,68 +13079,9 @@ def main(): build_cron_parser(subparsers, cmd_cron=cmd_cron) # ========================================================================= - # webhook command + # webhook command (parser built in hermes_cli/subcommands/webhook.py) # ========================================================================= - 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/)") - 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) + build_webhook_parser(subparsers, cmd_webhook=cmd_webhook) # ========================================================================= # portal command — Nous Portal status + Tool Gateway routing @@ -13439,250 +13100,36 @@ def main(): # ========================================================================= # hooks command — shell-hook inspection and management # ========================================================================= - hooks_parser = subparsers.add_parser( - "hooks", - help="Inspect and manage shell-script hooks", - description=( - "Inspect shell-script hooks declared in ~/.hermes/config.yaml, " - "test them against synthetic payloads, and manage the first-use " - "consent allowlist at ~/.hermes/shell-hooks-allowlist.json." - ), - ) - hooks_subparsers = hooks_parser.add_subparsers(dest="hooks_action") - - hooks_subparsers.add_parser( - "list", - aliases=["ls"], - help="List configured hooks with matcher, timeout, and consent status", - ) - - _hk_test = hooks_subparsers.add_parser( - "test", - help="Fire every hook matching against a synthetic payload", - ) - _hk_test.add_argument( - "event", - help="Hook event name (e.g. pre_tool_call, pre_llm_call, subagent_stop)", - ) - _hk_test.add_argument( - "--for-tool", - dest="for_tool", - default=None, - help=( - "Only fire hooks whose matcher matches this tool name " - "(used for pre_tool_call / post_tool_call)" - ), - ) - _hk_test.add_argument( - "--payload-file", - dest="payload_file", - default=None, - help=( - "Path to a JSON file whose contents are merged into the " - "synthetic payload before execution" - ), - ) - - _hk_revoke = hooks_subparsers.add_parser( - "revoke", - aliases=["remove", "rm"], - help="Remove a command's allowlist entries (takes effect on next restart)", - ) - _hk_revoke.add_argument( - "command", - help="The exact command string to revoke (as declared in config.yaml)", - ) - - hooks_subparsers.add_parser( - "doctor", - help=( - "Check each configured hook: exec bit, allowlist, mtime drift, " - "JSON validity, and synthetic run timing" - ), - ) - - hooks_parser.set_defaults(func=cmd_hooks) + # hooks command (parser built in hermes_cli/subcommands/hooks.py) + # ========================================================================= + build_hooks_parser(subparsers, cmd_hooks=cmd_hooks) # ========================================================================= - # doctor command + # doctor command (parser built in hermes_cli/subcommands/doctor.py) # ========================================================================= - 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) + build_doctor_parser(subparsers, cmd_doctor=cmd_doctor) # ========================================================================= # security command — on-demand supply-chain audit # ========================================================================= - 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="", - ) - - 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) + # security command (parser built in hermes_cli/subcommands/security.py) + # ========================================================================= + build_security_parser(subparsers, cmd_security=cmd_security) # ========================================================================= - # dump command + # dump command (parser built in hermes_cli/subcommands/dump.py) # ========================================================================= - 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) + build_dump_parser(subparsers, cmd_dump=cmd_dump) # ========================================================================= - # debug command + # debug command (parser built in hermes_cli/subcommands/debug.py) # ========================================================================= - 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 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) + build_debug_parser(subparsers, cmd_debug=cmd_debug) # ========================================================================= - # backup command + # backup command (parser built in hermes_cli/subcommands/backup.py) # ========================================================================= - 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-.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) + build_backup_parser(subparsers, cmd_backup=cmd_backup) # ========================================================================= # checkpoints command @@ -13699,60 +13146,14 @@ Examples: _register_checkpoints_cli(checkpoints_parser) # ========================================================================= - # import command + # import command (parser built in hermes_cli/subcommands/import_cmd.py) # ========================================================================= - 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) + build_import_cmd_parser(subparsers, cmd_import=cmd_import) # ========================================================================= - # config command + # config command (parser built in hermes_cli/subcommands/config.py) # ========================================================================= - 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) + build_config_parser(subparsers, cmd_config=cmd_config) # ========================================================================= # pairing command @@ -15004,97 +14405,19 @@ Examples: claw_parser.set_defaults(func=cmd_claw) # ========================================================================= - # version command + # version command (parser built in hermes_cli/subcommands/version.py) # ========================================================================= - version_parser = subparsers.add_parser("version", help="Show version information") - version_parser.set_defaults(func=cmd_version) + build_version_parser(subparsers, cmd_version=cmd_version) # ========================================================================= - # update command + # update command (parser built in hermes_cli/subcommands/update.py) # ========================================================================= - 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) + build_update_parser(subparsers, cmd_update=cmd_update) # ========================================================================= - # uninstall command + # uninstall command (parser built in hermes_cli/subcommands/uninstall.py) # ========================================================================= - 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) + build_uninstall_parser(subparsers, cmd_uninstall=cmd_uninstall) # ========================================================================= # acp command @@ -15182,112 +14505,14 @@ Examples: completion_parser.set_defaults(func=lambda args: cmd_completion(args, parser)) # ========================================================================= - # dashboard command + # dashboard command (parser built in hermes_cli/subcommands/dashboard.py) # ========================================================================= - 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", + build_dashboard_parser( + subparsers, + cmd_dashboard=cmd_dashboard, + cmd_dashboard_register=cmd_dashboard_register, ) - 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) # ========================================================================= # desktop (a.k.a. gui) command @@ -15298,144 +14523,19 @@ Examples: # to be the one that appears in --help (argparse promotes the primary # name; aliases stay hidden). # ========================================================================= - 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) + # gui command (parser built in hermes_cli/subcommands/gui.py) + # ========================================================================= + build_gui_parser(subparsers, cmd_gui=cmd_gui) # ========================================================================= - # logs command + # logs command (parser built in hermes_cli/subcommands/logs.py) # ========================================================================= - 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) + build_logs_parser(subparsers, cmd_logs=cmd_logs) # ========================================================================= - # prompt-size command + # prompt-size command (parser built in hermes_cli/subcommands/prompt_size.py) # ========================================================================= - 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) + build_prompt_size_parser(subparsers, cmd_prompt_size=cmd_prompt_size) # ========================================================================= # Parse and execute diff --git a/hermes_cli/subcommands/auth.py b/hermes_cli/subcommands/auth.py new file mode 100644 index 00000000000..a087937cb93 --- /dev/null +++ b/hermes_cli/subcommands/auth.py @@ -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) diff --git a/hermes_cli/subcommands/backup.py b/hermes_cli/subcommands/backup.py new file mode 100644 index 00000000000..745d2193303 --- /dev/null +++ b/hermes_cli/subcommands/backup.py @@ -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-.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) diff --git a/hermes_cli/subcommands/config.py b/hermes_cli/subcommands/config.py new file mode 100644 index 00000000000..5080d69c17f --- /dev/null +++ b/hermes_cli/subcommands/config.py @@ -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) diff --git a/hermes_cli/subcommands/dashboard.py b/hermes_cli/subcommands/dashboard.py new file mode 100644 index 00000000000..6bdb858513d --- /dev/null +++ b/hermes_cli/subcommands/dashboard.py @@ -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) diff --git a/hermes_cli/subcommands/debug.py b/hermes_cli/subcommands/debug.py new file mode 100644 index 00000000000..d666d1943d5 --- /dev/null +++ b/hermes_cli/subcommands/debug.py @@ -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 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) diff --git a/hermes_cli/subcommands/doctor.py b/hermes_cli/subcommands/doctor.py new file mode 100644 index 00000000000..5be37c64558 --- /dev/null +++ b/hermes_cli/subcommands/doctor.py @@ -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) diff --git a/hermes_cli/subcommands/dump.py b/hermes_cli/subcommands/dump.py new file mode 100644 index 00000000000..fdad4e5a663 --- /dev/null +++ b/hermes_cli/subcommands/dump.py @@ -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) diff --git a/hermes_cli/subcommands/gui.py b/hermes_cli/subcommands/gui.py new file mode 100644 index 00000000000..b51ff4b5ff9 --- /dev/null +++ b/hermes_cli/subcommands/gui.py @@ -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) diff --git a/hermes_cli/subcommands/hooks.py b/hermes_cli/subcommands/hooks.py new file mode 100644 index 00000000000..2e71f2fb89f --- /dev/null +++ b/hermes_cli/subcommands/hooks.py @@ -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 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) diff --git a/hermes_cli/subcommands/import_cmd.py b/hermes_cli/subcommands/import_cmd.py new file mode 100644 index 00000000000..36ed375d8d2 --- /dev/null +++ b/hermes_cli/subcommands/import_cmd.py @@ -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) diff --git a/hermes_cli/subcommands/login.py b/hermes_cli/subcommands/login.py new file mode 100644 index 00000000000..efc91e8924e --- /dev/null +++ b/hermes_cli/subcommands/login.py @@ -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) diff --git a/hermes_cli/subcommands/logout.py b/hermes_cli/subcommands/logout.py new file mode 100644 index 00000000000..292b327c0f7 --- /dev/null +++ b/hermes_cli/subcommands/logout.py @@ -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) diff --git a/hermes_cli/subcommands/logs.py b/hermes_cli/subcommands/logs.py new file mode 100644 index 00000000000..53964b022fc --- /dev/null +++ b/hermes_cli/subcommands/logs.py @@ -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) diff --git a/hermes_cli/subcommands/model.py b/hermes_cli/subcommands/model.py new file mode 100644 index 00000000000..37567e39533 --- /dev/null +++ b/hermes_cli/subcommands/model.py @@ -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) diff --git a/hermes_cli/subcommands/postinstall.py b/hermes_cli/subcommands/postinstall.py new file mode 100644 index 00000000000..207040ada2f --- /dev/null +++ b/hermes_cli/subcommands/postinstall.py @@ -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) diff --git a/hermes_cli/subcommands/prompt_size.py b/hermes_cli/subcommands/prompt_size.py new file mode 100644 index 00000000000..d79fcb30bcc --- /dev/null +++ b/hermes_cli/subcommands/prompt_size.py @@ -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) diff --git a/hermes_cli/subcommands/security.py b/hermes_cli/subcommands/security.py new file mode 100644 index 00000000000..b763a6e62e8 --- /dev/null +++ b/hermes_cli/subcommands/security.py @@ -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="", + ) + + 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) diff --git a/hermes_cli/subcommands/setup.py b/hermes_cli/subcommands/setup.py new file mode 100644 index 00000000000..406710a6887 --- /dev/null +++ b/hermes_cli/subcommands/setup.py @@ -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) diff --git a/hermes_cli/subcommands/slack.py b/hermes_cli/subcommands/slack.py new file mode 100644 index 00000000000..28229c1fc6f --- /dev/null +++ b/hermes_cli/subcommands/slack.py @@ -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) diff --git a/hermes_cli/subcommands/status.py b/hermes_cli/subcommands/status.py new file mode 100644 index 00000000000..ad107a32a60 --- /dev/null +++ b/hermes_cli/subcommands/status.py @@ -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) diff --git a/hermes_cli/subcommands/uninstall.py b/hermes_cli/subcommands/uninstall.py new file mode 100644 index 00000000000..1250af3e04d --- /dev/null +++ b/hermes_cli/subcommands/uninstall.py @@ -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) diff --git a/hermes_cli/subcommands/update.py b/hermes_cli/subcommands/update.py new file mode 100644 index 00000000000..ddfe1db30a1 --- /dev/null +++ b/hermes_cli/subcommands/update.py @@ -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) diff --git a/hermes_cli/subcommands/version.py b/hermes_cli/subcommands/version.py new file mode 100644 index 00000000000..54346d02b67 --- /dev/null +++ b/hermes_cli/subcommands/version.py @@ -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) diff --git a/hermes_cli/subcommands/webhook.py b/hermes_cli/subcommands/webhook.py new file mode 100644 index 00000000000..cd58da35069 --- /dev/null +++ b/hermes_cli/subcommands/webhook.py @@ -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/)") + 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) diff --git a/hermes_cli/subcommands/whatsapp.py b/hermes_cli/subcommands/whatsapp.py new file mode 100644 index 00000000000..5b1b9344c33 --- /dev/null +++ b/hermes_cli/subcommands/whatsapp.py @@ -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) diff --git a/tests/hermes_cli/test_subcommands_batch.py b/tests/hermes_cli/test_subcommands_batch.py new file mode 100644 index 00000000000..4fbba841fb2 --- /dev/null +++ b/tests/hermes_cli/test_subcommands_batch.py @@ -0,0 +1,97 @@ +"""Smoke tests for the batch-extracted subcommand parser builders. + +Each ``build__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