mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-04 09:47:54 +08:00
Compare commits
3 Commits
opencode-p
...
fix/relaun
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c116c74aa4 | ||
|
|
27c03fbd99 | ||
|
|
883de8f9a1 |
367
hermes_cli/_parser.py
Normal file
367
hermes_cli/_parser.py
Normal file
@@ -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 <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(
|
||||||
|
"--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
|
||||||
@@ -114,6 +114,12 @@ def _apply_profile_override() -> None:
|
|||||||
consume = 1
|
consume = 1
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it.
|
||||||
|
# This lets child processes (relaunch, subprocess) inherit the parent's
|
||||||
|
# profile choice without having to pass --profile again.
|
||||||
|
if profile_name is None and os.environ.get("HERMES_HOME"):
|
||||||
|
return
|
||||||
|
|
||||||
# 2. If no flag, check active_profile in the hermes root
|
# 2. If no flag, check active_profile in the hermes root
|
||||||
if profile_name is None:
|
if profile_name is None:
|
||||||
try:
|
try:
|
||||||
@@ -7758,302 +7764,9 @@ def cmd_logs(args):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main entry point for hermes CLI."""
|
"""Main entry point for hermes CLI."""
|
||||||
parser = argparse.ArgumentParser(
|
from hermes_cli._parser import build_top_level_parser
|
||||||
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
|
|
||||||
|
|
||||||
For more help on a command:
|
parser, subparsers, chat_parser = build_top_level_parser()
|
||||||
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(
|
|
||||||
"--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)",
|
|
||||||
)
|
|
||||||
chat_parser.set_defaults(func=cmd_chat)
|
chat_parser.set_defaults(func=cmd_chat)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -9723,15 +9436,8 @@ Examples:
|
|||||||
|
|
||||||
# Launch hermes --resume <id> by replacing the current process
|
# Launch hermes --resume <id> by replacing the current process
|
||||||
print(f"Resuming session: {selected_id}")
|
print(f"Resuming session: {selected_id}")
|
||||||
hermes_bin = shutil.which("hermes")
|
from hermes_cli.relaunch import relaunch
|
||||||
if hermes_bin:
|
relaunch(["--resume", selected_id])
|
||||||
os.execvp(hermes_bin, ["hermes", "--resume", selected_id])
|
|
||||||
else:
|
|
||||||
# Fallback: re-invoke via python -m
|
|
||||||
os.execvp(
|
|
||||||
sys.executable,
|
|
||||||
[sys.executable, "-m", "hermes_cli.main", "--resume", selected_id],
|
|
||||||
)
|
|
||||||
return # won't reach here after execvp
|
return # won't reach here after execvp
|
||||||
|
|
||||||
elif action == "stats":
|
elif action == "stats":
|
||||||
|
|||||||
149
hermes_cli/relaunch.py
Normal file
149
hermes_cli/relaunch.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
Unified self-relaunch for Hermes CLI.
|
||||||
|
|
||||||
|
Preserves critical flags (--tui, --dev, --profile, --model, etc.) across
|
||||||
|
process replacement so that ``hermes sessions browse`` or post-setup relaunch
|
||||||
|
doesn't silently drop the user's UI mode or other preferences.
|
||||||
|
|
||||||
|
Also works when ``hermes`` is not on PATH (e.g. ``nix run`` or ``python -m``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
from hermes_cli._parser import (
|
||||||
|
PRE_ARGPARSE_INHERITED_FLAGS,
|
||||||
|
build_top_level_parser,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 _INHERITED_FLAGS_TABLE:
|
||||||
|
if key == flag:
|
||||||
|
flags.append(arg)
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
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("-"):
|
||||||
|
flags.append(argv[i + 1])
|
||||||
|
i += 1
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
return flags
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_hermes_bin() -> Optional[str]:
|
||||||
|
"""Find the hermes entry point.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. ``sys.argv[0]`` if it resolves to a real executable.
|
||||||
|
2. ``shutil.which("hermes")`` on PATH.
|
||||||
|
3. ``None`` → caller should fall back to ``python -m hermes_cli.main``.
|
||||||
|
"""
|
||||||
|
argv0 = sys.argv[0]
|
||||||
|
|
||||||
|
# Absolute path to an executable (covers nix store, venv wrappers, etc.)
|
||||||
|
if os.path.isabs(argv0) and os.path.isfile(argv0) and os.access(argv0, os.X_OK):
|
||||||
|
return argv0
|
||||||
|
|
||||||
|
# Relative path — resolve against CWD
|
||||||
|
if not argv0.startswith("-") and os.path.isfile(argv0):
|
||||||
|
abs_path = os.path.abspath(argv0)
|
||||||
|
if os.access(abs_path, os.X_OK):
|
||||||
|
return abs_path
|
||||||
|
|
||||||
|
# PATH lookup
|
||||||
|
path_bin = shutil.which("hermes")
|
||||||
|
if path_bin:
|
||||||
|
return path_bin
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_relaunch_argv(
|
||||||
|
extra_args: Sequence[str],
|
||||||
|
*,
|
||||||
|
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_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:]``).
|
||||||
|
"""
|
||||||
|
bin_path = resolve_hermes_bin()
|
||||||
|
|
||||||
|
if bin_path:
|
||||||
|
argv = [bin_path]
|
||||||
|
else:
|
||||||
|
argv = [sys.executable, "-m", "hermes_cli.main"]
|
||||||
|
|
||||||
|
src = list(original_argv) if original_argv is not None else list(sys.argv[1:])
|
||||||
|
|
||||||
|
if preserve_inherited:
|
||||||
|
argv.extend(_extract_inherited_flags(src))
|
||||||
|
|
||||||
|
argv.extend(extra_args)
|
||||||
|
return argv
|
||||||
|
|
||||||
|
|
||||||
|
def relaunch(
|
||||||
|
extra_args: Sequence[str],
|
||||||
|
*,
|
||||||
|
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_inherited=preserve_inherited, original_argv=original_argv
|
||||||
|
)
|
||||||
|
os.execvp(new_argv[0], new_argv)
|
||||||
@@ -3249,33 +3249,14 @@ def run_setup_wizard(args):
|
|||||||
_offer_launch_chat()
|
_offer_launch_chat()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_hermes_chat_argv() -> Optional[list[str]]:
|
|
||||||
"""Resolve argv for launching ``hermes chat`` in a fresh process."""
|
|
||||||
hermes_bin = shutil.which("hermes")
|
|
||||||
if hermes_bin:
|
|
||||||
return [hermes_bin, "chat"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
if importlib.util.find_spec("hermes_cli") is not None:
|
|
||||||
return [sys.executable, "-m", "hermes_cli.main", "chat"]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _offer_launch_chat():
|
def _offer_launch_chat():
|
||||||
"""Prompt the user to jump straight into chat after setup."""
|
"""Prompt the user to jump straight into chat after setup."""
|
||||||
print()
|
print()
|
||||||
if not prompt_yes_no("Launch hermes chat now?", True):
|
if not prompt_yes_no("Launch hermes chat now?", True):
|
||||||
return
|
return
|
||||||
|
|
||||||
chat_argv = _resolve_hermes_chat_argv()
|
from hermes_cli.relaunch import relaunch
|
||||||
if not chat_argv:
|
relaunch(["chat"])
|
||||||
print_info("Could not relaunch Hermes automatically. Run 'hermes chat' manually.")
|
|
||||||
return
|
|
||||||
|
|
||||||
os.execvp(chat_argv[0], chat_argv)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
|
||||||
|
|||||||
@@ -224,22 +224,21 @@ class TestArgparseFlagsRegistered:
|
|||||||
assert args.ignore_rules is True
|
assert args.ignore_rules is True
|
||||||
|
|
||||||
def test_main_py_registers_both_flags(self):
|
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.
|
parser, _subparsers, chat_parser = build_top_level_parser()
|
||||||
"""
|
|
||||||
import hermes_cli.main as hm
|
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
|
# 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_USER_CONFIG" in src
|
||||||
assert "HERMES_IGNORE_RULES" in src
|
assert "HERMES_IGNORE_RULES" in src
|
||||||
|
|||||||
155
tests/hermes_cli/test_relaunch.py
Normal file
155
tests/hermes_cli/test_relaunch.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""Tests for hermes_cli.relaunch — unified self-relaunch utility."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hermes_cli import relaunch as relaunch_mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveHermesBin:
|
||||||
|
def test_prefers_absolute_argv0_when_executable(self, monkeypatch):
|
||||||
|
fake = "/nix/store/abc/bin/hermes"
|
||||||
|
monkeypatch.setattr(sys, "argv", [fake])
|
||||||
|
monkeypatch.setattr(relaunch_mod.os.path, "isfile", lambda p: p == fake)
|
||||||
|
monkeypatch.setattr(relaunch_mod.os, "access", lambda p, mode: p == fake)
|
||||||
|
assert relaunch_mod.resolve_hermes_bin() == fake
|
||||||
|
|
||||||
|
def test_resolves_relative_argv0(self, monkeypatch, tmp_path):
|
||||||
|
fake = tmp_path / "hermes"
|
||||||
|
fake.write_text("#!/bin/sh\n")
|
||||||
|
fake.chmod(0o755)
|
||||||
|
monkeypatch.setattr(sys, "argv", [str(fake.name)])
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
# Ensure we don't accidentally match a real 'hermes' on PATH
|
||||||
|
monkeypatch.setattr(relaunch_mod.shutil, "which", lambda _name: None)
|
||||||
|
assert relaunch_mod.resolve_hermes_bin() == str(fake)
|
||||||
|
|
||||||
|
def test_falls_back_to_path_which(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(sys, "argv", ["-c"]) # not a real path
|
||||||
|
monkeypatch.setattr(
|
||||||
|
relaunch_mod.shutil, "which", lambda name: "/usr/bin/hermes" if name == "hermes" else None
|
||||||
|
)
|
||||||
|
assert relaunch_mod.resolve_hermes_bin() == "/usr/bin/hermes"
|
||||||
|
|
||||||
|
def test_returns_none_when_unresolvable(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(sys, "argv", ["-c"])
|
||||||
|
monkeypatch.setattr(relaunch_mod.shutil, "which", lambda _name: None)
|
||||||
|
assert relaunch_mod.resolve_hermes_bin() is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractInheritedFlags:
|
||||||
|
def test_extracts_tui_and_dev(self):
|
||||||
|
argv = ["--tui", "--dev", "chat"]
|
||||||
|
assert relaunch_mod._extract_inherited_flags(argv) == ["--tui", "--dev"]
|
||||||
|
|
||||||
|
def test_extracts_profile_with_value(self):
|
||||||
|
argv = ["--profile", "work", "chat"]
|
||||||
|
assert relaunch_mod._extract_inherited_flags(argv) == ["--profile", "work"]
|
||||||
|
|
||||||
|
def test_extracts_short_p_with_value(self):
|
||||||
|
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_inherited_flags(argv) == [
|
||||||
|
"--profile=work",
|
||||||
|
"--model=anthropic/claude-sonnet-4",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_skips_unknown_flags(self):
|
||||||
|
argv = ["--foo", "bar", "--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_inherited_flags(argv) == ["--tui"]
|
||||||
|
|
||||||
|
def test_preserves_multiple_skills(self):
|
||||||
|
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:
|
||||||
|
def test_uses_bin_when_available(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
|
||||||
|
argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"])
|
||||||
|
assert argv[0] == "/usr/bin/hermes"
|
||||||
|
|
||||||
|
def test_falls_back_to_python_module(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: None)
|
||||||
|
argv = relaunch_mod.build_relaunch_argv(["--resume", "abc"])
|
||||||
|
assert argv == [sys.executable, "-m", "hermes_cli.main", "--resume", "abc"]
|
||||||
|
|
||||||
|
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)
|
||||||
|
assert "--tui" in argv
|
||||||
|
assert "--dev" in argv
|
||||||
|
assert "--profile" in argv
|
||||||
|
assert "work" in argv
|
||||||
|
assert "--resume" in argv
|
||||||
|
assert "abc" in argv
|
||||||
|
# The original subcommand should not survive
|
||||||
|
assert "sessions" not in argv
|
||||||
|
assert "browse" not in argv
|
||||||
|
|
||||||
|
def test_can_disable_preserve(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
|
||||||
|
original = ["--tui", "chat"]
|
||||||
|
argv = relaunch_mod.build_relaunch_argv(
|
||||||
|
["--resume", "abc"], preserve_inherited=False, original_argv=original
|
||||||
|
)
|
||||||
|
assert "--tui" not in argv
|
||||||
|
assert argv == ["/usr/bin/hermes", "--resume", "abc"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRelaunch:
|
||||||
|
def test_calls_execvp(self, monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_execvp(path, argv):
|
||||||
|
calls.append((path, argv))
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp)
|
||||||
|
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/bin/hermes")
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
relaunch_mod.relaunch(["--resume", "abc"])
|
||||||
|
|
||||||
|
assert calls == [("/usr/bin/hermes", ["/usr/bin/hermes", "--resume", "abc"])]
|
||||||
@@ -558,28 +558,12 @@ def test_vercel_setup_prefills_project_and_team_from_link_file(tmp_path, monkeyp
|
|||||||
assert defaults[" Vercel team ID"] == "linked-team"
|
assert defaults[" Vercel team ID"] == "linked-team"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_hermes_chat_argv_prefers_which(monkeypatch):
|
def test_offer_launch_chat_relaunches_via_bin(monkeypatch):
|
||||||
from hermes_cli import setup as setup_mod
|
|
||||||
|
|
||||||
monkeypatch.setattr(setup_mod.shutil, "which", lambda name: "/usr/local/bin/hermes" if name == "hermes" else None)
|
|
||||||
|
|
||||||
assert setup_mod._resolve_hermes_chat_argv() == ["/usr/local/bin/hermes", "chat"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_hermes_chat_argv_falls_back_to_module(monkeypatch):
|
|
||||||
from hermes_cli import setup as setup_mod
|
|
||||||
|
|
||||||
monkeypatch.setattr(setup_mod.shutil, "which", lambda _name: None)
|
|
||||||
monkeypatch.setattr(setup_mod.importlib.util, "find_spec", lambda name: object() if name == "hermes_cli" else None)
|
|
||||||
|
|
||||||
assert setup_mod._resolve_hermes_chat_argv() == [sys.executable, "-m", "hermes_cli.main", "chat"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_offer_launch_chat_execs_fresh_process(monkeypatch):
|
|
||||||
from hermes_cli import setup as setup_mod
|
from hermes_cli import setup as setup_mod
|
||||||
|
from hermes_cli import relaunch as relaunch_mod
|
||||||
|
|
||||||
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
|
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
|
||||||
monkeypatch.setattr(setup_mod, "_resolve_hermes_chat_argv", lambda: ["/usr/local/bin/hermes", "chat"])
|
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/local/bin/hermes")
|
||||||
|
|
||||||
exec_calls = []
|
exec_calls = []
|
||||||
|
|
||||||
@@ -587,7 +571,7 @@ def test_offer_launch_chat_execs_fresh_process(monkeypatch):
|
|||||||
exec_calls.append((path, argv))
|
exec_calls.append((path, argv))
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
monkeypatch.setattr(setup_mod.os, "execvp", fake_execvp)
|
monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp)
|
||||||
|
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
setup_mod._offer_launch_chat()
|
setup_mod._offer_launch_chat()
|
||||||
@@ -595,13 +579,22 @@ def test_offer_launch_chat_execs_fresh_process(monkeypatch):
|
|||||||
assert exec_calls == [("/usr/local/bin/hermes", ["/usr/local/bin/hermes", "chat"])]
|
assert exec_calls == [("/usr/local/bin/hermes", ["/usr/local/bin/hermes", "chat"])]
|
||||||
|
|
||||||
|
|
||||||
def test_offer_launch_chat_manual_fallback_when_unresolvable(monkeypatch, capsys):
|
def test_offer_launch_chat_falls_back_to_module(monkeypatch):
|
||||||
from hermes_cli import setup as setup_mod
|
from hermes_cli import setup as setup_mod
|
||||||
|
from hermes_cli import relaunch as relaunch_mod
|
||||||
|
|
||||||
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
|
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
|
||||||
monkeypatch.setattr(setup_mod, "_resolve_hermes_chat_argv", lambda: None)
|
monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: None)
|
||||||
|
|
||||||
setup_mod._offer_launch_chat()
|
exec_calls = []
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
def fake_execvp(path, argv):
|
||||||
assert "Run 'hermes chat' manually" in captured.out
|
exec_calls.append((path, argv))
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
setup_mod._offer_launch_chat()
|
||||||
|
|
||||||
|
assert exec_calls == [(sys.executable, [sys.executable, "-m", "hermes_cli.main", "chat"])]
|
||||||
|
|||||||
Reference in New Issue
Block a user