diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index d959d20c8..01014de4d 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -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. diff --git a/unsloth_cli/tests/test_start.py b/unsloth_cli/tests/test_start.py index 58888010b..bd964d5e5 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -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))