From 50c046331dc722fa875fd290ce29b9cc5130fc08 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:06:32 -0700 Subject: [PATCH] feat(update): add --yes/-y flag to skip interactive prompts (#18261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/main.py | 30 +++- tests/hermes_cli/test_update_yes_flag.py | 167 +++++++++++++++++++++++ 2 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 tests/hermes_cli/test_update_yes_flag.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 79ef21ee..5598a1f3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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) # ========================================================================= diff --git a/tests/hermes_cli/test_update_yes_flag.py b/tests/hermes_cli/test_update_yes_flag.py new file mode 100644 index 00000000..e36cc514 --- /dev/null +++ b/tests/hermes_cli/test_update_yes_flag.py @@ -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}" + )