refactor(cli): derive relaunch flag table from argparse introspection

Pull the top-level + chat parser construction out of main() into
hermes_cli/_parser.py so relaunch.py can introspect parser._actions to
discover which flags exist and whether they take values, instead of
maintaining a parallel hand-rolled (flag, takes_value) tuple list.

- _parser.py: build_top_level_parser() returns (parser, subparsers,
  chat_parser); side-effect-free import.
- main.py: ~290 lines of inline parser construction collapsed to a
  helper call. Other subparsers stay inline (dispatch is bound to
  module-level cmd_* functions).
- _parser._inherited_flag(parser, ...): wraps parser.add_argument and
  sets action.inherit_on_relaunch = True. Used in place of
  parser.add_argument for the 25 flags (top-level + chat) that need to
  carry over.
- _parser.PRE_ARGPARSE_INHERITED_FLAGS: holds --profile/-p, which
  isn't on argparse (consumed earlier by main._apply_profile_override).
- relaunch.py: drops _CRITICAL_DESTS and _PRE_ARGPARSE_FLAGS; the table
  builder now filters by getattr(action, 'inherit_on_relaunch', False).
- test_ignore_user_config_flags.py: brittle inspect.getsource grep
  replaced with proper parser introspection.
- test_relaunch.py: introspection sanity tests added.

Salvaged from PR #17549; added top-level -t/--toolsets flag to
_parser.py so #17623 (fix(tui): honor launch toolsets) behavior is
preserved on current main.

Co-authored-by: ethernet <arilotter@gmail.com>
This commit is contained in:
ethernet 2026-04-29 20:29:42 -07:00 committed by Teknium
parent 95f2802f84
commit 3c673468b4
5 changed files with 479 additions and 363 deletions

373
hermes_cli/_parser.py Normal file
View File

@ -0,0 +1,373 @@
"""
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 <session_id> Resume a specific session by ID
hermes setup Run setup wizard
hermes logout Clear stored authentication
hermes auth add <provider> Add a pooled credential
hermes auth list List pooled credentials
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
hermes auth reset <provider> 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 <command> --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(
"-t",
"--toolsets",
default=None,
help="Comma-separated toolsets to enable for this invocation. Applies to -z/--oneshot and --tui.",
)
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

View File

@ -7842,308 +7842,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 <session_id> Resume a specific session by ID
hermes setup Run setup wizard
hermes logout Clear stored authentication
hermes auth add <provider> Add a pooled credential
hermes auth list List pooled credentials
hermes auth remove <p> <t> Remove pooled credential by index, id, or label
hermes auth reset <provider> 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 <command> --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(
"-t",
"--toolsets",
default=None,
help="Comma-separated toolsets to enable for this invocation. Applies to -z/--oneshot and --tui.",
)
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)
# =========================================================================

View File

@ -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)

View File

@ -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

View File

@ -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"]