Fix Hermes install hint on Windows (#6903)

* fix: use Windows Hermes installer from unsloth start

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Skip the Hermes setup wizard during unattended start-install

unsloth start hermes auto-installs Hermes and then writes its own
session-scoped Hermes config. The install commands, as written, drop into
the installer's interactive setup wizard (hermes setup), which prompts for
global API keys and model choice and points the user at a different global
provider than the one Unsloth just configured, blocking the launch.

Pass the installer's skip flag on both platforms: the PowerShell scriptblock
form with -SkipSetup, and bash -s -- --skip-setup for the piped POSIX
installer.

* Refresh PATH from the registry after a Windows agent install

A Windows installer persists the agent's directory to the User/Machine PATH
in the registry and updates only its own process, so the current process
keeps a stale PATH until it restarts (the installers print 'restart your
terminal'). The post-install shutil.which then misses the just-installed
agent and unsloth start fails with 'installed but isn't on PATH yet',
forcing a re-run in a new shell.

Merge the registry PATH hives back into the process before re-resolving so a
freshly installed agent launches in the same invocation. No-op off Windows
and on any read error; only ever augments PATH.

* Fix/adjust PATH refresh for PR #6903

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: wasimysaid <112766706+wasimysaid@users.noreply.github.com>
Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
This commit is contained in:
Lee Jackson 2026-07-08 10:23:25 +01:00 committed by GitHub
parent 393d7e9c2b
commit baacbd025d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 165 additions and 4 deletions

View file

@ -44,6 +44,19 @@ _CODEX_PROFILE = "unsloth_api"
_CODEX_ENV_KEY = "UNSLOTH_STUDIO_AUTH_TOKEN"
_HERMES_ENV_KEY = "UNSLOTH_API_KEY"
_HERMES_PROVIDER = "unsloth"
# Skip the installer's interactive setup wizard: `unsloth start hermes` runs
# this hint unattended and then writes its own session-scoped Hermes config, so
# the wizard's global API-key/model prompts would block the launch and point the
# user at a different (global) provider than the one Unsloth just configured.
# Both installers expose a skip flag: `-SkipSetup` (PowerShell) and
# `--skip-setup` (POSIX; passed to the piped script via `bash -s --`).
_HERMES_WINDOWS_INSTALL_HINT = (
"& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1))) -SkipSetup"
)
_HERMES_POSIX_INSTALL_HINT = (
"curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent"
"/main/scripts/install.sh | bash -s -- --skip-setup"
)
# Hermes refuses to initialize when the model window is under 64,000 tokens; its
# error message points at the model.context_length / auxiliary.compression
# overrides in config.yaml. write_hermes_config claims this value for smaller
@ -138,6 +151,10 @@ def _yolo_command_flags(agent: str, yolo: bool) -> list:
return _YOLO_COMMAND_FLAGS.get(agent, []) if yolo else []
def _hermes_install_hint() -> str:
return _HERMES_WINDOWS_INSTALL_HINT if os.name == "nt" else _HERMES_POSIX_INSTALL_HINT
class LoadOptions(NamedTuple):
"""Model-load knobs forwarded to /api/inference/load when --model triggers a load."""
@ -848,6 +865,54 @@ def _print_env(
typer.echo(" ".join((*inline, shlex.join(command))))
def _refresh_windows_path() -> None:
# Merge Windows registry PATH hives after the current process PATH so a
# freshly installed agent is visible without changing existing precedence.
if os.name != "nt":
return
try:
import winreg
except Exception:
return
entries = []
seen = set()
def add_path(value: str) -> bool:
added = False
for entry in str(value).split(os.pathsep):
entry = entry.strip()
if not entry:
continue
key = os.path.normcase(entry).casefold()
if key in seen:
continue
seen.add(key)
entries.append(entry)
added = True
return added
add_path(os.environ.get("PATH", ""))
added_registry = False
hives = (
(winreg.HKEY_CURRENT_USER, "Environment"),
(
winreg.HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
),
)
for root, sub in hives:
try:
with winreg.OpenKey(root, sub) as key:
value, _ = winreg.QueryValueEx(key, "Path")
except OSError:
continue
if value:
added_registry = add_path(os.path.expandvars(str(value))) or added_registry
if added_registry:
os.environ["PATH"] = os.pathsep.join(entries)
def _install_agent(name: str, install_hint: str) -> Optional[str]:
# Missing agent under --launch: offer to run its documented install command, then
# re-resolve it on PATH. Consent-based (we never auto-run a remote install script
@ -866,6 +931,9 @@ def _install_agent(name: str, install_hint: str) -> Optional[str]:
install_command = ["/bin/sh", "-c", install_hint]
if subprocess.run(install_command).returncode != 0:
_fail(f"Install command failed. Run it yourself, then re-run: {install_hint}")
# The installer just wrote PATH to the registry (Windows); pull it into this
# process so the freshly installed agent resolves without a shell restart.
_refresh_windows_path()
executable = shutil.which(name)
if executable is None:
_fail(
@ -1536,10 +1604,7 @@ def hermes(
launch = launch,
)
command = ["hermes", *_yolo_command_flags("hermes", yolo), *ctx.args]
install_hint = (
"curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent"
"/main/scripts/install.sh | bash"
)
install_hint = _hermes_install_hint()
with _session_config("hermes", launch) as home:
# HERMES_HOME relocates hermes' whole home dir (config.yaml, sessions, state)
# like CODEX_HOME, so the user's ~/.hermes is left untouched for the session.

View file

@ -92,6 +92,7 @@ def test_claude_flags_detected_when_version_not_first_token(monkeypatch):
def test_install_agent_prompts_then_installs(monkeypatch):
# TTY + yes: run the documented install command, then re-resolve the now-present binary.
monkeypatch.setattr(start.os, "name", "posix")
monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True))
monkeypatch.setattr(start.typer, "confirm", lambda *a, **k: True)
ran = []
@ -108,6 +109,101 @@ def test_install_agent_prompts_then_installs(monkeypatch):
assert ran == [["/bin/sh", "-c", "npm install -g @openai/codex"]]
def test_install_agent_uses_powershell_on_windows(monkeypatch):
monkeypatch.setattr(start.os, "name", "nt")
monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True))
monkeypatch.setattr(start.typer, "confirm", lambda *a, **k: True)
ran = []
monkeypatch.setattr(
start.subprocess,
"run",
lambda command, *a, **k: ran.append(command) or SimpleNamespace(returncode = 0),
)
monkeypatch.setattr(start.shutil, "which", lambda _: r"C:\Users\samle\bin\hermes.exe")
install_hint = "& ([scriptblock]::Create((irm https://x/install.ps1))) -SkipSetup"
executable = start._install_agent("hermes", install_hint)
assert executable == r"C:\Users\samle\bin\hermes.exe"
assert ran == [["powershell", "-NoProfile", "-Command", install_hint]]
def test_hermes_install_hint_is_windows_native_on_windows(monkeypatch):
monkeypatch.setattr(start.os, "name", "nt")
# Scriptblock form so `-SkipSetup` reaches the installer and the interactive
# setup wizard is skipped during the unattended `unsloth start hermes` run.
assert start._hermes_install_hint() == (
"& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1)))"
" -SkipSetup"
)
def test_hermes_install_hint_is_bash_on_posix(monkeypatch):
monkeypatch.setattr(start.os, "name", "posix")
# `bash -s -- --skip-setup` forwards the skip flag to the piped installer.
assert start._hermes_install_hint() == (
"curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent"
"/main/scripts/install.sh | bash -s -- --skip-setup"
)
def test_refresh_windows_path_noop_off_windows(monkeypatch):
monkeypatch.setattr(start.os, "name", "posix")
before = os.environ.get("PATH", "")
monkeypatch.setenv("PATH", before)
start._refresh_windows_path()
assert os.environ.get("PATH", "") == before
def test_refresh_windows_path_merges_registry_hives(monkeypatch):
# Fake Windows registry PATH values written after this process started.
hkcu, hklm = object(), object()
reg = {
(hkcu, "Environment"): r"C:\existing;C:\Users\me\hermes\bin",
(
hklm,
r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
): r"C:\Windows\System32",
}
class _Key:
def __init__(self, value):
self._value = value
def __enter__(self):
return self
def __exit__(self, *a):
return False
def open_key(root, sub):
if (root, sub) in reg:
return _Key(reg[(root, sub)])
raise OSError("missing hive")
fake_winreg = SimpleNamespace(
HKEY_CURRENT_USER = hkcu,
HKEY_LOCAL_MACHINE = hklm,
OpenKey = open_key,
QueryValueEx = lambda key, name: (key._value, 1),
)
monkeypatch.setattr(start.os, "name", "nt")
monkeypatch.setattr(start.os, "pathsep", ";")
monkeypatch.setitem(sys.modules, "winreg", fake_winreg)
monkeypatch.setenv("PATH", r"C:\custom;C:\existing")
start._refresh_windows_path()
assert os.environ["PATH"].split(";") == [
r"C:\custom",
r"C:\existing",
r"C:\Users\me\hermes\bin",
r"C:\Windows\System32",
]
def test_install_agent_declined_returns_none(monkeypatch):
# TTY + no: never runs anything; caller falls back to the print-hint failure.
monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True))