feat(update): add --yes/-y flag to skip interactive prompts (#18261)
hermes update had two interactive [Y/n] prompts with no bypass:
1. Config migration (after new env/config options are added)
2. Autostash restore (when uncommitted work was stashed before pull)
hermes uninstall already has --yes/-y; mirrors that.
Under --yes:
- Config-migrate prompt → auto-yes, migrate_config(interactive=False)
so new config fields are applied but API-key prompts are skipped
(user runs 'hermes config migrate' later for those). Matches
gateway-mode semantics.
- Stash-restore prompt → auto-yes, git stash apply runs automatically.
Closes the 'can I hermes update -y, No ! Fix' gap reported by @murelux.
This commit is contained in:
parent
4caad285a6
commit
50c046331d
@ -6673,6 +6673,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
if gateway_mode
|
||||
else None
|
||||
)
|
||||
assume_yes = bool(getattr(args, "yes", False))
|
||||
|
||||
print("⚕ Updating Hermes Agent...")
|
||||
print()
|
||||
@ -6792,8 +6793,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
else:
|
||||
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
|
||||
|
||||
prompt_for_restore = auto_stash_ref is not None and (
|
||||
gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty())
|
||||
prompt_for_restore = (
|
||||
auto_stash_ref is not None
|
||||
and not assume_yes
|
||||
and (gateway_mode or (sys.stdin.isatty() and sys.stdout.isatty()))
|
||||
)
|
||||
|
||||
# Check if there are updates
|
||||
@ -7054,7 +7057,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
print(f" ℹ️ {len(missing_config)} new config option(s) available")
|
||||
|
||||
print()
|
||||
if gateway_mode:
|
||||
if assume_yes:
|
||||
print(" ℹ --yes: auto-applying config migration (skipping API-key prompts).")
|
||||
response = "y"
|
||||
elif gateway_mode:
|
||||
response = (
|
||||
_gateway_prompt(
|
||||
"Would you like to configure new options now? [Y/n]", "n"
|
||||
@ -7080,14 +7086,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
|
||||
if response in ("", "y", "yes"):
|
||||
print()
|
||||
# In gateway mode, run auto-migrations only (no input() prompts
|
||||
# for API keys which would hang the detached process).
|
||||
results = migrate_config(interactive=not gateway_mode, quiet=False)
|
||||
# In gateway mode OR under --yes, run auto-migrations only (no
|
||||
# input() prompts for API keys which would hang the detached
|
||||
# process / defeat the point of --yes).
|
||||
results = migrate_config(
|
||||
interactive=not (gateway_mode or assume_yes), quiet=False
|
||||
)
|
||||
|
||||
if results["env_added"] or results["config_added"]:
|
||||
print()
|
||||
print("✓ Configuration updated!")
|
||||
if gateway_mode and missing_env:
|
||||
if (gateway_mode or assume_yes) and missing_env:
|
||||
print(" ℹ API keys require manual entry: hermes config migrate")
|
||||
else:
|
||||
print()
|
||||
@ -9893,6 +9902,13 @@ Examples:
|
||||
default=False,
|
||||
help="Force a pre-update backup for this run (off by default; overrides updates.pre_update_backup)",
|
||||
)
|
||||
update_parser.add_argument(
|
||||
"--yes",
|
||||
"-y",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Assume yes for interactive prompts (config migration, stash restore). API-key entry is skipped; run 'hermes config migrate' separately for those.",
|
||||
)
|
||||
update_parser.set_defaults(func=cmd_update)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
167
tests/hermes_cli/test_update_yes_flag.py
Normal file
167
tests/hermes_cli/test_update_yes_flag.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""Tests for `hermes update --yes / -y` — assume yes for interactive prompts.
|
||||
|
||||
Covers:
|
||||
1. argparse parses the flag
|
||||
2. Config-migration prompt is auto-answered (no input() call) and migrate_config
|
||||
runs with interactive=False so API-key prompts are skipped
|
||||
3. Autostash restore prompt is auto-answered (prompt_for_restore == False, no
|
||||
input() call) and the stash is applied automatically
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.main import cmd_update
|
||||
|
||||
|
||||
def _make_run_side_effect(
|
||||
branch="main", verify_ok=True, commit_count="1", dirty=False
|
||||
):
|
||||
"""Minimal subprocess.run side_effect for the update flow."""
|
||||
|
||||
def side_effect(cmd, **kwargs):
|
||||
joined = " ".join(str(c) for c in cmd)
|
||||
|
||||
if "rev-parse" in joined and "--abbrev-ref" in joined:
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout=f"{branch}\n", stderr="")
|
||||
if "rev-parse" in joined and "--verify" in joined:
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, 0 if verify_ok else 128, stdout="", stderr=""
|
||||
)
|
||||
if "rev-list" in joined:
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, 0, stdout=f"{commit_count}\n", stderr=""
|
||||
)
|
||||
# `git status --porcelain` for dirty-tree detection during autostash.
|
||||
if "status" in joined and "--porcelain" in joined:
|
||||
out = " M hermes_cli/main.py\n" if dirty else ""
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout=out, stderr="")
|
||||
# `git stash list` — return a stash ref when dirty (so _stash_local_changes
|
||||
# gets something to return). _stash_local_changes_if_needed is what we
|
||||
# actually patch in tests that exercise restore, so this is a catch-all.
|
||||
if "stash" in joined and "list" in joined:
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||
|
||||
return side_effect
|
||||
|
||||
|
||||
class TestUpdateYesConfigMigration:
|
||||
"""--yes auto-answers the config-migration prompt and skips API-key prompts."""
|
||||
|
||||
@patch("hermes_cli.config.migrate_config")
|
||||
@patch("hermes_cli.config.check_config_version", return_value=(1, 2))
|
||||
@patch("hermes_cli.config.get_missing_config_fields", return_value=[])
|
||||
@patch("hermes_cli.config.get_missing_env_vars", return_value=["NEW_KEY"])
|
||||
@patch("shutil.which", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
def test_yes_auto_migrates_without_input(
|
||||
self,
|
||||
mock_run,
|
||||
_mock_which,
|
||||
_mock_missing_env,
|
||||
_mock_missing_cfg,
|
||||
_mock_version,
|
||||
mock_migrate,
|
||||
capsys,
|
||||
):
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
branch="main", verify_ok=True, commit_count="1"
|
||||
)
|
||||
mock_migrate.return_value = {"env_added": [], "config_added": []}
|
||||
|
||||
args = SimpleNamespace(yes=True)
|
||||
|
||||
with patch("builtins.input") as mock_input:
|
||||
cmd_update(args)
|
||||
# Never prompted the user.
|
||||
mock_input.assert_not_called()
|
||||
|
||||
# migrate_config was invoked with interactive=False — API-key prompts
|
||||
# are suppressed, matching gateway-mode semantics.
|
||||
assert mock_migrate.call_count == 1
|
||||
_, kwargs = mock_migrate.call_args
|
||||
assert kwargs.get("interactive") is False
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "--yes: auto-applying config migration" in out
|
||||
# The "Would you like to configure them now?" prompt text never appears.
|
||||
assert "Would you like to configure them now?" not in out
|
||||
|
||||
@patch("hermes_cli.config.migrate_config")
|
||||
@patch("hermes_cli.config.check_config_version", return_value=(1, 2))
|
||||
@patch("hermes_cli.config.get_missing_config_fields", return_value=[])
|
||||
@patch("hermes_cli.config.get_missing_env_vars", return_value=["NEW_KEY"])
|
||||
@patch("shutil.which", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
def test_no_yes_flag_still_prompts_in_tty(
|
||||
self,
|
||||
mock_run,
|
||||
_mock_which,
|
||||
_mock_missing_env,
|
||||
_mock_missing_cfg,
|
||||
_mock_version,
|
||||
mock_migrate,
|
||||
capsys,
|
||||
):
|
||||
"""Regression guard: without --yes, the TTY prompt path still fires."""
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
branch="main", verify_ok=True, commit_count="1"
|
||||
)
|
||||
mock_migrate.return_value = {"env_added": [], "config_added": []}
|
||||
|
||||
args = SimpleNamespace(yes=False)
|
||||
|
||||
with patch("builtins.input", return_value="n") as mock_input, patch(
|
||||
"hermes_cli.main.sys"
|
||||
) as mock_sys:
|
||||
mock_sys.stdin.isatty.return_value = True
|
||||
mock_sys.stdout.isatty.return_value = True
|
||||
cmd_update(args)
|
||||
# The user was actually prompted.
|
||||
assert mock_input.called
|
||||
prompts = [c.args[0] if c.args else "" for c in mock_input.call_args_list]
|
||||
assert any("configure them now" in p for p in prompts)
|
||||
|
||||
|
||||
class TestUpdateYesStashRestore:
|
||||
"""--yes auto-restores the pre-update autostash without prompting."""
|
||||
|
||||
@patch("hermes_cli.main._restore_stashed_changes")
|
||||
@patch(
|
||||
"hermes_cli.main._stash_local_changes_if_needed",
|
||||
return_value="stash@{0}",
|
||||
)
|
||||
@patch("hermes_cli.config.check_config_version", return_value=(1, 1))
|
||||
@patch("hermes_cli.config.get_missing_config_fields", return_value=[])
|
||||
@patch("hermes_cli.config.get_missing_env_vars", return_value=[])
|
||||
@patch("shutil.which", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
def test_yes_restores_stash_without_prompting(
|
||||
self,
|
||||
mock_run,
|
||||
_mock_which,
|
||||
_mock_missing_env,
|
||||
_mock_missing_cfg,
|
||||
_mock_version,
|
||||
_mock_stash,
|
||||
mock_restore,
|
||||
capsys,
|
||||
):
|
||||
# Not on main → cmd_update switches to main → autostash fires.
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
branch="feature-branch", verify_ok=True, commit_count="1", dirty=True
|
||||
)
|
||||
|
||||
args = SimpleNamespace(yes=True)
|
||||
|
||||
cmd_update(args)
|
||||
|
||||
# _restore_stashed_changes was called, and called with prompt_user=False
|
||||
# every time (so the user never sees "Restore local changes now?").
|
||||
assert mock_restore.called
|
||||
for call in mock_restore.call_args_list:
|
||||
assert call.kwargs.get("prompt_user") is False, (
|
||||
f"Expected prompt_user=False under --yes, got {call.kwargs}"
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user