diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5150bfa1a7..3befd835cd 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4033,7 +4033,7 @@ For more help on a command: chat_parser.add_argument( "-s", "--skills", action="append", - default=None, + default=argparse.SUPPRESS, help="Preload one or more skills for the session (repeat flag or comma-separate)" ) chat_parser.add_argument( @@ -4055,6 +4055,7 @@ For more help on a command: chat_parser.add_argument( "--resume", "-r", metavar="SESSION_ID", + default=argparse.SUPPRESS, help="Resume a previous session by ID (shown on exit)" ) chat_parser.add_argument( @@ -4062,14 +4063,14 @@ For more help on a command: dest="continue_last", nargs="?", const=True, - default=None, + default=argparse.SUPPRESS, metavar="SESSION_NAME", help="Resume a session by name, or the most recent if no name given" ) chat_parser.add_argument( "--worktree", "-w", action="store_true", - default=False, + default=argparse.SUPPRESS, help="Run in an isolated git worktree (for parallel agents on the same repo)" ) chat_parser.add_argument( @@ -4088,13 +4089,13 @@ For more help on a command: chat_parser.add_argument( "--yolo", action="store_true", - default=False, + default=argparse.SUPPRESS, help="Bypass all dangerous command approval prompts (use at your own risk)" ) chat_parser.add_argument( "--pass-session-id", action="store_true", - default=False, + default=argparse.SUPPRESS, help="Include the session ID in the agent's system prompt" ) chat_parser.add_argument( diff --git a/tests/hermes_cli/test_argparse_flag_propagation.py b/tests/hermes_cli/test_argparse_flag_propagation.py new file mode 100644 index 0000000000..388f3aef50 --- /dev/null +++ b/tests/hermes_cli/test_argparse_flag_propagation.py @@ -0,0 +1,172 @@ +"""Tests for parent→subparser flag propagation. + +When flags like --yolo, -w, -s exist on both the parent parser and the 'chat' +subparser, placing the flag BEFORE the subcommand (e.g. 'hermes --yolo chat') +must not silently drop the flag value. + +Regression test for: argparse subparser default=False overwriting parent's +parsed True when the same argument is defined on both parsers. + +Fix: chat subparser uses default=argparse.SUPPRESS for all duplicated flags, +so the subparser only sets the attribute when the user explicitly provides it. +""" + +import argparse +import os +import sys +from unittest.mock import patch + +import pytest + + +def _build_parser(): + """Build the hermes argument parser from the real code. + + We import the real main() and extract the parser it builds. + Since main() is a large function that does much more than parse args, + we replicate just the parser structure here to avoid side effects. + """ + parser = argparse.ArgumentParser(prog="hermes") + parser.add_argument("--resume", "-r", metavar="SESSION", default=None) + parser.add_argument( + "--continue", "-c", dest="continue_last", nargs="?", + const=True, default=None, metavar="SESSION_NAME", + ) + parser.add_argument("--worktree", "-w", action="store_true", default=False) + parser.add_argument("--skills", "-s", action="append", default=None) + parser.add_argument("--yolo", action="store_true", default=False) + parser.add_argument("--pass-session-id", action="store_true", default=False) + + subparsers = parser.add_subparsers(dest="command") + chat = subparsers.add_parser("chat") + # These MUST use argparse.SUPPRESS to avoid overwriting parent values + chat.add_argument("--yolo", action="store_true", + default=argparse.SUPPRESS) + chat.add_argument("--worktree", "-w", action="store_true", + default=argparse.SUPPRESS) + chat.add_argument("--skills", "-s", action="append", + default=argparse.SUPPRESS) + chat.add_argument("--pass-session-id", action="store_true", + default=argparse.SUPPRESS) + chat.add_argument("--resume", "-r", metavar="SESSION_ID", + default=argparse.SUPPRESS) + chat.add_argument( + "--continue", "-c", dest="continue_last", nargs="?", + const=True, default=argparse.SUPPRESS, metavar="SESSION_NAME", + ) + return parser + + +class TestFlagBeforeSubcommand: + """Flags placed before 'chat' must propagate through.""" + + def test_yolo_before_chat(self): + parser = _build_parser() + args = parser.parse_args(["--yolo", "chat"]) + assert getattr(args, "yolo", False) is True + + def test_worktree_before_chat(self): + parser = _build_parser() + args = parser.parse_args(["-w", "chat"]) + assert getattr(args, "worktree", False) is True + + def test_skills_before_chat(self): + parser = _build_parser() + args = parser.parse_args(["-s", "myskill", "chat"]) + assert getattr(args, "skills", None) == ["myskill"] + + def test_pass_session_id_before_chat(self): + parser = _build_parser() + args = parser.parse_args(["--pass-session-id", "chat"]) + assert getattr(args, "pass_session_id", False) is True + + def test_resume_before_chat(self): + parser = _build_parser() + args = parser.parse_args(["-r", "abc123", "chat"]) + assert getattr(args, "resume", None) == "abc123" + + +class TestFlagAfterSubcommand: + """Flags placed after 'chat' must still work.""" + + def test_yolo_after_chat(self): + parser = _build_parser() + args = parser.parse_args(["chat", "--yolo"]) + assert getattr(args, "yolo", False) is True + + def test_worktree_after_chat(self): + parser = _build_parser() + args = parser.parse_args(["chat", "-w"]) + assert getattr(args, "worktree", False) is True + + def test_skills_after_chat(self): + parser = _build_parser() + args = parser.parse_args(["chat", "-s", "myskill"]) + assert getattr(args, "skills", None) == ["myskill"] + + def test_resume_after_chat(self): + parser = _build_parser() + args = parser.parse_args(["chat", "-r", "abc123"]) + assert getattr(args, "resume", None) == "abc123" + + +class TestNoSubcommandDefaults: + """When no subcommand is given, flags must work and defaults must hold.""" + + def test_yolo_no_subcommand(self): + parser = _build_parser() + args = parser.parse_args(["--yolo"]) + assert args.yolo is True + assert args.command is None + + def test_defaults_no_flags(self): + parser = _build_parser() + args = parser.parse_args([]) + assert getattr(args, "yolo", False) is False + assert getattr(args, "worktree", False) is False + assert getattr(args, "skills", None) is None + assert getattr(args, "resume", None) is None + + def test_defaults_chat_no_flags(self): + parser = _build_parser() + args = parser.parse_args(["chat"]) + # With SUPPRESS, these fall through to parent defaults + assert getattr(args, "yolo", False) is False + assert getattr(args, "worktree", False) is False + assert getattr(args, "skills", None) is None + + +class TestYoloEnvVar: + """Verify --yolo sets HERMES_YOLO_MODE regardless of flag position. + + This tests the actual cmd_chat logic pattern (getattr → os.environ). + """ + + @pytest.fixture(autouse=True) + def _clean_env(self): + os.environ.pop("HERMES_YOLO_MODE", None) + yield + os.environ.pop("HERMES_YOLO_MODE", None) + + def _simulate_cmd_chat_yolo_check(self, args): + """Replicate the exact check from cmd_chat in main.py.""" + if getattr(args, "yolo", False): + os.environ["HERMES_YOLO_MODE"] = "1" + + def test_yolo_before_chat_sets_env(self): + parser = _build_parser() + args = parser.parse_args(["--yolo", "chat"]) + self._simulate_cmd_chat_yolo_check(args) + assert os.environ.get("HERMES_YOLO_MODE") == "1" + + def test_yolo_after_chat_sets_env(self): + parser = _build_parser() + args = parser.parse_args(["chat", "--yolo"]) + self._simulate_cmd_chat_yolo_check(args) + assert os.environ.get("HERMES_YOLO_MODE") == "1" + + def test_no_yolo_no_env(self): + parser = _build_parser() + args = parser.parse_args(["chat"]) + self._simulate_cmd_chat_yolo_check(args) + assert os.environ.get("HERMES_YOLO_MODE") is None