diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py new file mode 100644 index 00000000000..edea33b54b4 --- /dev/null +++ b/hermes_cli/_parser.py @@ -0,0 +1,367 @@ +""" +Top-level argparse construction for the hermes CLI. + +Lives in its own module so other modules (e.g. ``relaunch.py``) can +introspect the parser to discover which flags exist without running the +``main`` fn. + +Only the top-level parser and the ``chat`` subparser live here. Every other +subparser (model, gateway, sessions, …) is built inline in ``main.py`` +because its dispatch is tightly coupled to module-level ``cmd_*`` functions. +""" + +import argparse + + +# `--profile` / `-p` is consumed by ``main._apply_profile_override`` before +# argparse runs (it sets ``HERMES_HOME`` and strips itself from ``sys.argv``), +# so it isn't on the parser. Listed here so all "carry over on relaunch" +# metadata lives in one file. +PRE_ARGPARSE_INHERITED_FLAGS: list[tuple[str, bool]] = [ + ("--profile", True), + ("-p", True), +] + + +def _inherited_flag(parser, *args, **kwargs): + """Register a flag that ``hermes_cli.relaunch`` should carry over when + the CLI re-execs itself (e.g. after ``sessions browse`` picks a session, + or after the setup wizard launches chat). + + Equivalent to ``parser.add_argument(...)`` plus tagging the resulting + Action with ``inherit_on_relaunch = True`` so the relaunch table builder + can find it via introspection. + """ + action = parser.add_argument(*args, **kwargs) + action.inherit_on_relaunch = True + return action + + +_EPILOGUE = """ +Examples: + hermes Start interactive chat + hermes chat -q "Hello" Single query mode + hermes -c Resume the most recent session + hermes -c "my project" Resume a session by name (latest in lineage) + hermes --resume Resume a specific session by ID + hermes setup Run setup wizard + hermes logout Clear stored authentication + hermes auth add Add a pooled credential + hermes auth list List pooled credentials + hermes auth remove

Remove pooled credential by index, id, or label + hermes auth reset Clear exhaustion status for a provider + hermes model Select default model + hermes fallback [list] Show fallback provider chain + hermes fallback add Add a fallback provider (same picker as `hermes model`) + hermes fallback remove Remove a fallback provider from the chain + hermes config View configuration + hermes config edit Edit config in $EDITOR + hermes config set model gpt-4 Set a config value + hermes gateway Run messaging gateway + hermes -s hermes-agent-dev,github-auth + hermes -w Start in isolated git worktree + hermes gateway install Install gateway background service + hermes sessions list List past sessions + hermes sessions browse Interactive session picker + hermes sessions rename ID T Rename/title a session + hermes logs View agent.log (last 50 lines) + hermes logs -f Follow agent.log in real time + hermes logs errors View errors.log + hermes logs --since 1h Lines from the last hour + hermes debug share Upload debug report for support + hermes update Update to latest version + +For more help on a command: + hermes --help +""" + + +def build_top_level_parser(): + """Build the top-level parser, the subparsers action, and the ``chat`` subparser. + + Returns ``(parser, subparsers, chat_parser)``. The caller wires + ``chat_parser.set_defaults(func=cmd_chat)`` and continues registering + other subparsers via ``subparsers.add_parser(...)``. + """ + parser = argparse.ArgumentParser( + prog="hermes", + description="Hermes Agent - AI assistant with tool-calling capabilities", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=_EPILOGUE, + ) + + parser.add_argument( + "--version", "-V", action="store_true", help="Show version and exit" + ) + parser.add_argument( + "-z", + "--oneshot", + metavar="PROMPT", + default=None, + help=( + "One-shot mode: send a single prompt and print ONLY the final " + "response text to stdout. No banner, no spinner, no tool " + "previews, no session_id line. Tools, memory, rules, and " + "AGENTS.md in the CWD are loaded as normal; approvals are " + "auto-bypassed. Intended for scripts / pipes." + ), + ) + # --model / --provider are accepted at the top level so they can pair + # with -z without needing the `chat` subcommand. If neither -z nor a + # subcommand consumes them, they fall through harmlessly as None. + # Mirrors `hermes chat --model ... --provider ...` semantics. + _inherited_flag( + parser, + "-m", + "--model", + default=None, + help=( + "Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). " + "Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var." + ), + ) + _inherited_flag( + parser, + "--provider", + default=None, + help=( + "Provider override for this invocation (e.g. openrouter, anthropic). " + "Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var." + ), + ) + parser.add_argument( + "--resume", + "-r", + metavar="SESSION", + default=None, + help="Resume a previous session by ID or title", + ) + parser.add_argument( + "--continue", + "-c", + dest="continue_last", + nargs="?", + const=True, + default=None, + metavar="SESSION_NAME", + help="Resume a session by name, or the most recent if no name given", + ) + parser.add_argument( + "--worktree", + "-w", + action="store_true", + default=False, + help="Run in an isolated git worktree (for parallel agents)", + ) + _inherited_flag( + parser, + "--accept-hooks", + action="store_true", + default=False, + help=( + "Auto-approve any unseen shell hooks declared in config.yaml " + "without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or " + "hooks_auto_accept: true in config.yaml. Use on CI / headless " + "runs that can't prompt." + ), + ) + _inherited_flag( + parser, + "--skills", + "-s", + action="append", + default=None, + help="Preload one or more skills for the session (repeat flag or comma-separate)", + ) + _inherited_flag( + parser, + "--yolo", + action="store_true", + default=False, + help="Bypass all dangerous command approval prompts (use at your own risk)", + ) + _inherited_flag( + parser, + "--pass-session-id", + action="store_true", + default=False, + help="Include the session ID in the agent's system prompt", + ) + _inherited_flag( + parser, + "--ignore-user-config", + action="store_true", + default=False, + help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)", + ) + _inherited_flag( + parser, + "--ignore-rules", + action="store_true", + default=False, + help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills", + ) + _inherited_flag( + parser, + "--tui", + action="store_true", + default=False, + help="Launch the modern TUI instead of the classic REPL", + ) + _inherited_flag( + parser, + "--dev", + dest="tui_dev", + action="store_true", + default=False, + help="With --tui: run TypeScript sources via tsx (skip dist build)", + ) + + subparsers = parser.add_subparsers(dest="command", help="Command to run") + + # ========================================================================= + # chat command + # ========================================================================= + chat_parser = subparsers.add_parser( + "chat", + help="Interactive chat with the agent", + description="Start an interactive chat session with Hermes Agent", + ) + chat_parser.add_argument( + "-q", "--query", help="Single query (non-interactive mode)" + ) + chat_parser.add_argument( + "--image", help="Optional local image path to attach to a single query" + ) + _inherited_flag( + chat_parser, + "-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)", + ) + chat_parser.add_argument( + "-t", "--toolsets", help="Comma-separated toolsets to enable" + ) + _inherited_flag( + chat_parser, + "-s", + "--skills", + action="append", + default=argparse.SUPPRESS, + help="Preload one or more skills for the session (repeat flag or comma-separate)", + ) + _inherited_flag( + chat_parser, + "--provider", + # No `choices=` here: user-defined providers from config.yaml `providers:` + # are also valid values, and runtime resolution (resolve_runtime_provider) + # handles validation/error reporting consistently with the top-level + # `--provider` flag. + default=None, + help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.", + ) + chat_parser.add_argument( + "-v", "--verbose", action="store_true", help="Verbose output" + ) + chat_parser.add_argument( + "-Q", + "--quiet", + action="store_true", + help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.", + ) + 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( + "--continue", + "-c", + dest="continue_last", + nargs="?", + const=True, + 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=argparse.SUPPRESS, + help="Run in an isolated git worktree (for parallel agents on the same repo)", + ) + _inherited_flag( + chat_parser, + "--accept-hooks", + action="store_true", + default=argparse.SUPPRESS, + help=( + "Auto-approve any unseen shell hooks declared in config.yaml " + "without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and " + "hooks_auto_accept: in config.yaml)." + ), + ) + chat_parser.add_argument( + "--checkpoints", + action="store_true", + default=False, + help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)", + ) + chat_parser.add_argument( + "--max-turns", + type=int, + default=None, + metavar="N", + help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)", + ) + _inherited_flag( + chat_parser, + "--yolo", + action="store_true", + default=argparse.SUPPRESS, + help="Bypass all dangerous command approval prompts (use at your own risk)", + ) + _inherited_flag( + chat_parser, + "--pass-session-id", + action="store_true", + default=argparse.SUPPRESS, + help="Include the session ID in the agent's system prompt", + ) + _inherited_flag( + chat_parser, + "--ignore-user-config", + action="store_true", + default=argparse.SUPPRESS, + help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.", + ) + _inherited_flag( + chat_parser, + "--ignore-rules", + action="store_true", + default=argparse.SUPPRESS, + help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.", + ) + chat_parser.add_argument( + "--source", + default=None, + help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.", + ) + _inherited_flag( + chat_parser, + "--tui", + action="store_true", + default=False, + help="Launch the modern TUI instead of the classic REPL", + ) + _inherited_flag( + chat_parser, + "--dev", + dest="tui_dev", + action="store_true", + default=False, + help="With --tui: run TypeScript sources via tsx (skip dist build)", + ) + + return parser, subparsers, chat_parser diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6db283bd944..39de53c0546 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7764,302 +7764,9 @@ def cmd_logs(args): def main(): """Main entry point for hermes CLI.""" - parser = argparse.ArgumentParser( - prog="hermes", - description="Hermes Agent - AI assistant with tool-calling capabilities", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - hermes Start interactive chat - hermes chat -q "Hello" Single query mode - hermes -c Resume the most recent session - hermes -c "my project" Resume a session by name (latest in lineage) - hermes --resume Resume a specific session by ID - hermes setup Run setup wizard - hermes logout Clear stored authentication - hermes auth add Add a pooled credential - hermes auth list List pooled credentials - hermes auth remove

Remove pooled credential by index, id, or label - hermes auth reset Clear exhaustion status for a provider - hermes model Select default model - hermes fallback [list] Show fallback provider chain - hermes fallback add Add a fallback provider (same picker as `hermes model`) - hermes fallback remove Remove a fallback provider from the chain - hermes config View configuration - hermes config edit Edit config in $EDITOR - hermes config set model gpt-4 Set a config value - hermes gateway Run messaging gateway - hermes -s hermes-agent-dev,github-auth - hermes -w Start in isolated git worktree - hermes gateway install Install gateway background service - hermes sessions list List past sessions - hermes sessions browse Interactive session picker - hermes sessions rename ID T Rename/title a session - hermes logs View agent.log (last 50 lines) - hermes logs -f Follow agent.log in real time - hermes logs errors View errors.log - hermes logs --since 1h Lines from the last hour - hermes debug share Upload debug report for support - hermes update Update to latest version + from hermes_cli._parser import build_top_level_parser -For more help on a command: - hermes --help -""", - ) - - parser.add_argument( - "--version", "-V", action="store_true", help="Show version and exit" - ) - parser.add_argument( - "-z", - "--oneshot", - metavar="PROMPT", - default=None, - help=( - "One-shot mode: send a single prompt and print ONLY the final " - "response text to stdout. No banner, no spinner, no tool " - "previews, no session_id line. Tools, memory, rules, and " - "AGENTS.md in the CWD are loaded as normal; approvals are " - "auto-bypassed. Intended for scripts / pipes." - ), - ) - # --model / --provider are accepted at the top level so they can pair - # with -z without needing the `chat` subcommand. If neither -z nor a - # subcommand consumes them, they fall through harmlessly as None. - # Mirrors `hermes chat --model ... --provider ...` semantics. - parser.add_argument( - "-m", - "--model", - default=None, - help=( - "Model override for this invocation (e.g. anthropic/claude-sonnet-4.6). " - "Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_MODEL env var." - ), - ) - parser.add_argument( - "--provider", - default=None, - help=( - "Provider override for this invocation (e.g. openrouter, anthropic). " - "Applies to -z/--oneshot and --tui. Also settable via HERMES_INFERENCE_PROVIDER env var." - ), - ) - parser.add_argument( - "--resume", - "-r", - metavar="SESSION", - default=None, - help="Resume a previous session by ID or title", - ) - parser.add_argument( - "--continue", - "-c", - dest="continue_last", - nargs="?", - const=True, - default=None, - metavar="SESSION_NAME", - help="Resume a session by name, or the most recent if no name given", - ) - parser.add_argument( - "--worktree", - "-w", - action="store_true", - default=False, - help="Run in an isolated git worktree (for parallel agents)", - ) - parser.add_argument( - "--accept-hooks", - action="store_true", - default=False, - help=( - "Auto-approve any unseen shell hooks declared in config.yaml " - "without a TTY prompt. Equivalent to HERMES_ACCEPT_HOOKS=1 or " - "hooks_auto_accept: true in config.yaml. Use on CI / headless " - "runs that can't prompt." - ), - ) - parser.add_argument( - "--skills", - "-s", - action="append", - default=None, - help="Preload one or more skills for the session (repeat flag or comma-separate)", - ) - parser.add_argument( - "--yolo", - action="store_true", - default=False, - help="Bypass all dangerous command approval prompts (use at your own risk)", - ) - parser.add_argument( - "--pass-session-id", - action="store_true", - default=False, - help="Include the session ID in the agent's system prompt", - ) - parser.add_argument( - "--ignore-user-config", - action="store_true", - default=False, - help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded)", - ) - parser.add_argument( - "--ignore-rules", - action="store_true", - default=False, - help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills", - ) - parser.add_argument( - "--tui", - action="store_true", - default=False, - help="Launch the modern TUI instead of the classic REPL", - ) - parser.add_argument( - "--dev", - dest="tui_dev", - action="store_true", - default=False, - help="With --tui: run TypeScript sources via tsx (skip dist build)", - ) - - subparsers = parser.add_subparsers(dest="command", help="Command to run") - - # ========================================================================= - # chat command - # ========================================================================= - chat_parser = subparsers.add_parser( - "chat", - help="Interactive chat with the agent", - description="Start an interactive chat session with Hermes Agent", - ) - chat_parser.add_argument( - "-q", "--query", help="Single query (non-interactive mode)" - ) - chat_parser.add_argument( - "--image", help="Optional local image path to attach to a single query" - ) - chat_parser.add_argument( - "-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)" - ) - chat_parser.add_argument( - "-t", "--toolsets", help="Comma-separated toolsets to enable" - ) - chat_parser.add_argument( - "-s", - "--skills", - action="append", - default=argparse.SUPPRESS, - help="Preload one or more skills for the session (repeat flag or comma-separate)", - ) - chat_parser.add_argument( - "--provider", - # No `choices=` here: user-defined providers from config.yaml `providers:` - # are also valid values, and runtime resolution (resolve_runtime_provider) - # handles validation/error reporting consistently with the top-level - # `--provider` flag. - default=None, - help="Inference provider (default: auto). Built-in or a user-defined name from `providers:` in config.yaml.", - ) - chat_parser.add_argument( - "-v", "--verbose", action="store_true", help="Verbose output" - ) - chat_parser.add_argument( - "-Q", - "--quiet", - action="store_true", - help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.", - ) - 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( - "--continue", - "-c", - dest="continue_last", - nargs="?", - const=True, - 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=argparse.SUPPRESS, - help="Run in an isolated git worktree (for parallel agents on the same repo)", - ) - chat_parser.add_argument( - "--accept-hooks", - action="store_true", - default=argparse.SUPPRESS, - help=( - "Auto-approve any unseen shell hooks declared in config.yaml " - "without a TTY prompt (see also HERMES_ACCEPT_HOOKS env var and " - "hooks_auto_accept: in config.yaml)." - ), - ) - chat_parser.add_argument( - "--checkpoints", - action="store_true", - default=False, - help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)", - ) - chat_parser.add_argument( - "--max-turns", - type=int, - default=None, - metavar="N", - help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)", - ) - chat_parser.add_argument( - "--yolo", - action="store_true", - 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=argparse.SUPPRESS, - help="Include the session ID in the agent's system prompt", - ) - chat_parser.add_argument( - "--ignore-user-config", - action="store_true", - default=argparse.SUPPRESS, - help="Ignore ~/.hermes/config.yaml and fall back to built-in defaults (credentials in .env are still loaded). Useful for isolated CI runs, reproduction, and third-party integrations.", - ) - chat_parser.add_argument( - "--ignore-rules", - action="store_true", - default=argparse.SUPPRESS, - help="Skip auto-injection of AGENTS.md, SOUL.md, .cursorrules, memory, and preloaded skills. Combine with --ignore-user-config for a fully isolated run.", - ) - chat_parser.add_argument( - "--source", - default=None, - help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.", - ) - chat_parser.add_argument( - "--tui", - action="store_true", - default=False, - help="Launch the modern TUI instead of the classic REPL", - ) - chat_parser.add_argument( - "--dev", - dest="tui_dev", - action="store_true", - default=False, - help="With --tui: run TypeScript sources via tsx (skip dist build)", - ) + parser, subparsers, chat_parser = build_top_level_parser() chat_parser.set_defaults(func=cmd_chat) # ========================================================================= diff --git a/hermes_cli/relaunch.py b/hermes_cli/relaunch.py index 66f6d79718a..7d9e6473870 100644 --- a/hermes_cli/relaunch.py +++ b/hermes_cli/relaunch.py @@ -13,50 +13,60 @@ import shutil import sys from typing import Optional, Sequence - -# (option_string, takes_value) — flags whose presence (and value, where -# applicable) on the original argv must survive a self-relaunch. -_CRITICAL_FLAGS: list[tuple[str, bool]] = [ - ("--tui", False), - ("--dev", False), - ("--profile", True), - ("-p", True), - ("--model", True), - ("-m", True), - ("--provider", True), - ("--yolo", False), - ("--ignore-user-config", False), - ("--ignore-rules", False), - ("--pass-session-id", False), - ("--accept-hooks", False), - ("--worktree", False), - ("-w", False), - ("--skills", True), - ("-s", True), - ("--quiet", False), - ("-Q", False), - ("--verbose", False), - ("-v", False), - ("--source", True), -] +from hermes_cli._parser import ( + PRE_ARGPARSE_INHERITED_FLAGS, + build_top_level_parser, +) -def _extract_critical_flags(argv: Sequence[str]) -> list[str]: - """Pull out flags that affect session behaviour / UI mode.""" +def _build_inherited_flag_table() -> list[tuple[str, bool]]: + """Build the ``(option_string, takes_value)`` table of flags that must + survive a self-relaunch, by introspecting the real parser used by + ``hermes`` itself. + + A flag participates if its argparse Action carries + ``inherit_on_relaunch = True`` — set by ``_parser._inherited_flag``. + """ + parser, _subparsers, chat_parser = build_top_level_parser() + + table: list[tuple[str, bool]] = [] + seen: set[tuple[str, bool]] = set() + for p in (parser, chat_parser): + for action in p._actions: + if not action.option_strings: + continue # positional / no flag form + if not getattr(action, "inherit_on_relaunch", False): + continue + takes_value = action.nargs != 0 # store_true/false set nargs=0 + for opt in action.option_strings: + key = (opt, takes_value) + if key not in seen: + seen.add(key) + table.append(key) + + table.extend(PRE_ARGPARSE_INHERITED_FLAGS) + return table + + +_INHERITED_FLAGS_TABLE = _build_inherited_flag_table() + + +def _extract_inherited_flags(argv: Sequence[str]) -> list[str]: + """Pull out flags that should carry over into a self-relaunched hermes.""" flags: list[str] = [] i = 0 while i < len(argv): arg = argv[i] if "=" in arg: key = arg.split("=", 1)[0] - for flag, _ in _CRITICAL_FLAGS: + for flag, _ in _INHERITED_FLAGS_TABLE: if key == flag: flags.append(arg) break i += 1 continue - for flag, takes_value in _CRITICAL_FLAGS: + for flag, takes_value in _INHERITED_FLAGS_TABLE: if arg == flag: flags.append(arg) if takes_value and i + 1 < len(argv) and not argv[i + 1].startswith("-"): @@ -98,14 +108,15 @@ def resolve_hermes_bin() -> Optional[str]: def build_relaunch_argv( extra_args: Sequence[str], *, - preserve_critical: bool = True, + preserve_inherited: bool = True, original_argv: Optional[Sequence[str]] = None, ) -> list[str]: """Construct an argv list for replacing the current process with hermes. Args: extra_args: Arguments to append (e.g. ``["--resume", id]``). - preserve_critical: Whether to carry over UI / behaviour flags. + preserve_inherited: Whether to carry over UI / behaviour flags + tagged with ``inherit_on_relaunch`` in the parser. original_argv: The original argv to scan for flags (defaults to ``sys.argv[1:]``). """ @@ -118,8 +129,8 @@ def build_relaunch_argv( src = list(original_argv) if original_argv is not None else list(sys.argv[1:]) - if preserve_critical: - argv.extend(_extract_critical_flags(src)) + if preserve_inherited: + argv.extend(_extract_inherited_flags(src)) argv.extend(extra_args) return argv @@ -128,20 +139,20 @@ def build_relaunch_argv( def relaunch( extra_args: Sequence[str], *, - preserve_critical: bool = True, + preserve_inherited: bool = True, original_argv: Optional[Sequence[str]] = None, ) -> None: """Replace the current process with a fresh hermes invocation.""" new_argv = build_relaunch_argv( - extra_args, preserve_critical=preserve_critical, original_argv=original_argv + extra_args, preserve_inherited=preserve_inherited, original_argv=original_argv ) os.execvp(new_argv[0], new_argv) def relaunch_chat( *, - preserve_critical: bool = True, + preserve_inherited: bool = True, original_argv: Optional[Sequence[str]] = None, ) -> None: """Convenience wrapper: relaunch into ``hermes chat``.""" - relaunch(["chat"], preserve_critical=preserve_critical, original_argv=original_argv) + relaunch(["chat"], preserve_inherited=preserve_inherited, original_argv=original_argv) diff --git a/tests/hermes_cli/test_ignore_user_config_flags.py b/tests/hermes_cli/test_ignore_user_config_flags.py index 3d5336cfca7..60738779321 100644 --- a/tests/hermes_cli/test_ignore_user_config_flags.py +++ b/tests/hermes_cli/test_ignore_user_config_flags.py @@ -224,22 +224,21 @@ class TestArgparseFlagsRegistered: assert args.ignore_rules is True def test_main_py_registers_both_flags(self): - """E2E: the real hermes_cli/main.py parser accepts both flags. + """E2E: the real hermes parser accepts both flags.""" + from hermes_cli._parser import build_top_level_parser - We invoke the real argparse tree builder from hermes_cli.main. - """ - import hermes_cli.main as hm + parser, _subparsers, chat_parser = build_top_level_parser() + + top_dests = {a.dest for a in parser._actions} + chat_dests = {a.dest for a in chat_parser._actions} + assert "ignore_user_config" in top_dests + assert "ignore_rules" in top_dests + assert "ignore_user_config" in chat_dests + assert "ignore_rules" in chat_dests - # hm has a helper that builds the argparse tree inside main(). - # We can extract it by catching the SystemExit on --help. - # Simpler: just grep the source for the flag strings. Both approaches - # are brittle; we use a combined test. - import inspect - src = inspect.getsource(hm) - assert '"--ignore-user-config"' in src, \ - "chat subparser must register --ignore-user-config" - assert '"--ignore-rules"' in src, \ - "chat subparser must register --ignore-rules" # And the cmd_chat env-var wiring must be present + import inspect + import hermes_cli.main as hm + src = inspect.getsource(hm) assert "HERMES_IGNORE_USER_CONFIG" in src assert "HERMES_IGNORE_RULES" in src diff --git a/tests/hermes_cli/test_relaunch.py b/tests/hermes_cli/test_relaunch.py index 0052c7269cb..b57672fbed9 100644 --- a/tests/hermes_cli/test_relaunch.py +++ b/tests/hermes_cli/test_relaunch.py @@ -38,37 +38,69 @@ class TestResolveHermesBin: assert relaunch_mod.resolve_hermes_bin() is None -class TestExtractCriticalFlags: +class TestExtractInheritedFlags: def test_extracts_tui_and_dev(self): argv = ["--tui", "--dev", "chat"] - assert relaunch_mod._extract_critical_flags(argv) == ["--tui", "--dev"] + assert relaunch_mod._extract_inherited_flags(argv) == ["--tui", "--dev"] def test_extracts_profile_with_value(self): argv = ["--profile", "work", "chat"] - assert relaunch_mod._extract_critical_flags(argv) == ["--profile", "work"] + assert relaunch_mod._extract_inherited_flags(argv) == ["--profile", "work"] def test_extracts_short_p_with_value(self): argv = ["-p", "work"] - assert relaunch_mod._extract_critical_flags(argv) == ["-p", "work"] + assert relaunch_mod._extract_inherited_flags(argv) == ["-p", "work"] def test_extracts_equals_form(self): argv = ["--profile=work", "--model=anthropic/claude-sonnet-4"] - assert relaunch_mod._extract_critical_flags(argv) == [ + assert relaunch_mod._extract_inherited_flags(argv) == [ "--profile=work", "--model=anthropic/claude-sonnet-4", ] def test_skips_unknown_flags(self): argv = ["--foo", "bar", "--tui"] - assert relaunch_mod._extract_critical_flags(argv) == ["--tui"] + assert relaunch_mod._extract_inherited_flags(argv) == ["--tui"] def test_does_not_consume_flag_like_value(self): argv = ["--tui", "--resume", "abc123"] - assert relaunch_mod._extract_critical_flags(argv) == ["--tui"] + assert relaunch_mod._extract_inherited_flags(argv) == ["--tui"] def test_preserves_multiple_skills(self): argv = ["-s", "foo", "-s", "bar", "--tui"] - assert relaunch_mod._extract_critical_flags(argv) == ["-s", "foo", "-s", "bar", "--tui"] + assert relaunch_mod._extract_inherited_flags(argv) == ["-s", "foo", "-s", "bar", "--tui"] + + +class TestInheritedFlagTable: + """Sanity-check the argparse-introspected table that drives extraction.""" + + def test_short_and_long_aliases_are_paired(self): + table = dict(relaunch_mod._INHERITED_FLAGS_TABLE) + # Each pair declared together in the parser shares takes_value. + for short, long_ in [ + ("-p", "--profile"), + ("-m", "--model"), + ("-s", "--skills"), + ]: + assert table[short] == table[long_], f"{short}/{long_} disagree" + + def test_store_true_flags_do_not_take_value(self): + table = dict(relaunch_mod._INHERITED_FLAGS_TABLE) + for flag in ["--tui", "--dev", "--yolo", "--ignore-user-config", "--ignore-rules"]: + assert table[flag] is False, f"{flag} should not take a value" + + def test_value_flags_take_value(self): + table = dict(relaunch_mod._INHERITED_FLAGS_TABLE) + for flag in ["--profile", "--model", "--provider", "--skills"]: + assert table[flag] is True, f"{flag} should take a value" + + def test_excluded_flags_are_not_inherited(self): + table = dict(relaunch_mod._INHERITED_FLAGS_TABLE) + # --worktree creates a new worktree per process; inheriting would + # orphan the parent's. Chat-only flags (--quiet/-Q, --verbose/-v, + # --source) can't be in argv at the existing relaunch callsites. + for flag in ["-w", "--worktree", "-Q", "--quiet", "-v", "--verbose", "--source"]: + assert flag not in table, f"{flag} should not be inherited" class TestBuildRelaunchArgv: @@ -82,7 +114,7 @@ class TestBuildRelaunchArgv: argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"]) assert argv == [sys.executable, "-m", "hermes_cli.main", "--resume", "abc"] - def test_preserves_critical_flags(self, monkeypatch): + def test_preserves_inherited_flags(self, monkeypatch): monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes") original = ["--tui", "--dev", "--profile", "work", "sessions", "browse"] argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"], original_argv=original) @@ -100,7 +132,7 @@ class TestBuildRelaunchArgv: monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes") original = ["--tui", "chat"] argv = relaunch_mod.build_relaunch_argv( - ["--resume", "abc"], preserve_critical=False, original_argv=original + ["--resume", "abc"], preserve_inherited=False, original_argv=original ) assert "--tui" not in argv assert argv == ["/usr/bin/hermes", "--resume", "abc"]