From 4da45e872738761a53d1f04079e4b49b1b2f63c9 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:37:14 -0700 Subject: [PATCH] refactor(cli): extract profile + gateway/proxy parsers into hermes_cli/subcommands/ Follow-on to the cron extraction in the same Phase 2 PR. Same pattern: per-group build__parser() functions with injected handlers, no main import. - subcommands/profile.py: build_profile_parser (190-line block out of main()). - subcommands/gateway.py: build_gateway_parser (gateway + proxy, 238-line block; they shared one inline section). Imports argparse for SUPPRESS defaults. - main(): two more inline blocks become single builder calls. Behavior-neutral: 'profile [sub] --help' and 'gateway/proxy [sub] --help' byte-identical to pre-extraction (diff-verified). main() now 2723 LOC (was 3297 at Phase 2 start); add_parser calls in main.py 179 -> 141. Validation: tests/hermes_cli/ 6476 passed / 0 failed under per-file process isolation; new builder unit tests cover subactions, aliases, dispatch, flags. --- hermes_cli/main.py | 430 +----------------- hermes_cli/subcommands/gateway.py | 256 +++++++++++ hermes_cli/subcommands/profile.py | 203 +++++++++ .../test_subcommands_profile_gateway.py | 83 ++++ 4 files changed, 548 insertions(+), 424 deletions(-) create mode 100644 hermes_cli/subcommands/gateway.py create mode 100644 hermes_cli/subcommands/profile.py create mode 100644 tests/hermes_cli/test_subcommands_profile_gateway.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5252663878c..21bdca9b361 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -264,6 +264,8 @@ from typing import Optional from hermes_cli.subcommands._shared import add_accept_hooks_flag as _add_accept_hooks_flag 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 def _require_tty(command_name: str) -> None: @@ -13040,243 +13042,9 @@ def main(): migrate_parser.set_defaults(func=cmd_migrate) # ========================================================================= - # gateway command + # gateway + proxy commands (parsers built in hermes_cli/subcommands/gateway.py) # ========================================================================= - gateway_parser = subparsers.add_parser( - "gateway", - help="Messaging gateway management", - description="Manage the messaging gateway (Telegram, Discord, WhatsApp, Weixin, and more)", - ) - gateway_subparsers = gateway_parser.add_subparsers(dest="gateway_command") - - # gateway run (default) - gateway_run = gateway_subparsers.add_parser( - "run", help="Run gateway in foreground (recommended for WSL, Docker, Termux)" - ) - gateway_run.add_argument( - "-v", - "--verbose", - action="count", - default=0, - help="Increase stderr log verbosity (-v=INFO, -vv=DEBUG)", - ) - gateway_run.add_argument( - "-q", "--quiet", action="store_true", help="Suppress all stderr log output" - ) - gateway_run.add_argument( - "--replace", - action="store_true", - help="Replace any existing gateway instance (useful for systemd)", - ) - gateway_run.add_argument( - "--no-supervise", - action="store_true", - help=( - "Inside the s6-overlay Docker image, normally `gateway run` is " - "automatically redirected to the supervised s6 service (so the " - "gateway gets auto-restart on crash, plus a supervised dashboard " - "if HERMES_DASHBOARD is set). Pass --no-supervise to opt out and " - "get the historical pre-s6 foreground behavior: the gateway is " - "the container's main process and the container exits with the " - "gateway's exit code. No effect outside an s6 container." - ), - ) - _add_accept_hooks_flag(gateway_run) - _add_accept_hooks_flag(gateway_parser) - - # gateway start - gateway_start = gateway_subparsers.add_parser( - "start", help="Start the installed systemd/launchd background service" - ) - gateway_start.add_argument( - "--system", - action="store_true", - help="Target the Linux system-level gateway service", - ) - gateway_start.add_argument( - "--all", - action="store_true", - help="Kill ALL stale gateway processes across all profiles before starting", - ) - - # gateway stop - gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service") - gateway_stop.add_argument( - "--system", - action="store_true", - help="Target the Linux system-level gateway service", - ) - gateway_stop.add_argument( - "--all", - action="store_true", - help="Stop ALL gateway processes across all profiles", - ) - - # gateway restart - gateway_restart = gateway_subparsers.add_parser( - "restart", help="Restart gateway service" - ) - gateway_restart.add_argument( - "--system", - action="store_true", - help="Target the Linux system-level gateway service", - ) - gateway_restart.add_argument( - "--all", - action="store_true", - help="Kill ALL gateway processes across all profiles before restarting", - ) - - # gateway status - gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status") - gateway_status.add_argument("--deep", action="store_true", help="Deep status check") - gateway_status.add_argument( - "-l", - "--full", - action="store_true", - help="Show full, untruncated service/log output where supported", - ) - gateway_status.add_argument( - "--system", - action="store_true", - help="Target the Linux system-level gateway service", - ) - - # gateway install - gateway_install = gateway_subparsers.add_parser( - "install", help="Install gateway as a systemd/launchd background service" - ) - gateway_install.add_argument("--force", action="store_true", help="Force reinstall") - gateway_install.add_argument( - "--system", - action="store_true", - help="Install as a Linux system-level service (starts at boot)", - ) - gateway_install.add_argument( - "--run-as-user", - dest="run_as_user", - help="User account the Linux system service should run as", - ) - gateway_install.add_argument( - "--start-now", - dest="start_now", - action="store_true", - default=None, - help=argparse.SUPPRESS, - ) - gateway_install.add_argument( - "--no-start-now", - dest="start_now", - action="store_false", - help=argparse.SUPPRESS, - ) - gateway_install.add_argument( - "--start-on-login", - dest="start_on_login", - action="store_true", - default=None, - help=argparse.SUPPRESS, - ) - gateway_install.add_argument( - "--no-start-on-login", - dest="start_on_login", - action="store_false", - help=argparse.SUPPRESS, - ) - gateway_install.add_argument( - "--elevated-handoff", - dest="elevated_handoff", - action="store_true", - help=argparse.SUPPRESS, - ) - - # gateway uninstall - gateway_uninstall = gateway_subparsers.add_parser( - "uninstall", help="Uninstall gateway service" - ) - gateway_uninstall.add_argument( - "--system", - action="store_true", - help="Target the Linux system-level gateway service", - ) - - # gateway list - gateway_subparsers.add_parser("list", help="List all profiles and their gateway status") - - # gateway setup - gateway_subparsers.add_parser("setup", help="Configure messaging platforms") - - # gateway migrate-legacy - gateway_migrate_legacy = gateway_subparsers.add_parser( - "migrate-legacy", - help="Remove legacy hermes.service units from pre-rename installs", - description=( - "Stop, disable, and remove legacy Hermes gateway unit files " - "(e.g. hermes.service) left over from older installs. Profile " - "units (hermes-gateway-.service) and unrelated " - "third-party services are never touched." - ), - ) - gateway_migrate_legacy.add_argument( - "--dry-run", - dest="dry_run", - action="store_true", - help="List what would be removed without doing it", - ) - gateway_migrate_legacy.add_argument( - "-y", - "--yes", - dest="yes", - action="store_true", - help="Skip the confirmation prompt", - ) - - # ========================================================================= - # proxy command — local OpenAI-compatible proxy that attaches the user's - # OAuth-authenticated provider credentials to outbound requests. Lets - # external apps (OpenViking, Karakeep, Open WebUI, ...) ride a logged-in - # subscription without copy-pasting static API keys. - # ========================================================================= - proxy_parser = subparsers.add_parser( - "proxy", - help="Local OpenAI-compatible proxy to OAuth providers", - description=( - "Run a local HTTP server that forwards OpenAI-compatible requests " - "to an OAuth-authenticated provider (e.g. Nous Portal). External " - "apps can point at the proxy with any bearer token; the proxy " - "attaches your real credentials." - ), - ) - proxy_subparsers = proxy_parser.add_subparsers(dest="proxy_command") - - proxy_start = proxy_subparsers.add_parser( - "start", help="Run the proxy in the foreground" - ) - proxy_start.add_argument( - "--provider", - default="nous", - help="Upstream provider: nous or xai (default: nous). See `hermes proxy providers`.", - ) - proxy_start.add_argument( - "--host", - default=None, - help="Bind address (default: 127.0.0.1). Use 0.0.0.0 to expose on LAN.", - ) - proxy_start.add_argument( - "--port", - type=int, - default=None, - help="Bind port (default: 8645)", - ) - - proxy_subparsers.add_parser( - "status", help="Show which proxy upstreams are ready" - ) - proxy_subparsers.add_parser( - "providers", help="List available proxy upstream providers" - ) - proxy_parser.set_defaults(func=cmd_proxy) - gateway_parser.set_defaults(func=cmd_gateway) + build_gateway_parser(subparsers, cmd_gateway=cmd_gateway, cmd_proxy=cmd_proxy) # ========================================================================= # lsp command @@ -15393,195 +15161,9 @@ Examples: acp_parser.set_defaults(func=cmd_acp) # ========================================================================= - # profile command + # profile command (parser built in hermes_cli/subcommands/profile.py) # ========================================================================= - profile_parser = subparsers.add_parser( - "profile", - help="Manage profiles — multiple isolated Hermes instances", - ) - profile_subparsers = profile_parser.add_subparsers(dest="profile_action") - - profile_subparsers.add_parser("list", help="List all profiles") - profile_use = profile_subparsers.add_parser( - "use", help="Set sticky default profile" - ) - profile_use.add_argument("profile_name", help="Profile name (or 'default')") - - profile_create = profile_subparsers.add_parser( - "create", help="Create a new profile" - ) - profile_create.add_argument( - "profile_name", help="Profile name (lowercase, alphanumeric)" - ) - profile_create.add_argument( - "--clone", - action="store_true", - help="Copy config.yaml, .env, SOUL.md from active profile", - ) - profile_create.add_argument( - "--clone-all", - action="store_true", - help="Full copy of active profile (all state)", - ) - profile_create.add_argument( - "--clone-from", - metavar="SOURCE", - help="Source profile to clone from (default: active)", - ) - profile_create.add_argument( - "--no-alias", action="store_true", help="Skip wrapper script creation" - ) - profile_create.add_argument( - "--no-skills", - action="store_true", - help="Create an empty profile with no bundled skills (opts out of `hermes update` skill sync)", - ) - profile_create.add_argument( - "--description", - default=None, - help="One- or two-sentence description of what this profile is good at. " - "Used by the kanban decomposer to route tasks based on role instead " - "of profile name alone. Skip and add later via `hermes profile describe`.", - ) - - profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile") - profile_delete.add_argument("profile_name", help="Profile to delete") - profile_delete.add_argument( - "-y", "--yes", action="store_true", help="Skip confirmation prompt" - ) - - profile_describe = profile_subparsers.add_parser( - "describe", - help="Read or set a profile's description (used by the kanban orchestrator)", - ) - profile_describe.add_argument( - "profile_name", - nargs="?", - default=None, - help="Profile to describe (omit + use --all --auto to sweep)", - ) - profile_describe.add_argument( - "--text", - default=None, - help="Set description to this exact text (overwrites any existing description)", - ) - profile_describe.add_argument( - "--auto", - action="store_true", - help="Auto-generate description via the auxiliary LLM " - "(uses auxiliary.profile_describer)", - ) - profile_describe.add_argument( - "--overwrite", - action="store_true", - help="With --auto, replace user-authored descriptions too (default: only " - "fill in missing or previously-auto descriptions)", - ) - profile_describe.add_argument( - "--all", - dest="all_missing", - action="store_true", - help="With --auto, run on every profile missing a description", - ) - - profile_show = profile_subparsers.add_parser("show", help="Show profile details") - profile_show.add_argument("profile_name", help="Profile to show") - - profile_alias = profile_subparsers.add_parser( - "alias", help="Manage wrapper scripts" - ) - profile_alias.add_argument("profile_name", help="Profile name") - profile_alias.add_argument( - "--remove", action="store_true", help="Remove the wrapper script" - ) - profile_alias.add_argument( - "--name", - dest="alias_name", - metavar="NAME", - help="Custom alias name (default: profile name)", - ) - - profile_rename = profile_subparsers.add_parser("rename", help="Rename a profile") - profile_rename.add_argument("old_name", help="Current profile name") - profile_rename.add_argument("new_name", help="New profile name") - - profile_export = profile_subparsers.add_parser( - "export", help="Export a profile to archive" - ) - profile_export.add_argument("profile_name", help="Profile to export") - profile_export.add_argument( - "-o", "--output", default=None, help="Output file (default: .tar.gz)" - ) - - profile_import = profile_subparsers.add_parser( - "import", help="Import a profile from archive" - ) - profile_import.add_argument("archive", help="Path to .tar.gz archive") - profile_import.add_argument( - "--name", - dest="import_name", - metavar="NAME", - help="Profile name (default: inferred from archive)", - ) - - # ---------- Distribution subcommands (issue #20456) ---------- - profile_install = profile_subparsers.add_parser( - "install", - help="Install a profile distribution from a git URL or local directory", - description=( - "Install a Hermes profile distribution. SOURCE can be a git URL " - "(github.com/user/repo, https://..., git@...) or a local " - "directory containing distribution.yaml at its root." - ), - ) - profile_install.add_argument( - "source", - help="Distribution source (git URL or local directory)", - ) - profile_install.add_argument( - "--name", dest="install_name", metavar="NAME", - help="Override profile name (default: read from manifest)", - ) - profile_install.add_argument( - "--alias", action="store_true", - help="Create a shell wrapper alias for the installed profile", - ) - profile_install.add_argument( - "--force", action="store_true", - help="Overwrite an existing profile of the same name (user data preserved)", - ) - profile_install.add_argument( - "-y", "--yes", action="store_true", - help="Skip manifest preview confirmation", - ) - - profile_update = profile_subparsers.add_parser( - "update", - help="Re-pull a distribution and apply updates (user data preserved)", - description=( - "Fetch the distribution from its recorded source and overwrite " - "distribution-owned files (SOUL.md, skills/, cron/, mcp.json). " - "User data (memories, sessions, auth, .env) is never touched. " - "config.yaml is preserved unless --force-config is passed." - ), - ) - profile_update.add_argument("profile_name", help="Profile to update") - profile_update.add_argument( - "--force-config", action="store_true", - help="Also overwrite config.yaml (normally preserved to keep user overrides)", - ) - profile_update.add_argument( - "-y", "--yes", action="store_true", - help="Skip confirmation", - ) - - profile_info = profile_subparsers.add_parser( - "info", - help="Show a profile's distribution manifest (version, requirements, source)", - ) - profile_info.add_argument("profile_name", help="Profile to inspect") - - profile_parser.set_defaults(func=cmd_profile) + build_profile_parser(subparsers, cmd_profile=cmd_profile) # ========================================================================= # completion command diff --git a/hermes_cli/subcommands/gateway.py b/hermes_cli/subcommands/gateway.py new file mode 100644 index 00000000000..e6bd0ba9907 --- /dev/null +++ b/hermes_cli/subcommands/gateway.py @@ -0,0 +1,256 @@ +"""``hermes gateway`` and ``hermes proxy`` subcommand parsers. + +Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2). +Both parsers are built together because they shared one inline block (the +``gateway`` section also defined ``proxy``). Handlers injected to avoid +importing ``main``. +""" + +from __future__ import annotations + +import argparse +from typing import Callable + +from hermes_cli.subcommands._shared import add_accept_hooks_flag + + +def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callable) -> None: + """Attach the ``gateway`` and ``proxy`` subcommands to ``subparsers``.""" + # ========================================================================= + # gateway command + # ========================================================================= + gateway_parser = subparsers.add_parser( + "gateway", + help="Messaging gateway management", + description="Manage the messaging gateway (Telegram, Discord, WhatsApp, Weixin, and more)", + ) + gateway_subparsers = gateway_parser.add_subparsers(dest="gateway_command") + + # gateway run (default) + gateway_run = gateway_subparsers.add_parser( + "run", help="Run gateway in foreground (recommended for WSL, Docker, Termux)" + ) + gateway_run.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="Increase stderr log verbosity (-v=INFO, -vv=DEBUG)", + ) + gateway_run.add_argument( + "-q", "--quiet", action="store_true", help="Suppress all stderr log output" + ) + gateway_run.add_argument( + "--replace", + action="store_true", + help="Replace any existing gateway instance (useful for systemd)", + ) + gateway_run.add_argument( + "--no-supervise", + action="store_true", + help=( + "Inside the s6-overlay Docker image, normally `gateway run` is " + "automatically redirected to the supervised s6 service (so the " + "gateway gets auto-restart on crash, plus a supervised dashboard " + "if HERMES_DASHBOARD is set). Pass --no-supervise to opt out and " + "get the historical pre-s6 foreground behavior: the gateway is " + "the container's main process and the container exits with the " + "gateway's exit code. No effect outside an s6 container." + ), + ) + add_accept_hooks_flag(gateway_run) + add_accept_hooks_flag(gateway_parser) + + # gateway start + gateway_start = gateway_subparsers.add_parser( + "start", help="Start the installed systemd/launchd background service" + ) + gateway_start.add_argument( + "--system", + action="store_true", + help="Target the Linux system-level gateway service", + ) + gateway_start.add_argument( + "--all", + action="store_true", + help="Kill ALL stale gateway processes across all profiles before starting", + ) + + # gateway stop + gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service") + gateway_stop.add_argument( + "--system", + action="store_true", + help="Target the Linux system-level gateway service", + ) + gateway_stop.add_argument( + "--all", + action="store_true", + help="Stop ALL gateway processes across all profiles", + ) + + # gateway restart + gateway_restart = gateway_subparsers.add_parser( + "restart", help="Restart gateway service" + ) + gateway_restart.add_argument( + "--system", + action="store_true", + help="Target the Linux system-level gateway service", + ) + gateway_restart.add_argument( + "--all", + action="store_true", + help="Kill ALL gateway processes across all profiles before restarting", + ) + + # gateway status + gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status") + gateway_status.add_argument("--deep", action="store_true", help="Deep status check") + gateway_status.add_argument( + "-l", + "--full", + action="store_true", + help="Show full, untruncated service/log output where supported", + ) + gateway_status.add_argument( + "--system", + action="store_true", + help="Target the Linux system-level gateway service", + ) + + # gateway install + gateway_install = gateway_subparsers.add_parser( + "install", help="Install gateway as a systemd/launchd background service" + ) + gateway_install.add_argument("--force", action="store_true", help="Force reinstall") + gateway_install.add_argument( + "--system", + action="store_true", + help="Install as a Linux system-level service (starts at boot)", + ) + gateway_install.add_argument( + "--run-as-user", + dest="run_as_user", + help="User account the Linux system service should run as", + ) + gateway_install.add_argument( + "--start-now", + dest="start_now", + action="store_true", + default=None, + help=argparse.SUPPRESS, + ) + gateway_install.add_argument( + "--no-start-now", + dest="start_now", + action="store_false", + help=argparse.SUPPRESS, + ) + gateway_install.add_argument( + "--start-on-login", + dest="start_on_login", + action="store_true", + default=None, + help=argparse.SUPPRESS, + ) + gateway_install.add_argument( + "--no-start-on-login", + dest="start_on_login", + action="store_false", + help=argparse.SUPPRESS, + ) + gateway_install.add_argument( + "--elevated-handoff", + dest="elevated_handoff", + action="store_true", + help=argparse.SUPPRESS, + ) + + # gateway uninstall + gateway_uninstall = gateway_subparsers.add_parser( + "uninstall", help="Uninstall gateway service" + ) + gateway_uninstall.add_argument( + "--system", + action="store_true", + help="Target the Linux system-level gateway service", + ) + + # gateway list + gateway_subparsers.add_parser("list", help="List all profiles and their gateway status") + + # gateway setup + gateway_subparsers.add_parser("setup", help="Configure messaging platforms") + + # gateway migrate-legacy + gateway_migrate_legacy = gateway_subparsers.add_parser( + "migrate-legacy", + help="Remove legacy hermes.service units from pre-rename installs", + description=( + "Stop, disable, and remove legacy Hermes gateway unit files " + "(e.g. hermes.service) left over from older installs. Profile " + "units (hermes-gateway-.service) and unrelated " + "third-party services are never touched." + ), + ) + gateway_migrate_legacy.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + help="List what would be removed without doing it", + ) + gateway_migrate_legacy.add_argument( + "-y", + "--yes", + dest="yes", + action="store_true", + help="Skip the confirmation prompt", + ) + + # ========================================================================= + # proxy command — local OpenAI-compatible proxy that attaches the user's + # OAuth-authenticated provider credentials to outbound requests. Lets + # external apps (OpenViking, Karakeep, Open WebUI, ...) ride a logged-in + # subscription without copy-pasting static API keys. + # ========================================================================= + proxy_parser = subparsers.add_parser( + "proxy", + help="Local OpenAI-compatible proxy to OAuth providers", + description=( + "Run a local HTTP server that forwards OpenAI-compatible requests " + "to an OAuth-authenticated provider (e.g. Nous Portal). External " + "apps can point at the proxy with any bearer token; the proxy " + "attaches your real credentials." + ), + ) + proxy_subparsers = proxy_parser.add_subparsers(dest="proxy_command") + + proxy_start = proxy_subparsers.add_parser( + "start", help="Run the proxy in the foreground" + ) + proxy_start.add_argument( + "--provider", + default="nous", + help="Upstream provider: nous or xai (default: nous). See `hermes proxy providers`.", + ) + proxy_start.add_argument( + "--host", + default=None, + help="Bind address (default: 127.0.0.1). Use 0.0.0.0 to expose on LAN.", + ) + proxy_start.add_argument( + "--port", + type=int, + default=None, + help="Bind port (default: 8645)", + ) + + proxy_subparsers.add_parser( + "status", help="Show which proxy upstreams are ready" + ) + proxy_subparsers.add_parser( + "providers", help="List available proxy upstream providers" + ) + proxy_parser.set_defaults(func=cmd_proxy) + gateway_parser.set_defaults(func=cmd_gateway) diff --git a/hermes_cli/subcommands/profile.py b/hermes_cli/subcommands/profile.py new file mode 100644 index 00000000000..5c6f98a032e --- /dev/null +++ b/hermes_cli/subcommands/profile.py @@ -0,0 +1,203 @@ +"""``hermes profile`` 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_profile_parser(subparsers, *, cmd_profile: Callable) -> None: + """Attach the ``profile`` subcommand to ``subparsers``.""" + # ========================================================================= + # profile command + # ========================================================================= + profile_parser = subparsers.add_parser( + "profile", + help="Manage profiles — multiple isolated Hermes instances", + ) + profile_subparsers = profile_parser.add_subparsers(dest="profile_action") + + profile_subparsers.add_parser("list", help="List all profiles") + profile_use = profile_subparsers.add_parser( + "use", help="Set sticky default profile" + ) + profile_use.add_argument("profile_name", help="Profile name (or 'default')") + + profile_create = profile_subparsers.add_parser( + "create", help="Create a new profile" + ) + profile_create.add_argument( + "profile_name", help="Profile name (lowercase, alphanumeric)" + ) + profile_create.add_argument( + "--clone", + action="store_true", + help="Copy config.yaml, .env, SOUL.md from active profile", + ) + profile_create.add_argument( + "--clone-all", + action="store_true", + help="Full copy of active profile (all state)", + ) + profile_create.add_argument( + "--clone-from", + metavar="SOURCE", + help="Source profile to clone from (default: active)", + ) + profile_create.add_argument( + "--no-alias", action="store_true", help="Skip wrapper script creation" + ) + profile_create.add_argument( + "--no-skills", + action="store_true", + help="Create an empty profile with no bundled skills (opts out of `hermes update` skill sync)", + ) + profile_create.add_argument( + "--description", + default=None, + help="One- or two-sentence description of what this profile is good at. " + "Used by the kanban decomposer to route tasks based on role instead " + "of profile name alone. Skip and add later via `hermes profile describe`.", + ) + + profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile") + profile_delete.add_argument("profile_name", help="Profile to delete") + profile_delete.add_argument( + "-y", "--yes", action="store_true", help="Skip confirmation prompt" + ) + + profile_describe = profile_subparsers.add_parser( + "describe", + help="Read or set a profile's description (used by the kanban orchestrator)", + ) + profile_describe.add_argument( + "profile_name", + nargs="?", + default=None, + help="Profile to describe (omit + use --all --auto to sweep)", + ) + profile_describe.add_argument( + "--text", + default=None, + help="Set description to this exact text (overwrites any existing description)", + ) + profile_describe.add_argument( + "--auto", + action="store_true", + help="Auto-generate description via the auxiliary LLM " + "(uses auxiliary.profile_describer)", + ) + profile_describe.add_argument( + "--overwrite", + action="store_true", + help="With --auto, replace user-authored descriptions too (default: only " + "fill in missing or previously-auto descriptions)", + ) + profile_describe.add_argument( + "--all", + dest="all_missing", + action="store_true", + help="With --auto, run on every profile missing a description", + ) + + profile_show = profile_subparsers.add_parser("show", help="Show profile details") + profile_show.add_argument("profile_name", help="Profile to show") + + profile_alias = profile_subparsers.add_parser( + "alias", help="Manage wrapper scripts" + ) + profile_alias.add_argument("profile_name", help="Profile name") + profile_alias.add_argument( + "--remove", action="store_true", help="Remove the wrapper script" + ) + profile_alias.add_argument( + "--name", + dest="alias_name", + metavar="NAME", + help="Custom alias name (default: profile name)", + ) + + profile_rename = profile_subparsers.add_parser("rename", help="Rename a profile") + profile_rename.add_argument("old_name", help="Current profile name") + profile_rename.add_argument("new_name", help="New profile name") + + profile_export = profile_subparsers.add_parser( + "export", help="Export a profile to archive" + ) + profile_export.add_argument("profile_name", help="Profile to export") + profile_export.add_argument( + "-o", "--output", default=None, help="Output file (default: .tar.gz)" + ) + + profile_import = profile_subparsers.add_parser( + "import", help="Import a profile from archive" + ) + profile_import.add_argument("archive", help="Path to .tar.gz archive") + profile_import.add_argument( + "--name", + dest="import_name", + metavar="NAME", + help="Profile name (default: inferred from archive)", + ) + + # ---------- Distribution subcommands (issue #20456) ---------- + profile_install = profile_subparsers.add_parser( + "install", + help="Install a profile distribution from a git URL or local directory", + description=( + "Install a Hermes profile distribution. SOURCE can be a git URL " + "(github.com/user/repo, https://..., git@...) or a local " + "directory containing distribution.yaml at its root." + ), + ) + profile_install.add_argument( + "source", + help="Distribution source (git URL or local directory)", + ) + profile_install.add_argument( + "--name", dest="install_name", metavar="NAME", + help="Override profile name (default: read from manifest)", + ) + profile_install.add_argument( + "--alias", action="store_true", + help="Create a shell wrapper alias for the installed profile", + ) + profile_install.add_argument( + "--force", action="store_true", + help="Overwrite an existing profile of the same name (user data preserved)", + ) + profile_install.add_argument( + "-y", "--yes", action="store_true", + help="Skip manifest preview confirmation", + ) + + profile_update = profile_subparsers.add_parser( + "update", + help="Re-pull a distribution and apply updates (user data preserved)", + description=( + "Fetch the distribution from its recorded source and overwrite " + "distribution-owned files (SOUL.md, skills/, cron/, mcp.json). " + "User data (memories, sessions, auth, .env) is never touched. " + "config.yaml is preserved unless --force-config is passed." + ), + ) + profile_update.add_argument("profile_name", help="Profile to update") + profile_update.add_argument( + "--force-config", action="store_true", + help="Also overwrite config.yaml (normally preserved to keep user overrides)", + ) + profile_update.add_argument( + "-y", "--yes", action="store_true", + help="Skip confirmation", + ) + + profile_info = profile_subparsers.add_parser( + "info", + help="Show a profile's distribution manifest (version, requirements, source)", + ) + profile_info.add_argument("profile_name", help="Profile to inspect") + + profile_parser.set_defaults(func=cmd_profile) diff --git a/tests/hermes_cli/test_subcommands_profile_gateway.py b/tests/hermes_cli/test_subcommands_profile_gateway.py new file mode 100644 index 00000000000..0be0a7478fd --- /dev/null +++ b/tests/hermes_cli/test_subcommands_profile_gateway.py @@ -0,0 +1,83 @@ +"""Unit tests for extracted subcommand parser builders (profile, gateway). + +Confirms the builders attach the same subactions and ``func=`` dispatch that +lived inline in ``main()`` before the god-file Phase 2 extraction. +""" + +from __future__ import annotations + +import argparse + +from hermes_cli.subcommands.gateway import build_gateway_parser +from hermes_cli.subcommands.profile import build_profile_parser + + +def _h_gateway(args): # pragma: no cover - identity only + return "gateway" + + +def _h_proxy(args): # pragma: no cover - identity only + return "proxy" + + +def _h_profile(args): # pragma: no cover - identity only + return "profile" + + +def _profile_parser(): + p = argparse.ArgumentParser(prog="hermes") + sub = p.add_subparsers(dest="command") + build_profile_parser(sub, cmd_profile=_h_profile) + return p + + +def _gateway_parser(): + p = argparse.ArgumentParser(prog="hermes") + sub = p.add_subparsers(dest="command") + build_gateway_parser(sub, cmd_gateway=_h_gateway, cmd_proxy=_h_proxy) + return p + + +def test_profile_subactions_and_dispatch(): + p = _profile_parser() + ns = p.parse_args(["profile", "list"]) + assert ns.command == "profile" + assert ns.profile_action == "list" + assert ns.func is _h_profile + # a representative arg-taking subaction + ns2 = p.parse_args(["profile", "show", "work"]) + assert ns2.profile_action == "show" + + +def test_profile_has_expected_actions(): + p = _profile_parser() + # Map each subaction to a minimal valid argv suffix. + cases = { + "list": [], + "use": ["work"], + "create": ["work"], + "delete": ["work"], + "show": ["work"], + "rename": ["old", "new"], + "export": ["work"], + "import": ["/tmp/x.zip"], + } + for action, extra in cases.items(): + ns = p.parse_args(["profile", action, *extra]) + assert ns.profile_action == action + + +def test_gateway_and_proxy_dispatch(): + p = _gateway_parser() + gw = p.parse_args(["gateway", "run"]) + assert gw.command == "gateway" + assert gw.func is _h_gateway + px = p.parse_args(["proxy"]) + assert px.command == "proxy" + assert px.func is _h_proxy + + +def test_gateway_accept_hooks_flag(): + p = _gateway_parser() + ns = p.parse_args(["gateway", "run", "--accept-hooks"]) + assert ns.accept_hooks is True