diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4945a375cf4..5252663878c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -262,18 +262,8 @@ from pathlib import Path from typing import Optional -def _add_accept_hooks_flag(parser) -> None: - """Attach the ``--accept-hooks`` flag. Shared across every agent - subparser so the flag works regardless of CLI position.""" - parser.add_argument( - "--accept-hooks", - action="store_true", - default=argparse.SUPPRESS, - help=( - "Auto-approve unseen shell hooks without a TTY prompt " - "(equivalent to HERMES_ACCEPT_HOOKS=1 / hooks_auto_accept: true)." - ), - ) +from hermes_cli.subcommands._shared import add_accept_hooks_flag as _add_accept_hooks_flag +from hermes_cli.subcommands.cron import build_cron_parser def _require_tty(command_name: str) -> None: @@ -13596,163 +13586,9 @@ def main(): status_parser.set_defaults(func=cmd_status) # ========================================================================= - # cron command + # cron command (parser built in hermes_cli/subcommands/cron.py) # ========================================================================= - cron_parser = subparsers.add_parser( - "cron", help="Cron job management", description="Manage scheduled tasks" - ) - cron_subparsers = cron_parser.add_subparsers(dest="cron_command") - - # cron list - cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs") - cron_list.add_argument("--all", action="store_true", help="Include disabled jobs") - - # cron create/add - cron_create = cron_subparsers.add_parser( - "create", aliases=["add"], help="Create a scheduled job" - ) - cron_create.add_argument( - "schedule", help="Schedule like '30m', 'every 2h', or '0 9 * * *'" - ) - cron_create.add_argument( - "prompt", nargs="?", help="Optional self-contained prompt or task instruction" - ) - cron_create.add_argument("--name", help="Optional human-friendly job name") - cron_create.add_argument( - "--deliver", - help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id", - ) - cron_create.add_argument("--repeat", type=int, help="Optional repeat count") - cron_create.add_argument( - "--skill", - dest="skills", - action="append", - help="Attach a skill. Repeat to add multiple skills.", - ) - cron_create.add_argument( - "--script", - help=( - "Path to a script under ~/.hermes/scripts/. Default mode: " - "script stdout is injected into the agent's prompt each run. " - "With --no-agent: the script IS the job and its stdout is " - "delivered verbatim. .sh/.bash files run via bash, everything " - "else via Python." - ), - ) - cron_create.add_argument( - "--no-agent", - dest="no_agent", - action="store_true", - default=False, - help=( - "Skip the LLM entirely — run --script on schedule and deliver " - "its stdout directly. Empty stdout = silent. Classic watchdog " - "pattern (memory alerts, disk alerts, CI pings)." - ), - ) - cron_create.add_argument( - "--workdir", - help="Absolute path for the job to run from. Injects AGENTS.md / CLAUDE.md / .cursorrules from that directory and uses it as the cwd for terminal/file/code_exec tools. Omit to preserve old behaviour (no project context files).", - ) - cron_create.add_argument( - "--profile", - help="Hermes profile name to run the job under. Use 'default' for the root profile. Named profiles must already exist. Omit to preserve the scheduler's existing profile.", - ) - - # cron edit - cron_edit = cron_subparsers.add_parser( - "edit", help="Edit an existing scheduled job" - ) - cron_edit.add_argument("job_id", help="Job ID to edit") - cron_edit.add_argument("--schedule", help="New schedule") - cron_edit.add_argument("--prompt", help="New prompt/task instruction") - cron_edit.add_argument("--name", help="New job name") - cron_edit.add_argument("--deliver", help="New delivery target") - cron_edit.add_argument("--repeat", type=int, help="New repeat count") - cron_edit.add_argument( - "--skill", - dest="skills", - action="append", - help="Replace the job's skills with this set. Repeat to attach multiple skills.", - ) - cron_edit.add_argument( - "--add-skill", - dest="add_skills", - action="append", - help="Append a skill without replacing the existing list. Repeatable.", - ) - cron_edit.add_argument( - "--remove-skill", - dest="remove_skills", - action="append", - help="Remove a specific attached skill. Repeatable.", - ) - cron_edit.add_argument( - "--clear-skills", - action="store_true", - help="Remove all attached skills from the job", - ) - cron_edit.add_argument( - "--script", - help=( - "Path to a script under ~/.hermes/scripts/. Pass empty string to clear. " - "With --no-agent the script IS the job; otherwise its stdout is " - "injected into the agent's prompt each run." - ), - ) - cron_edit.add_argument( - "--no-agent", - dest="no_agent", - action="store_const", - const=True, - default=None, - help=( - "Enable no-agent mode on this job (requires --script or an " - "existing script on the job)." - ), - ) - cron_edit.add_argument( - "--agent", - dest="no_agent", - action="store_const", - const=False, - help="Disable no-agent mode on this job (reverts to LLM-driven execution).", - ) - cron_edit.add_argument( - "--workdir", - help="Absolute path for the job to run from (injects AGENTS.md etc. and sets terminal cwd). Pass empty string to clear.", - ) - cron_edit.add_argument( - "--profile", - help="Hermes profile name to run the job under. Use 'default' for the root profile. Pass empty string to clear.", - ) - - # lifecycle actions - cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job") - cron_pause.add_argument("job_id", help="Job ID to pause") - - cron_resume = cron_subparsers.add_parser("resume", help="Resume a paused job") - cron_resume.add_argument("job_id", help="Job ID to resume") - - cron_run = cron_subparsers.add_parser( - "run", help="Run a job on the next scheduler tick" - ) - cron_run.add_argument("job_id", help="Job ID to trigger") - _add_accept_hooks_flag(cron_run) - - cron_remove = cron_subparsers.add_parser( - "remove", aliases=["rm", "delete"], help="Remove a scheduled job" - ) - cron_remove.add_argument("job_id", help="Job ID to remove") - - # cron status - cron_subparsers.add_parser("status", help="Check if cron scheduler is running") - - # cron tick (mostly for debugging) - cron_tick = cron_subparsers.add_parser("tick", help="Run due jobs once and exit") - _add_accept_hooks_flag(cron_tick) - _add_accept_hooks_flag(cron_parser) - cron_parser.set_defaults(func=cmd_cron) + build_cron_parser(subparsers, cmd_cron=cmd_cron) # ========================================================================= # webhook command diff --git a/hermes_cli/subcommands/__init__.py b/hermes_cli/subcommands/__init__.py new file mode 100644 index 00000000000..3a39f3ce9cf --- /dev/null +++ b/hermes_cli/subcommands/__init__.py @@ -0,0 +1,18 @@ +"""CLI subcommand parser builders for ``hermes ``. + +``hermes_cli/main.py:main()`` historically built the entire argparse tree +inline — 179 ``add_parser`` calls across ~26 subcommand groups, all wedged +into one 3,300-line function. This package breaks that tree apart: each +subcommand group owns a ``build__parser(subparsers, ...)`` function in +its own module, and ``main()`` calls those builders instead of inlining the +argument definitions. + +Handlers (the ``cmd_*`` functions) still live in ``main.py`` for now and are +dependency-injected into the builders so these modules never import ``main`` +(which would create a cycle). Shared parser helpers live in +``_shared.py``. + +Part of the god-file decomposition plan (Phase 2). +""" + +from __future__ import annotations diff --git a/hermes_cli/subcommands/_shared.py b/hermes_cli/subcommands/_shared.py new file mode 100644 index 00000000000..c99178668c0 --- /dev/null +++ b/hermes_cli/subcommands/_shared.py @@ -0,0 +1,29 @@ +"""Shared parser helpers used across multiple CLI subcommand builders. + +These were module-level helpers in ``hermes_cli/main.py``. They are pulled +into a neutral module so both ``main.py`` and every +``hermes_cli/subcommands/.py`` builder can import them without an +import cycle. ``main.py`` re-exports them for backwards compatibility, so +existing references keep working. +""" + +from __future__ import annotations + +import argparse + + +def add_accept_hooks_flag(parser: argparse.ArgumentParser) -> None: + """Attach the ``--accept-hooks`` flag. + + Shared across every agent subparser so the flag works regardless of CLI + position. + """ + parser.add_argument( + "--accept-hooks", + action="store_true", + default=argparse.SUPPRESS, + help=( + "Auto-approve unseen shell hooks without a TTY prompt " + "(equivalent to HERMES_ACCEPT_HOOKS=1 / hooks_auto_accept: true)." + ), + ) diff --git a/hermes_cli/subcommands/cron.py b/hermes_cli/subcommands/cron.py new file mode 100644 index 00000000000..33dd10158f3 --- /dev/null +++ b/hermes_cli/subcommands/cron.py @@ -0,0 +1,171 @@ +"""``hermes cron`` subcommand parser. + +Extracted verbatim from ``hermes_cli/main.py:main()`` — same arguments, same +``func=cmd_cron`` dispatch. The handler is injected so this module does not +import ``main`` (cycle avoidance). +""" + +from __future__ import annotations + +from typing import Callable + +from hermes_cli.subcommands._shared import add_accept_hooks_flag + + +def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None: + """Attach the ``cron`` subcommand (and its sub-actions) to ``subparsers``.""" + cron_parser = subparsers.add_parser( + "cron", help="Cron job management", description="Manage scheduled tasks" + ) + cron_subparsers = cron_parser.add_subparsers(dest="cron_command") + + # cron list + cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs") + cron_list.add_argument("--all", action="store_true", help="Include disabled jobs") + + # cron create/add + cron_create = cron_subparsers.add_parser( + "create", aliases=["add"], help="Create a scheduled job" + ) + cron_create.add_argument( + "schedule", help="Schedule like '30m', 'every 2h', or '0 9 * * *'" + ) + cron_create.add_argument( + "prompt", nargs="?", help="Optional self-contained prompt or task instruction" + ) + cron_create.add_argument("--name", help="Optional human-friendly job name") + cron_create.add_argument( + "--deliver", + help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id", + ) + cron_create.add_argument("--repeat", type=int, help="Optional repeat count") + cron_create.add_argument( + "--skill", + dest="skills", + action="append", + help="Attach a skill. Repeat to add multiple skills.", + ) + cron_create.add_argument( + "--script", + help=( + "Path to a script under ~/.hermes/scripts/. Default mode: " + "script stdout is injected into the agent's prompt each run. " + "With --no-agent: the script IS the job and its stdout is " + "delivered verbatim. .sh/.bash files run via bash, everything " + "else via Python." + ), + ) + cron_create.add_argument( + "--no-agent", + dest="no_agent", + action="store_true", + default=False, + help=( + "Skip the LLM entirely — run --script on schedule and deliver " + "its stdout directly. Empty stdout = silent. Classic watchdog " + "pattern (memory alerts, disk alerts, CI pings)." + ), + ) + cron_create.add_argument( + "--workdir", + help="Absolute path for the job to run from. Injects AGENTS.md / CLAUDE.md / .cursorrules from that directory and uses it as the cwd for terminal/file/code_exec tools. Omit to preserve old behaviour (no project context files).", + ) + cron_create.add_argument( + "--profile", + help="Hermes profile name to run the job under. Use 'default' for the root profile. Named profiles must already exist. Omit to preserve the scheduler's existing profile.", + ) + + # cron edit + cron_edit = cron_subparsers.add_parser( + "edit", help="Edit an existing scheduled job" + ) + cron_edit.add_argument("job_id", help="Job ID to edit") + cron_edit.add_argument("--schedule", help="New schedule") + cron_edit.add_argument("--prompt", help="New prompt/task instruction") + cron_edit.add_argument("--name", help="New job name") + cron_edit.add_argument("--deliver", help="New delivery target") + cron_edit.add_argument("--repeat", type=int, help="New repeat count") + cron_edit.add_argument( + "--skill", + dest="skills", + action="append", + help="Replace the job's skills with this set. Repeat to attach multiple skills.", + ) + cron_edit.add_argument( + "--add-skill", + dest="add_skills", + action="append", + help="Append a skill without replacing the existing list. Repeatable.", + ) + cron_edit.add_argument( + "--remove-skill", + dest="remove_skills", + action="append", + help="Remove a specific attached skill. Repeatable.", + ) + cron_edit.add_argument( + "--clear-skills", + action="store_true", + help="Remove all attached skills from the job", + ) + cron_edit.add_argument( + "--script", + help=( + "Path to a script under ~/.hermes/scripts/. Pass empty string to clear. " + "With --no-agent the script IS the job; otherwise its stdout is " + "injected into the agent's prompt each run." + ), + ) + cron_edit.add_argument( + "--no-agent", + dest="no_agent", + action="store_const", + const=True, + default=None, + help=( + "Enable no-agent mode on this job (requires --script or an " + "existing script on the job)." + ), + ) + cron_edit.add_argument( + "--agent", + dest="no_agent", + action="store_const", + const=False, + help="Disable no-agent mode on this job (reverts to LLM-driven execution).", + ) + cron_edit.add_argument( + "--workdir", + help="Absolute path for the job to run from (injects AGENTS.md etc. and sets terminal cwd). Pass empty string to clear.", + ) + cron_edit.add_argument( + "--profile", + help="Hermes profile name to run the job under. Use 'default' for the root profile. Pass empty string to clear.", + ) + + # lifecycle actions + cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job") + cron_pause.add_argument("job_id", help="Job ID to pause") + + cron_resume = cron_subparsers.add_parser("resume", help="Resume a paused job") + cron_resume.add_argument("job_id", help="Job ID to resume") + + cron_run = cron_subparsers.add_parser( + "run", help="Run a job on the next scheduler tick" + ) + cron_run.add_argument("job_id", help="Job ID to trigger") + add_accept_hooks_flag(cron_run) + + cron_remove = cron_subparsers.add_parser( + "remove", aliases=["rm", "delete"], help="Remove a scheduled job" + ) + cron_remove.add_argument("job_id", help="Job ID to remove") + + # cron status + cron_subparsers.add_parser("status", help="Check if cron scheduler is running") + + # cron tick (mostly for debugging) + cron_tick = cron_subparsers.add_parser("tick", help="Run due jobs once and exit") + add_accept_hooks_flag(cron_tick) + add_accept_hooks_flag(cron_parser) + cron_parser.set_defaults(func=cmd_cron) diff --git a/tests/hermes_cli/test_subcommands_cron.py b/tests/hermes_cli/test_subcommands_cron.py new file mode 100644 index 00000000000..e51a0bb6409 --- /dev/null +++ b/tests/hermes_cli/test_subcommands_cron.py @@ -0,0 +1,86 @@ +"""Unit tests for the extracted ``hermes cron`` parser builder. + +Confirms ``build_cron_parser`` wires up the same subactions, aliases, options, +and ``func=cmd_cron`` dispatch that lived inline in ``main()`` before the +god-file Phase 2 extraction. +""" + +from __future__ import annotations + +import argparse + +from hermes_cli.subcommands.cron import build_cron_parser + + +def _sentinel_handler(args): # pragma: no cover - only identity is asserted + return "cron-handler" + + +def _build(): + parser = argparse.ArgumentParser(prog="hermes") + subparsers = parser.add_subparsers(dest="command") + build_cron_parser(subparsers, cmd_cron=_sentinel_handler) + return parser + + +def test_cron_subactions_present(): + parser = _build() + for action in ("list", "create", "edit", "pause", "resume", "run", "remove", "status", "tick"): + ns = parser.parse_args(["cron", action] if action in ("list", "status", "tick") + else ["cron", action, "jobid"] if action in ("pause", "resume", "run", "remove", "edit") + else ["cron", "create", "30m"]) + assert ns.command == "cron" + assert ns.cron_command == action + + +def test_cron_aliases(): + parser = _build() + # create has alias "add" + ns = parser.parse_args(["cron", "add", "30m"]) + assert ns.cron_command == "add" + # remove has aliases rm / delete + for alias in ("rm", "delete"): + ns = parser.parse_args(["cron", alias, "jid"]) + assert ns.cron_command == alias + + +def test_cron_create_options(): + parser = _build() + ns = parser.parse_args([ + "cron", "create", "0 9 * * *", "do the thing", + "--name", "daily", "--deliver", "origin", "--repeat", "3", + "--skill", "a", "--skill", "b", "--no-agent", + "--workdir", "/tmp/x", "--profile", "work", + ]) + assert ns.schedule == "0 9 * * *" + assert ns.prompt == "do the thing" + assert ns.name == "daily" + assert ns.deliver == "origin" + assert ns.repeat == 3 + assert ns.skills == ["a", "b"] + assert ns.no_agent is True + assert ns.workdir == "/tmp/x" + assert ns.profile == "work" + + +def test_cron_edit_no_agent_tristate(): + parser = _build() + # --no-agent -> True, --agent -> False, neither -> None + assert parser.parse_args(["cron", "edit", "j", "--no-agent"]).no_agent is True + assert parser.parse_args(["cron", "edit", "j", "--agent"]).no_agent is False + assert parser.parse_args(["cron", "edit", "j"]).no_agent is None + + +def test_cron_dispatch_func_is_injected_handler(): + parser = _build() + ns = parser.parse_args(["cron", "list"]) + assert ns.func is _sentinel_handler + + +def test_cron_accept_hooks_flag_on_run_and_tick(): + parser = _build() + # --accept-hooks is suppressed-default; present only when passed. + ns = parser.parse_args(["cron", "run", "jid", "--accept-hooks"]) + assert ns.accept_hooks is True + ns2 = parser.parse_args(["cron", "tick", "--accept-hooks"]) + assert ns2.accept_hooks is True