mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
refactor(cli): extract hermes cron parser into hermes_cli/subcommands/ (god-file Phase 2)
Phase 2 of the god-file decomposition plan. main()'s argparse tree is 179 inline add_parser calls in one 3,297-line function. This establishes the hermes_cli/subcommands/ package and extracts the first group (cron) as the proof-of-pattern: - hermes_cli/subcommands/_shared.py: shared parser helpers (add_accept_hooks_flag), re-exported from main.py for backwards compat. - hermes_cli/subcommands/cron.py: build_cron_parser(subparsers, cmd_cron=...). Handler injected so the module never imports main (cycle avoidance). - main()'s ~155-line inline cron block becomes one build_cron_parser() call. Behavior-neutral: 'hermes cron create --help' output is byte-identical to origin/main. main() 3297 -> 3143 LOC. Validation: tests/hermes_cli/ 6466 passed / 0 failed under per-file process isolation; new test_subcommands_cron.py covers subactions, aliases, options, no-agent tristate, injected dispatch, and --accept-hooks.
This commit is contained in:
parent
54870847cb
commit
b2e6053243
5 changed files with 308 additions and 168 deletions
|
|
@ -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
|
||||
|
|
|
|||
18
hermes_cli/subcommands/__init__.py
Normal file
18
hermes_cli/subcommands/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""CLI subcommand parser builders for ``hermes <subcommand>``.
|
||||
|
||||
``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_<group>_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
|
||||
29
hermes_cli/subcommands/_shared.py
Normal file
29
hermes_cli/subcommands/_shared.py
Normal file
|
|
@ -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/<group>.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)."
|
||||
),
|
||||
)
|
||||
171
hermes_cli/subcommands/cron.py
Normal file
171
hermes_cli/subcommands/cron.py
Normal file
|
|
@ -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)
|
||||
86
tests/hermes_cli/test_subcommands_cron.py
Normal file
86
tests/hermes_cli/test_subcommands_cron.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue