fix(windows): stage hermes launchers in the managed binary dir, not the git checkout
The installer staged the hermes/hermes-acp launcher copies at
hermes-agent\bin -- inside the git working tree -- and put that dir on
the user PATH (#84452). The update command's pre-pull autostash
(git stash push --include-untracked) swept those untracked, unignored
copies off disk, and once the desktop updater stopped re-applying
stashes (--keep-stash, 5dd221d442) nothing restored them: `hermes`
stopped resolving in every new terminal on every desktop-updated
install.
Move the canonical launcher home to the managed binary dir
(%LOCALAPPDATA%\hermes\bin, next to the managed uv) -- outside the
checkout, where no git operation can ever touch it. The dir is
per-machine and shared by every profile, so all anchoring uses
get_default_hermes_root(), never HERMES_HOME (which points inside
profiles\<name> under `hermes -p`).
The copy design also had a second latent break: managed-uv rebuilds
create relocatable venvs, and a relocatable venv's exe trampoline
resolves relative to its own location -- a copy outside venv\Scripts
dies with 'uv trampoline failed to canonicalize script path'. Launcher
form now depends on the venv (lockstep in install.ps1 and
_install_repair.py): exe copy for normal venvs, a .cmd delegator
invoking the in-venv exe by absolute path for relocatable ones. Either
form counts as present, so pre-rebuild exe copies are left alone.
Delivery to the existing fleet, per cohort:
- already-broken installs cannot run the CLI, so an import-time heal in
hermes_cli.main (ensure_windows_bin_launchers) re-stages missing
launchers when the desktop app spawns its backend -- the one channel
that still reaches them. Gates fail toward inaction: canonical dir
only for the managed clone, legacy hermes-agent\bin only while the
user PATH still resolves through it (some pre-managed-uv installs
have no hermes\bin PATH entry; the legacy re-stage is what fixes
those). Staging-name + os.replace keeps concurrent process starts
from tearing a launcher; the helper never raises.
- healthy old-layout installs migrate in the update tail
(migrate_windows_bin_path): stage canonical launchers, verify them
BEFORE touching the registry, prepend hermes\bin to the user PATH,
strip the legacy entries (hermes-agent\bin and venv\Scripts, #83797),
preserving REG_EXPAND_SZ and raw %VARS%. The legacy dir's files stay
on purpose -- configs holding absolute launcher paths keep working;
only the sweepable PATH resolution route goes.
- fresh installs get the new layout from install.ps1 directly.
/bin/ is gitignored so the one update that DELIVERS this fix cannot
sweep pre-migration launchers a final time under the old rules; the
gitignore line, the legacy re-stage branch, and the update-tail call
are transition machinery with a named expiry once the fleet has
migrated.
Also rewrites _ensure_acp_launcher's stale Windows paragraph to match
(raw docstring fixes its invalid \S escape) and updates the Windows
native docs to the new layout, with a docs<->installer parity test.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
/venv/
|
||||
/venv.old/
|
||||
/venv.stale.runtime-*/
|
||||
/bin/
|
||||
/.hermes-runtime/
|
||||
/_pycache/
|
||||
*.pyc*
|
||||
|
||||
@@ -117,6 +117,341 @@ def _venv_scripts_dir(root: Path) -> Path | None:
|
||||
return scripts if scripts.is_dir() else None
|
||||
|
||||
|
||||
#: Launcher command names install.ps1's Set-PathVariable exposes from the
|
||||
#: managed binary dir (the default Hermes root's ``bin``, next to uv.exe)
|
||||
#: on the user PATH. Keep in lockstep with the launcher list in
|
||||
#: scripts/install.ps1.
|
||||
_WINDOWS_BIN_LAUNCHERS = ("hermes", "hermes-acp")
|
||||
|
||||
|
||||
def _venv_is_relocatable(venv_dir: Path) -> bool:
|
||||
"""True when the venv's pyvenv.cfg declares ``relocatable = true``.
|
||||
|
||||
uv writes the flag; ``hermes_cli.managed_uv`` builds its replacement
|
||||
venvs with ``--relocatable`` (they are constructed aside and swapped
|
||||
into place). A relocatable venv's console-script trampolines embed a
|
||||
RELATIVE interpreter reference, so a COPY of one placed outside
|
||||
``venv\\Scripts`` fails at run time with ``uv trampoline failed to
|
||||
canonicalize script path``. Non-relocatable venvs (fresh installs)
|
||||
embed the absolute interpreter path and their trampolines survive
|
||||
copying. This flag decides which launcher form a PATH dir gets.
|
||||
"""
|
||||
try:
|
||||
cfg = (Path(venv_dir) / "pyvenv.cfg").read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
for line in cfg.splitlines():
|
||||
key, _, value = line.partition("=")
|
||||
if key.strip().lower() == "relocatable" and value.strip().lower() == "true":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_windows_path(value) -> str:
|
||||
"""Windows path equality key: backslashes, no trailing separator, lowered.
|
||||
|
||||
Lowercase via ``.lower()`` (what ``ntpath.normcase`` does) rather than
|
||||
``os.path.normcase`` — that is an identity function on POSIX, and this
|
||||
comparison must behave Windows-correct even when tests exercise the
|
||||
Windows branch from another host (same rationale as
|
||||
``venv_bin_dir(windows=...)``).
|
||||
"""
|
||||
return str(value).replace("/", "\\").rstrip("\\").lower()
|
||||
|
||||
|
||||
def _windows_user_path_entries() -> list[str]:
|
||||
"""User PATH entries from the registry — the value install.ps1 writes.
|
||||
|
||||
Falls back to the process PATH when the registry is unreadable. Only
|
||||
called on Windows.
|
||||
"""
|
||||
try:
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
|
||||
raw, _kind = winreg.QueryValueEx(key, "Path")
|
||||
value = os.path.expandvars(str(raw))
|
||||
except (OSError, ImportError):
|
||||
value = os.environ.get("PATH", "")
|
||||
return [entry for entry in value.split(";") if entry.strip()]
|
||||
|
||||
|
||||
def ensure_windows_bin_launchers(
|
||||
root,
|
||||
*,
|
||||
windows: bool | None = None,
|
||||
user_path_entries: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Re-stage the Windows ``hermes`` launchers when they vanish.
|
||||
|
||||
On Windows, ``hermes`` resolves through launchers derived from the venv
|
||||
console scripts — never ``venv\\Scripts`` itself on PATH, which would
|
||||
shadow the user's ``python`` (#83797). The canonical launcher home is
|
||||
the managed binary dir — the default Hermes root's ``bin``
|
||||
(``%LOCALAPPDATA%\\hermes\\bin``, next to the managed uv) — which lives
|
||||
OUTSIDE the git checkout so no git operation can ever touch it. It is
|
||||
a per-machine dir shared by every profile: ``get_hermes_home()`` would
|
||||
point inside ``profiles\\<name>`` under ``hermes -p``, so the anchor
|
||||
here is :func:`hermes_constants.get_default_hermes_root`.
|
||||
|
||||
Earlier installer versions staged them at ``<checkout>\\bin`` instead —
|
||||
inside the git working tree — where ``hermes update``'s pre-update
|
||||
autostash (``git stash push --include-untracked``) swept them off disk;
|
||||
once the desktop updater stopped re-applying stashes (``--keep-stash``)
|
||||
nothing restored them and ``hermes`` stopped resolving in every new
|
||||
terminal. That legacy location is re-staged too, during the transition,
|
||||
for installs whose user PATH still resolves through it.
|
||||
|
||||
The launcher FORM depends on the venv (see :func:`_venv_is_relocatable`):
|
||||
a normal venv's exe trampoline embeds an absolute interpreter path and
|
||||
survives copying, so it is copied as ``<name>.exe``; a relocatable
|
||||
venv's trampoline resolves relative to its own location and a copy
|
||||
dies with ``uv trampoline failed to canonicalize script path``, so a
|
||||
``<name>.cmd`` delegator invoking the in-venv exe by absolute path is
|
||||
written instead. A name counts as present when EITHER form exists —
|
||||
exe copies staged before a venv rebuild keep working (they embed the
|
||||
swapped-in-place venv's absolute path) and are left alone.
|
||||
|
||||
Two targets, two gates, both failing toward inaction:
|
||||
|
||||
- canonical managed binary dir: only when *root* is the managed clone
|
||||
(``root.parent == get_default_hermes_root()``), so source checkouts
|
||||
elsewhere never gain launchers;
|
||||
- legacy ``<root>\\bin``: only when that dir is on the user PATH
|
||||
(registry value, process PATH as fallback), i.e. the install opted
|
||||
into the old layout and still resolves through it.
|
||||
|
||||
Writes go through a staging name + ``os.replace`` so concurrent process
|
||||
starts cannot tear a launcher. Never raises; returns the restored paths.
|
||||
|
||||
*windows* and *user_path_entries* are injectable for tests, same pattern
|
||||
as ``hermes_constants.venv_bin_dir``.
|
||||
"""
|
||||
if windows is None:
|
||||
windows = _is_windows()
|
||||
if not windows:
|
||||
return []
|
||||
|
||||
root = Path(root)
|
||||
|
||||
# Per-machine anchor: the DEFAULT Hermes root, not get_hermes_home() —
|
||||
# under ``hermes -p <name>`` that returns ``profiles\\<name>``, which
|
||||
# would fail the managed-clone gate below and silently skip the heal
|
||||
# for profile users. The launcher dir serves the whole machine.
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
try:
|
||||
home = Path(get_default_hermes_root())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _launcher_present(target: Path, name: str) -> bool:
|
||||
return (target / f"{name}.exe").exists() or (target / f"{name}.cmd").exists()
|
||||
|
||||
targets: list[Path] = []
|
||||
|
||||
# Canonical target — gate on the managed-clone shape. This runs at
|
||||
# every hermes_cli.main process start (right after the profile
|
||||
# override), so the healthy path must stay at a couple of stat calls.
|
||||
if _normalize_windows_path(root.parent) == _normalize_windows_path(home):
|
||||
canonical = home / "bin"
|
||||
if any(not _launcher_present(canonical, name) for name in _WINDOWS_BIN_LAUNCHERS):
|
||||
targets.append(canonical)
|
||||
|
||||
# Legacy transition target — the pre-migration in-checkout dir. Only
|
||||
# re-staged while the user PATH still points at it (consent), compared
|
||||
# as normalized literal strings: the installer wrote the long literal
|
||||
# path, and realpath'ing arbitrary PATH entries could hang on dead
|
||||
# network shares. An entry stored some other way (8.3 short path,
|
||||
# subst drive) misses the re-stage, which fails safe: no-op.
|
||||
legacy = root / "bin"
|
||||
if any(not _launcher_present(legacy, name) for name in _WINDOWS_BIN_LAUNCHERS):
|
||||
if user_path_entries is None:
|
||||
user_path_entries = _windows_user_path_entries()
|
||||
configured = {_normalize_windows_path(entry) for entry in user_path_entries}
|
||||
if _normalize_windows_path(legacy) in configured:
|
||||
targets.append(legacy)
|
||||
|
||||
if not targets:
|
||||
return []
|
||||
|
||||
from hermes_constants import project_venv_dir, venv_bin_dir
|
||||
|
||||
venv_dir = project_venv_dir(root)
|
||||
if venv_dir is None:
|
||||
return []
|
||||
scripts_dir = venv_bin_dir(venv_dir, windows=windows)
|
||||
sources = [
|
||||
(name, scripts_dir / f"{name}.exe")
|
||||
for name in _WINDOWS_BIN_LAUNCHERS
|
||||
if (scripts_dir / f"{name}.exe").is_file()
|
||||
]
|
||||
if not sources:
|
||||
return []
|
||||
relocatable = _venv_is_relocatable(venv_dir)
|
||||
|
||||
import shutil
|
||||
|
||||
restored: list[str] = []
|
||||
for target in targets:
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
continue
|
||||
for name, source in sources:
|
||||
if _launcher_present(target, name):
|
||||
continue
|
||||
final = target / (f"{name}.cmd" if relocatable else f"{name}.exe")
|
||||
staging = target / f"{final.name}.heal.{os.getpid()}"
|
||||
try:
|
||||
if relocatable:
|
||||
staging.write_text(
|
||||
"@echo off\r\n" f'"{source}" %*\r\n', encoding="ascii"
|
||||
)
|
||||
else:
|
||||
shutil.copy2(source, staging)
|
||||
os.replace(staging, final)
|
||||
restored.append(str(final))
|
||||
except OSError:
|
||||
with contextlib.suppress(OSError):
|
||||
staging.unlink()
|
||||
if restored:
|
||||
# Guarded like everything else in this never-raises helper: a
|
||||
# closed/broken stderr must not turn a successful heal into a crash.
|
||||
with contextlib.suppress(OSError, ValueError):
|
||||
print(
|
||||
" ✓ Restored hermes launcher(s): " + ", ".join(restored),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return restored
|
||||
|
||||
|
||||
def _read_user_path_raw() -> tuple[list[str], int]:
|
||||
"""Raw (unexpanded) user PATH entries + registry value type.
|
||||
|
||||
Raw so a rewrite preserves ``%VARS%`` exactly as the user stored them
|
||||
(same discipline as ``hermes_cli.uninstall``). Only called on Windows.
|
||||
"""
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
|
||||
try:
|
||||
raw, kind = winreg.QueryValueEx(key, "Path")
|
||||
except FileNotFoundError:
|
||||
return [], winreg.REG_EXPAND_SZ
|
||||
return [entry for entry in str(raw).split(";") if entry], int(kind)
|
||||
|
||||
|
||||
def _write_user_path_raw(entries: list[str], kind: int) -> None:
|
||||
"""Write the user PATH back, preserving the registry value type."""
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE
|
||||
) as key:
|
||||
winreg.SetValueEx(key, "Path", 0, kind, ";".join(entries))
|
||||
|
||||
|
||||
def migrate_windows_bin_path(
|
||||
root,
|
||||
*,
|
||||
windows: bool | None = None,
|
||||
read_user_path=None,
|
||||
write_user_path=None,
|
||||
) -> bool:
|
||||
"""One-time PATH migration to the ``HERMES_HOME\\bin`` launcher layout.
|
||||
|
||||
Runs from the ``hermes update`` tail (and mirrors what install.ps1's
|
||||
Set-PathVariable does on fresh installs/repairs, which never reach
|
||||
existing installs — updates don't run install.ps1):
|
||||
|
||||
1. stage the launcher copies into the managed binary dir (via
|
||||
:func:`ensure_windows_bin_launchers`);
|
||||
2. verify both launchers are present there — otherwise STOP, leaving
|
||||
the user PATH untouched (never strip a working entry before its
|
||||
replacement is proven);
|
||||
3. ensure the managed binary dir is on the user PATH (prepend);
|
||||
4. strip the legacy entries: ``<root>\\bin`` (in-checkout launcher dir
|
||||
the update autostash could sweep) and ``<root>\\venv\\Scripts``
|
||||
(shadowed the user's ``python``, #83797).
|
||||
|
||||
The legacy ``<root>\\bin`` FILES are deliberately left in place: editor
|
||||
and ACP configs that captured absolute launcher paths keep working
|
||||
(the launchers run fine from there — only PATH resolution through a
|
||||
dir git could sweep was the bug), and the dir is git-ignored so it
|
||||
cannot dirty the tree.
|
||||
|
||||
Registry writes preserve the stored value type and raw ``%VARS%``.
|
||||
Never raises; returns True when the canonical layout is in place.
|
||||
|
||||
*read_user_path*/*write_user_path* are injectable for tests.
|
||||
"""
|
||||
if windows is None:
|
||||
windows = _is_windows()
|
||||
if not windows:
|
||||
return False
|
||||
|
||||
root = Path(root)
|
||||
|
||||
# Same per-machine anchor as ensure_windows_bin_launchers (see there).
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
try:
|
||||
home = Path(get_default_hermes_root())
|
||||
except Exception:
|
||||
return False
|
||||
if _normalize_windows_path(root.parent) != _normalize_windows_path(home):
|
||||
return False # not the managed clone — nothing to migrate
|
||||
|
||||
ensure_windows_bin_launchers(root, windows=windows, user_path_entries=[])
|
||||
|
||||
home_bin = home / "bin"
|
||||
if any(
|
||||
not ((home_bin / f"{name}.exe").is_file() or (home_bin / f"{name}.cmd").is_file())
|
||||
for name in _WINDOWS_BIN_LAUNCHERS
|
||||
):
|
||||
return False # staging incomplete — leave the PATH alone
|
||||
|
||||
if read_user_path is None:
|
||||
read_user_path = _read_user_path_raw
|
||||
if write_user_path is None:
|
||||
write_user_path = _write_user_path_raw
|
||||
|
||||
try:
|
||||
entries, kind = read_user_path()
|
||||
except (OSError, ImportError):
|
||||
return False
|
||||
|
||||
legacy_keys = {
|
||||
_normalize_windows_path(root / "bin"),
|
||||
_normalize_windows_path(root / "venv" / "Scripts"),
|
||||
}
|
||||
home_bin_key = _normalize_windows_path(home_bin)
|
||||
|
||||
def _entry_key(entry: str) -> str:
|
||||
return _normalize_windows_path(os.path.expandvars(entry))
|
||||
|
||||
kept = [e for e in entries if _entry_key(e) not in legacy_keys]
|
||||
have_home_bin = any(_entry_key(e) == home_bin_key for e in kept)
|
||||
if not have_home_bin:
|
||||
kept = [str(home_bin)] + kept
|
||||
|
||||
if kept != entries:
|
||||
try:
|
||||
write_user_path(kept, kind)
|
||||
except (OSError, ImportError):
|
||||
return False
|
||||
with contextlib.suppress(OSError, ValueError):
|
||||
print(
|
||||
f" ✓ hermes launchers now resolve from {home_bin} "
|
||||
"(legacy PATH entries removed)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _load_console_script_names(root: Path) -> list[str]:
|
||||
"""``[project.scripts]`` names from pyproject.toml (tomllib, 3.11+)."""
|
||||
try:
|
||||
|
||||
@@ -693,6 +693,31 @@ def _apply_profile_override() -> None:
|
||||
|
||||
_apply_profile_override()
|
||||
|
||||
# Windows launcher self-heal — the ``hermes`` command users run is a COPY of
|
||||
# the venv console script, staged into the managed binary dir (the default
|
||||
# Hermes root's ``bin``, next to the managed uv) by install.ps1. That dir
|
||||
# lives OUTSIDE the git checkout precisely because an earlier layout staged
|
||||
# the copies at ``<checkout>\bin``, where ``hermes update``'s autostash
|
||||
# (``git stash push --include-untracked``) swept them off disk; with the
|
||||
# desktop updater's ``--keep-stash`` nothing restored them and ``hermes``
|
||||
# stopped resolving in every new terminal (venv\Scripts itself must stay off
|
||||
# PATH — it shadows the user's ``python``, #83797). Re-staging at process
|
||||
# start reaches already-broken installs through the one channel that still
|
||||
# works there: the desktop app spawning its backend via
|
||||
# ``python -m hermes_cli.main``. Costs a few stat calls when healthy; gates
|
||||
# fail toward inaction so source checkouts are untouched. Sits AFTER the
|
||||
# profile override on purpose — no hermes module may be imported before
|
||||
# profiles resolve. The launcher dir itself is per-machine (the helper
|
||||
# anchors on the DEFAULT root, not HERMES_HOME), so profile sessions heal
|
||||
# the same shared dir.
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
from hermes_cli import _install_repair as _install_repair_mod
|
||||
|
||||
_install_repair_mod.ensure_windows_bin_launchers(_bootstrap_root)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||
# User-managed env files should override stale shell exports on restart.
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
@@ -3181,7 +3181,7 @@ def _ensure_fhs_path_guard() -> None:
|
||||
print(" (reload your shell or run 'source ~/.bashrc' to pick it up)")
|
||||
|
||||
def _ensure_acp_launcher() -> None:
|
||||
"""Self-heal: install a ``hermes-acp`` launcher next to the ``hermes`` one.
|
||||
r"""Self-heal: install a ``hermes-acp`` launcher next to the ``hermes`` one.
|
||||
|
||||
Mirrors the launcher block in ``scripts/install.sh`` so existing installs
|
||||
gain the ACP command on ``hermes update`` without a reinstall. ACP hosts
|
||||
@@ -3197,10 +3197,12 @@ def _ensure_acp_launcher() -> None:
|
||||
|
||||
No-op on Windows (install.ps1 copies ``hermes.exe`` + ``hermes-acp.exe``
|
||||
into ``$InstallDir\bin`` and puts THAT on the user PATH — never the whole
|
||||
``venv\Scripts`` dir, which would shadow the user's ``python`` (#83797) —
|
||||
so ``hermes-acp.exe`` already resolves) and wherever a ``hermes-acp`` is
|
||||
already present next to the ``hermes`` command. Unwritable directories
|
||||
(e.g. ``/usr/local/bin`` as non-root) are skipped silently. Idempotent.
|
||||
``venv\Scripts`` dir, which would shadow the user's ``python`` (#83797);
|
||||
when those copies go missing, ``hermes_cli._install_repair.
|
||||
ensure_windows_bin_launchers`` re-stages them) and wherever a
|
||||
``hermes-acp`` is already present next to the ``hermes`` command.
|
||||
Unwritable directories (e.g. ``/usr/local/bin`` as non-root) are skipped
|
||||
silently. Idempotent.
|
||||
"""
|
||||
if _m().sys.platform == "win32":
|
||||
return
|
||||
@@ -6486,6 +6488,24 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
except Exception as e:
|
||||
logger.debug("hermes-acp launcher self-heal failed: %s", e)
|
||||
|
||||
# Migrate the Windows hermes launchers to the managed binary dir
|
||||
# (the default Hermes root's bin, next to the managed uv) and repair
|
||||
# them if they are missing. Earlier layouts put them inside the git
|
||||
# checkout (hermes-agent\bin) or put venv\Scripts itself on PATH; the
|
||||
# in-checkout copies were swept by this command's own pre-update
|
||||
# autostash (git stash push --include-untracked) and, with
|
||||
# --keep-stash (the desktop updater), never restored — `hermes`
|
||||
# stopped resolving in every new terminal. Updates never run
|
||||
# install.ps1, so this tail call is how existing installs reach the
|
||||
# new layout. No-op on POSIX and on source checkouts (root is not
|
||||
# the managed clone under the default Hermes root).
|
||||
try:
|
||||
from hermes_cli._install_repair import migrate_windows_bin_path
|
||||
|
||||
migrate_windows_bin_path(_m().PROJECT_ROOT)
|
||||
except Exception as e:
|
||||
logger.debug("Windows bin launcher migration failed: %s", e)
|
||||
|
||||
# Refresh the cua-driver binary used by the Computer Use toolset.
|
||||
# The upstream installer is gated on supported platforms and on the
|
||||
# binary already being on PATH, so this is a no-op for users who
|
||||
|
||||
+47
-18
@@ -2987,31 +2987,60 @@ function Set-PathVariable {
|
||||
# venv\Scripts directory. venv\Scripts contains python.exe /
|
||||
# pythonw.exe / pip.exe, and putting it on the user PATH silently
|
||||
# hijacks the `python` command in every terminal on the machine
|
||||
# (#83797): unrelated projects start resolving python to Hermes'
|
||||
# runtime interpreter. A dedicated bin dir with copies of the
|
||||
# launcher exes keeps `hermes` globally available without
|
||||
# shadowing anything. (Launcher exes embed the venv interpreter
|
||||
# path, so they work from any location and survive updates.)
|
||||
$hermesBin = "$InstallDir\bin"
|
||||
# (#83797). And never a directory inside the git checkout:
|
||||
# `hermes update`'s autostash (git stash push --include-untracked)
|
||||
# deletes untracked files from the working tree, which silently
|
||||
# removed the launchers an earlier installer staged under
|
||||
# hermes-agent\bin. $HermesHome\bin is the managed binary dir
|
||||
# (shared with the managed uv), outside the checkout, where no git
|
||||
# operation can ever touch it. (Launcher exes embed the venv
|
||||
# interpreter path, so they work from any location and survive
|
||||
# updates.)
|
||||
$hermesBin = "$HermesHome\bin"
|
||||
New-Item -ItemType Directory -Force -Path $hermesBin | Out-Null
|
||||
foreach ($launcher in @("hermes.exe", "hermes-acp.exe")) {
|
||||
$src = "$InstallDir\venv\Scripts\$launcher"
|
||||
if (Test-Path $src) {
|
||||
Copy-Item -Force $src "$hermesBin\$launcher"
|
||||
# Launcher form depends on the venv (keep in lockstep with
|
||||
# hermes_cli/_install_repair.py): a normal venv's exe trampoline
|
||||
# embeds an absolute interpreter path and survives copying; a
|
||||
# relocatable venv's trampoline (managed_uv rebuilds use
|
||||
# --relocatable) resolves relative to its own location, and a copy
|
||||
# dies with 'uv trampoline failed to canonicalize script path' --
|
||||
# those get a .cmd delegator invoking the in-venv exe instead.
|
||||
$pyvenvCfg = "$InstallDir\venv\pyvenv.cfg"
|
||||
$venvRelocatable = $false
|
||||
if (Test-Path $pyvenvCfg) {
|
||||
$venvRelocatable = [bool](Select-String -Path $pyvenvCfg -Pattern '^\s*relocatable\s*=\s*true\s*$' -Quiet)
|
||||
}
|
||||
foreach ($launcher in @("hermes", "hermes-acp")) {
|
||||
$src = "$InstallDir\venv\Scripts\$launcher.exe"
|
||||
if (-not (Test-Path $src)) { continue }
|
||||
if ($venvRelocatable) {
|
||||
Remove-Item "$hermesBin\$launcher.exe" -Force -ErrorAction SilentlyContinue
|
||||
Set-Content -Path "$hermesBin\$launcher.cmd" -Value "@echo off`r`n`"$src`" %*" -Encoding Ascii
|
||||
} else {
|
||||
Remove-Item "$hermesBin\$launcher.cmd" -Force -ErrorAction SilentlyContinue
|
||||
Copy-Item -Force $src "$hermesBin\$launcher.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
||||
|
||||
# Migrate installs that got venv\Scripts onto PATH from earlier
|
||||
# installer versions -- remove it so the python shadowing stops.
|
||||
$legacyBin = "$InstallDir\venv\Scripts"
|
||||
if ((-not $NoVenv) -and $currentPath -like "*$legacyBin*") {
|
||||
$cleaned = ($currentPath -split ';' | Where-Object { $_ -and $_ -ne $legacyBin }) -join ';'
|
||||
[Environment]::SetEnvironmentVariable("Path", $cleaned, "User")
|
||||
$currentPath = $cleaned
|
||||
Write-Info "Removed legacy venv\Scripts from user PATH (kept hermes via $hermesBin)"
|
||||
# Migrate older layouts off the user PATH:
|
||||
# venv\Scripts -- shadowed the user's python (#83797)
|
||||
# hermes-agent\bin -- lived inside the git checkout, where the update
|
||||
# autostash could sweep the launchers off disk
|
||||
# The hermes-agent\bin FILES are left in place on purpose: editor/ACP
|
||||
# configs that captured absolute launcher paths keep working, and the
|
||||
# dir is git-ignored so it cannot dirty the checkout.
|
||||
if (-not $NoVenv) {
|
||||
$legacyEntries = @("$InstallDir\venv\Scripts", "$InstallDir\bin")
|
||||
$items = @(($currentPath -split ';') | Where-Object { $_ })
|
||||
$cleaned = @($items | Where-Object { $legacyEntries -notcontains $_ })
|
||||
if ($cleaned.Count -ne $items.Count) {
|
||||
$currentPath = $cleaned -join ";"
|
||||
[Environment]::SetEnvironmentVariable("Path", $currentPath, "User")
|
||||
Write-Info "Removed legacy launcher entries from user PATH (kept hermes via $hermesBin)"
|
||||
}
|
||||
}
|
||||
|
||||
if ($currentPath -notlike "*$hermesBin*") {
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""``hermes`` must survive git operations on the checkout (launcher layout).
|
||||
|
||||
The Windows ``hermes`` command is a launcher derived from the venv console
|
||||
script. Its canonical home is the managed binary dir ``HERMES_HOME\\bin`` —
|
||||
OUTSIDE the git checkout — because the earlier in-checkout home
|
||||
(``hermes-agent\\bin``) was swept by ``hermes update``'s autostash
|
||||
(``git stash push --include-untracked``) and, with the desktop updater's
|
||||
``--keep-stash``, never restored: ``hermes`` stopped resolving in every new
|
||||
terminal (``venv\\Scripts`` itself must stay off PATH — it shadows the
|
||||
user's ``python``, #83797).
|
||||
|
||||
``ensure_windows_bin_launchers`` re-stages missing launchers (canonical dir
|
||||
always for the managed clone; legacy dir only while the user PATH still
|
||||
points at it), choosing the form by venv kind: exe copy for normal venvs,
|
||||
``.cmd`` delegator for relocatable venvs whose exe trampolines die when
|
||||
copied out of ``venv\\Scripts``. ``migrate_windows_bin_path`` moves an
|
||||
existing install's PATH to the canonical layout from the ``hermes update``
|
||||
tail. Platform verdict, PATH values, and registry I/O are injected
|
||||
parameters (same pattern as ``hermes_constants.venv_bin_dir``), so these
|
||||
tests are host-independent input→output checks, not host fakes.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli._install_repair import (
|
||||
_WINDOWS_BIN_LAUNCHERS,
|
||||
_normalize_windows_path,
|
||||
ensure_windows_bin_launchers,
|
||||
migrate_windows_bin_path,
|
||||
)
|
||||
|
||||
|
||||
def _make_managed(tmp_path, monkeypatch, *, relocatable: bool = False):
|
||||
"""Fake managed layout: HERMES_HOME/hermes-agent/venv/Scripts + launchers."""
|
||||
home = tmp_path / "hermes"
|
||||
root = home / "hermes-agent"
|
||||
scripts = root / "venv" / "Scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
for name in _WINDOWS_BIN_LAUNCHERS:
|
||||
(scripts / f"{name}.exe").write_bytes(b"MZ console script: " + name.encode())
|
||||
cfg = "home = X\nversion_info = 3.11.15\n"
|
||||
if relocatable:
|
||||
cfg += "relocatable = true\n"
|
||||
(root / "venv" / "pyvenv.cfg").write_text(cfg, encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return home, root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def managed_install(tmp_path, monkeypatch):
|
||||
return _make_managed(tmp_path, monkeypatch)
|
||||
|
||||
|
||||
def test_managed_clone_heals_canonical_home_bin(managed_install):
|
||||
home, root = managed_install
|
||||
|
||||
restored = ensure_windows_bin_launchers(root, windows=True, user_path_entries=[])
|
||||
|
||||
assert len(restored) == len(_WINDOWS_BIN_LAUNCHERS)
|
||||
for name in _WINDOWS_BIN_LAUNCHERS:
|
||||
assert (home / "bin" / f"{name}.exe").read_bytes() == (
|
||||
root / "venv" / "Scripts" / f"{name}.exe"
|
||||
).read_bytes()
|
||||
|
||||
|
||||
def test_relocatable_venv_gets_cmd_delegators_not_exe_copies(tmp_path, monkeypatch):
|
||||
"""A copied relocatable-venv trampoline dies ('uv trampoline failed to
|
||||
canonicalize script path') — the heal must emit .cmd delegators."""
|
||||
home, root = _make_managed(tmp_path, monkeypatch, relocatable=True)
|
||||
|
||||
restored = ensure_windows_bin_launchers(root, windows=True, user_path_entries=[])
|
||||
|
||||
assert {Path(p).suffix for p in restored} == {".cmd"}
|
||||
for name in _WINDOWS_BIN_LAUNCHERS:
|
||||
body = (home / "bin" / f"{name}.cmd").read_text(encoding="ascii")
|
||||
# Delegates to the in-venv exe by absolute path, forwarding args.
|
||||
assert str(root / "venv" / "Scripts" / f"{name}.exe") in body
|
||||
assert "%*" in body
|
||||
assert not (home / "bin" / f"{name}.exe").exists()
|
||||
|
||||
|
||||
def test_existing_exe_counts_as_present_for_relocatable_venv(tmp_path, monkeypatch):
|
||||
"""Exe copies staged before a venv rebuild embed the swapped-in-place
|
||||
venv's absolute path and keep working — never replaced with .cmd."""
|
||||
home, root = _make_managed(tmp_path, monkeypatch, relocatable=True)
|
||||
(home / "bin").mkdir()
|
||||
for name in _WINDOWS_BIN_LAUNCHERS:
|
||||
(home / "bin" / f"{name}.exe").write_bytes(b"pre-rebuild copy")
|
||||
|
||||
assert ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) == []
|
||||
for name in _WINDOWS_BIN_LAUNCHERS:
|
||||
assert (home / "bin" / f"{name}.exe").read_bytes() == b"pre-rebuild copy"
|
||||
assert not (home / "bin" / f"{name}.cmd").exists()
|
||||
|
||||
|
||||
def test_healthy_canonical_layout_is_a_noop(managed_install):
|
||||
home, root = managed_install
|
||||
(home / "bin").mkdir()
|
||||
for name in _WINDOWS_BIN_LAUNCHERS:
|
||||
(home / "bin" / f"{name}.exe").write_bytes(b"present")
|
||||
|
||||
assert ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) == []
|
||||
|
||||
|
||||
def test_legacy_bin_restaged_only_while_on_user_path(managed_install):
|
||||
home, root = managed_install
|
||||
legacy = root / "bin"
|
||||
|
||||
restored = ensure_windows_bin_launchers(
|
||||
root, windows=True, user_path_entries=[str(legacy)]
|
||||
)
|
||||
|
||||
stems = {Path(p).stem for p in restored}
|
||||
assert set(_WINDOWS_BIN_LAUNCHERS) <= stems
|
||||
for name in _WINDOWS_BIN_LAUNCHERS:
|
||||
assert (legacy / f"{name}.exe").is_file() # legacy consent honored
|
||||
assert (home / "bin" / f"{name}.exe").is_file() # canonical healed too
|
||||
|
||||
|
||||
def test_legacy_bin_not_restaged_without_path_consent(managed_install):
|
||||
home, root = managed_install
|
||||
|
||||
ensure_windows_bin_launchers(root, windows=True, user_path_entries=[])
|
||||
|
||||
assert not (root / "bin").exists()
|
||||
|
||||
|
||||
def test_source_checkout_untouched(tmp_path, monkeypatch):
|
||||
"""A checkout NOT under HERMES_HOME gains nothing anywhere."""
|
||||
home = tmp_path / "hermes-home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
root = tmp_path / "src" / "hermes-agent"
|
||||
scripts = root / "venv" / "Scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
for name in _WINDOWS_BIN_LAUNCHERS:
|
||||
(scripts / f"{name}.exe").write_bytes(b"MZ")
|
||||
|
||||
assert ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) == []
|
||||
assert not (home / "bin").exists()
|
||||
assert not (root / "bin").exists()
|
||||
|
||||
|
||||
def test_noop_on_posix(managed_install):
|
||||
home, root = managed_install
|
||||
|
||||
assert ensure_windows_bin_launchers(root, windows=False) == []
|
||||
assert not (home / "bin").exists()
|
||||
|
||||
|
||||
def test_profile_session_still_heals_the_shared_bin(tmp_path, monkeypatch):
|
||||
"""Under ``hermes -p <name>`` HERMES_HOME points inside profiles/<name>;
|
||||
the launcher dir is per-machine, so the heal must anchor on the default
|
||||
root and fire anyway — a habitual profile user gets the same repair."""
|
||||
home = tmp_path / "hermes"
|
||||
root = home / "hermes-agent"
|
||||
scripts = root / "venv" / "Scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
for name in _WINDOWS_BIN_LAUNCHERS:
|
||||
(scripts / f"{name}.exe").write_bytes(b"MZ")
|
||||
(root / "venv" / "pyvenv.cfg").write_text("home = X\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(home / "profiles" / "work"))
|
||||
|
||||
restored = ensure_windows_bin_launchers(root, windows=True, user_path_entries=[])
|
||||
|
||||
assert len(restored) == len(_WINDOWS_BIN_LAUNCHERS)
|
||||
for name in _WINDOWS_BIN_LAUNCHERS:
|
||||
assert (home / "bin" / f"{name}.exe").is_file()
|
||||
assert not (home / "profiles" / "work" / "bin").exists()
|
||||
|
||||
|
||||
def test_noop_when_console_scripts_missing(tmp_path, monkeypatch):
|
||||
"""A venv mid-repair has no console scripts — nothing to copy, no error."""
|
||||
home = tmp_path / "hermes"
|
||||
root = home / "hermes-agent"
|
||||
(root / "venv" / "Scripts").mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
assert ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) == []
|
||||
|
||||
|
||||
def test_no_staging_litter_left_behind(managed_install):
|
||||
home, root = managed_install
|
||||
|
||||
ensure_windows_bin_launchers(root, windows=True, user_path_entries=[])
|
||||
|
||||
leftovers = [p.name for p in (home / "bin").iterdir() if ".heal." in p.name]
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# migrate_windows_bin_path — the `hermes update` tail migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_registry(initial: list[str]):
|
||||
"""In-memory user-PATH store standing in for the HKCU registry value."""
|
||||
state = {"entries": list(initial), "kind": 2, "writes": 0}
|
||||
|
||||
def read():
|
||||
return list(state["entries"]), state["kind"]
|
||||
|
||||
def write(entries, kind):
|
||||
state["entries"] = list(entries)
|
||||
state["kind"] = kind
|
||||
state["writes"] += 1
|
||||
|
||||
return state, read, write
|
||||
|
||||
|
||||
def test_migration_moves_path_to_home_bin_and_strips_legacy(managed_install):
|
||||
home, root = managed_install
|
||||
legacy_bin = str(root / "bin")
|
||||
legacy_scripts = str(root / "venv" / "Scripts")
|
||||
state, read, write = _fake_registry(
|
||||
[legacy_bin, legacy_scripts, r"C:\Windows\system32"]
|
||||
)
|
||||
(root / "bin").mkdir()
|
||||
(root / "bin" / "hermes.exe").write_bytes(b"legacy copy")
|
||||
|
||||
ok = migrate_windows_bin_path(
|
||||
root, windows=True, read_user_path=read, write_user_path=write
|
||||
)
|
||||
|
||||
assert ok
|
||||
keys = [_normalize_windows_path(e) for e in state["entries"]]
|
||||
assert _normalize_windows_path(home / "bin") in keys
|
||||
assert _normalize_windows_path(legacy_bin) not in keys
|
||||
assert _normalize_windows_path(legacy_scripts) not in keys
|
||||
assert _normalize_windows_path(r"C:\Windows\system32") in keys # untouched
|
||||
for name in _WINDOWS_BIN_LAUNCHERS:
|
||||
assert (home / "bin" / f"{name}.exe").is_file()
|
||||
# Legacy FILES stay: editor/ACP configs holding absolute launcher paths
|
||||
# keep working. Only the PATH entry (the sweepable resolution route) goes.
|
||||
assert (root / "bin" / "hermes.exe").read_bytes() == b"legacy copy"
|
||||
|
||||
|
||||
def test_migration_works_for_relocatable_venv(tmp_path, monkeypatch):
|
||||
home, root = _make_managed(tmp_path, monkeypatch, relocatable=True)
|
||||
state, read, write = _fake_registry([str(root / "bin")])
|
||||
|
||||
ok = migrate_windows_bin_path(
|
||||
root, windows=True, read_user_path=read, write_user_path=write
|
||||
)
|
||||
|
||||
assert ok
|
||||
for name in _WINDOWS_BIN_LAUNCHERS:
|
||||
assert (home / "bin" / f"{name}.cmd").is_file()
|
||||
keys = [_normalize_windows_path(e) for e in state["entries"]]
|
||||
assert _normalize_windows_path(home / "bin") in keys
|
||||
|
||||
|
||||
def test_migration_is_idempotent(managed_install):
|
||||
home, root = managed_install
|
||||
state, read, write = _fake_registry([str(home / "bin"), r"C:\Windows\system32"])
|
||||
|
||||
assert migrate_windows_bin_path(
|
||||
root, windows=True, read_user_path=read, write_user_path=write
|
||||
)
|
||||
first_entries = list(state["entries"])
|
||||
first_writes = state["writes"]
|
||||
|
||||
assert migrate_windows_bin_path(
|
||||
root, windows=True, read_user_path=read, write_user_path=write
|
||||
)
|
||||
assert state["entries"] == first_entries
|
||||
assert state["writes"] == first_writes # no redundant registry write
|
||||
|
||||
|
||||
def test_migration_never_strips_path_when_staging_fails(tmp_path, monkeypatch):
|
||||
"""No venv sources → launchers can't stage → PATH must stay untouched."""
|
||||
home = tmp_path / "hermes"
|
||||
root = home / "hermes-agent"
|
||||
(root / "venv" / "Scripts").mkdir(parents=True) # no launcher exes inside
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
legacy_bin = str(root / "bin")
|
||||
state, read, write = _fake_registry([legacy_bin])
|
||||
|
||||
ok = migrate_windows_bin_path(
|
||||
root, windows=True, read_user_path=read, write_user_path=write
|
||||
)
|
||||
|
||||
assert not ok
|
||||
assert state["entries"] == [legacy_bin] # working entry preserved
|
||||
assert state["writes"] == 0
|
||||
|
||||
|
||||
def test_migration_skips_source_checkouts(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes-home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
root = tmp_path / "src" / "hermes-agent"
|
||||
scripts = root / "venv" / "Scripts"
|
||||
scripts.mkdir(parents=True)
|
||||
for name in _WINDOWS_BIN_LAUNCHERS:
|
||||
(scripts / f"{name}.exe").write_bytes(b"MZ")
|
||||
state, read, write = _fake_registry([r"C:\Windows\system32"])
|
||||
|
||||
assert not migrate_windows_bin_path(
|
||||
root, windows=True, read_user_path=read, write_user_path=write
|
||||
)
|
||||
assert state["writes"] == 0
|
||||
|
||||
|
||||
def test_migration_noop_on_posix(managed_install):
|
||||
home, root = managed_install
|
||||
|
||||
assert not migrate_windows_bin_path(root, windows=False)
|
||||
|
||||
|
||||
def test_normalize_windows_path_equivalences():
|
||||
assert (
|
||||
_normalize_windows_path(r"C:\Users\Me\AppData\Local\hermes\bin")
|
||||
== _normalize_windows_path("c:/users/me/appdata/local/HERMES/BIN/")
|
||||
)
|
||||
|
||||
|
||||
def test_repo_gitignores_the_legacy_bin_dir():
|
||||
"""Transition safety: legacy in-checkout launchers must not be stash-swept.
|
||||
|
||||
Until every install has migrated, pre-migration checkouts still carry
|
||||
launchers at ``<checkout>/bin``. ``hermes update`` autostashes with
|
||||
``git stash push --include-untracked``; anything untracked and NOT
|
||||
ignored inside the checkout gets swept off disk. Exercises git's real
|
||||
ignore machinery rather than reading .gitignore text.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
if not (repo_root / ".git").exists():
|
||||
pytest.skip("not running from a git checkout")
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo_root), "check-ignore", "-q", "bin/hermes.exe"],
|
||||
capture_output=True,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
"bin/hermes.exe is not gitignored — hermes update's autostash "
|
||||
"(--include-untracked) would sweep pre-migration launchers off disk"
|
||||
)
|
||||
@@ -5,15 +5,18 @@ def test_windows_native_install_path_docs_match_installer() -> None:
|
||||
doc = Path("website/docs/user-guide/windows-native.md").read_text()
|
||||
install = Path("scripts/install.ps1").read_text()
|
||||
|
||||
# The launchers live in a dedicated bin/ dir on PATH — NOT the whole
|
||||
# venv\Scripts (which would shadow the user's python, #83797).
|
||||
assert "%LOCALAPPDATA%\\hermes\\hermes-agent\\bin" in doc
|
||||
# The launchers live in the managed binary dir OUTSIDE the git checkout
|
||||
# (HERMES_HOME\bin, next to the managed uv) — NOT the whole venv\Scripts
|
||||
# (which would shadow the user's python, #83797) and NOT a dir inside
|
||||
# the checkout (which `hermes update`'s autostash swept off disk).
|
||||
assert "%LOCALAPPDATA%\\hermes\\bin" in doc
|
||||
assert (
|
||||
"Get-Command hermes # should print "
|
||||
"C:\\Users\\<you>\\AppData\\Local\\hermes\\hermes-agent\\bin\\hermes.exe"
|
||||
"C:\\Users\\<you>\\AppData\\Local\\hermes\\bin\\hermes.exe"
|
||||
) in doc
|
||||
# Installer exposes $InstallDir\bin, and must copy the launchers into it.
|
||||
assert '$hermesBin = "$InstallDir\\bin"' in install
|
||||
# Installer exposes $HermesHome\bin, and must copy the launchers into it.
|
||||
assert '$hermesBin = "$HermesHome\\bin"' in install
|
||||
assert "hermes.exe" in install and "hermes-acp.exe" in install
|
||||
# Guard against a regression back to putting venv\Scripts on PATH.
|
||||
# Guard against regressions to either legacy layout.
|
||||
assert '$hermesBin = "$InstallDir\\venv\\Scripts"' not in install
|
||||
assert '$hermesBin = "$InstallDir\\bin"' not in install
|
||||
|
||||
@@ -75,7 +75,7 @@ Top-to-bottom, in order:
|
||||
6. **Tiered `uv pip install`** — tries `.[all]` first, falls back to progressively smaller sets (`[messaging,dashboard,ext]` → `[messaging]` → `.`) if a `git+https` dep flakes on rate-limited GitHub. Prevents "single flake drops you to a bare install" failure mode.
|
||||
7. **Auto-installs messaging SDKs** keyed off `.env` — if `TELEGRAM_BOT_TOKEN` / `DISCORD_BOT_TOKEN` / `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` / `WHATSAPP_ENABLED` are present, runs `python -m ensurepip --upgrade` and targeted `pip install` calls so each platform's SDK is actually importable.
|
||||
8. **Sets `HERMES_GIT_BASH_PATH`** to the resolved `bash.exe` so Hermes finds it deterministically in fresh shells.
|
||||
9. **Adds `%LOCALAPPDATA%\hermes\hermes-agent\bin` to User PATH and sets `HERMES_HOME=%LOCALAPPDATA%\hermes`** — exposes the `hermes` command (and points it at your data dir) after you open a new terminal. Only the `hermes.exe` / `hermes-acp.exe` launchers are copied into this `bin` directory; the full `venv\Scripts` is deliberately **not** placed on PATH so Hermes never shadows your own `python` command.
|
||||
9. **Adds `%LOCALAPPDATA%\hermes\bin` to User PATH and sets `HERMES_HOME=%LOCALAPPDATA%\hermes`** — exposes the `hermes` command (and points it at your data dir) after you open a new terminal. Only the `hermes.exe` / `hermes-acp.exe` launchers are copied into this `bin` directory; the full `venv\Scripts` is deliberately **not** placed on PATH so Hermes never shadows your own `python` command.
|
||||
10. **Runs `hermes setup`** — the normal first-run wizard (model, provider, toolsets). Skip with `-SkipSetup`.
|
||||
|
||||
:::tip Skip provider hunting on Windows
|
||||
@@ -202,10 +202,10 @@ Services require admin rights to install and tie the gateway's lifecycle to mach
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `%LOCALAPPDATA%\hermes\hermes-agent\` | Git checkout + venv. The `bin\hermes.exe` launcher (copied from `venv\Scripts\hermes.exe`) is the command added to User PATH. Safe to `Remove-Item -Recurse` and reinstall. |
|
||||
| `%LOCALAPPDATA%\hermes\hermes-agent\` | Git checkout + venv. Safe to `Remove-Item -Recurse` and reinstall. |
|
||||
| `%LOCALAPPDATA%\hermes\git\` | PortableGit (only if the installer provisioned it). |
|
||||
| `%LOCALAPPDATA%\hermes\node\` | Portable Node.js (only if the installer provisioned it). |
|
||||
| `%LOCALAPPDATA%\hermes\bin\` | Hermes's managed `uv.exe` (the Python manager it uses for updates). |
|
||||
| `%LOCALAPPDATA%\hermes\bin\` | The `hermes` / `hermes-acp` launchers and Hermes's managed `uv.exe` (the Python manager it uses for updates). |
|
||||
| `%LOCALAPPDATA%\hermes\` (root) | Your config, auth, skills, sessions, logs (`config.yaml`, `.env`, `skills\`, `sessions\`, `logs\`, …). **Survives reinstalls.** |
|
||||
|
||||
On native Windows the installer sets `HERMES_HOME=%LOCALAPPDATA%\hermes`, so your data and the disposable install live under the **same** `%LOCALAPPDATA%\hermes` root: the install/runtime is the `hermes-agent\`, `git\`, `node\`, and `bin\` subdirectories, while your data files sit directly in `%LOCALAPPDATA%\hermes`. Reinstalling only replaces the `hermes-agent\` checkout, so your data survives — but because the two share a root, **don't** `Remove-Item -Recurse %LOCALAPPDATA%\hermes` if you want to keep your data; delete the `hermes-agent\` subdirectory instead. Your data directory is identical in shape to a Linux `~/.hermes`, so you can mirror it between machines.
|
||||
@@ -224,12 +224,12 @@ The browser tool uses `agent-browser` (a Node helper) to drive Chromium. On Wind
|
||||
|
||||
### PATH after install
|
||||
|
||||
The installer adds `%LOCALAPPDATA%\hermes\hermes-agent\bin` to your **User PATH** via `[Environment]::SetEnvironmentVariable`. Existing terminals don't pick this up — open a new PowerShell window (or Windows Terminal tab) after installation. Close-and-reopen, don't `$env:PATH += …` by hand unless you know what you're doing.
|
||||
The installer adds `%LOCALAPPDATA%\hermes\bin` to your **User PATH** via `[Environment]::SetEnvironmentVariable`. Existing terminals don't pick this up — open a new PowerShell window (or Windows Terminal tab) after installation. Close-and-reopen, don't `$env:PATH += …` by hand unless you know what you're doing.
|
||||
|
||||
Verify:
|
||||
|
||||
```powershell
|
||||
Get-Command hermes # should print C:\Users\<you>\AppData\Local\hermes\hermes-agent\bin\hermes.exe
|
||||
Get-Command hermes # should print C:\Users\<you>\AppData\Local\hermes\bin\hermes.exe
|
||||
hermes --version
|
||||
```
|
||||
|
||||
@@ -288,7 +288,7 @@ Consequence: any codepath that said "check if this PID is alive" via `os.kill(pi
|
||||
## Common pitfalls
|
||||
|
||||
**`hermes: command not found` right after install.**
|
||||
Open a new PowerShell window. The installer added `%LOCALAPPDATA%\hermes\hermes-agent\bin` to User PATH, but existing shells need to be restarted to pick it up. In the meantime you can run `& "$env:LOCALAPPDATA\hermes\hermes-agent\bin\hermes.exe"`.
|
||||
Open a new PowerShell window. The installer added `%LOCALAPPDATA%\hermes\bin` to User PATH, but existing shells need to be restarted to pick it up. In the meantime you can run `& "$env:LOCALAPPDATA\hermes\bin\hermes.exe"`.
|
||||
|
||||
**`WinError 193: %1 is not a valid Win32 application` when running a tool.**
|
||||
You hit a shebang-script invocation that bypassed the `.cmd` shim. Hermes resolves commands through `shutil.which(cmd, path=local_bin)` so PATHEXT picks up `.CMD` — if you're invoking the tool via a hardcoded path instead, switch to the `.cmd` variant (e.g., `npx.cmd`, not `npx`).
|
||||
|
||||
Reference in New Issue
Block a user