196 lines
8.1 KiB
Python
196 lines
8.1 KiB
Python
"""Windows-safe stdio configuration.
|
|
|
|
The fix is to force UTF-8 on the Python side and also flip the console's code page to UTF-8 (65001).
|
|
Both matter: Python-level only helps when Python's stdout is a real TTY; code-page flipping lets
|
|
subprocesses and child Python ``print()`` calls agree on encoding.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
|
|
__all__ = ["configure_windows_stdio", "is_windows"]
|
|
|
|
|
|
_CONFIGURED = False
|
|
|
|
|
|
def is_windows() -> bool:
|
|
"""Return True iff running on native Windows (not WSL)."""
|
|
return sys.platform == "win32"
|
|
|
|
|
|
def _flip_console_code_page_to_utf8() -> None:
|
|
"""Set the attached console's input and output code pages to UTF-8.
|
|
|
|
Uses ``SetConsoleCP`` / ``SetConsoleOutputCP`` (CP_UTF8 = 65001). Failure is silent: without an
|
|
attached console (redirected stdout, service, PTY-less CI) the calls return 0 and we move on.
|
|
"""
|
|
try:
|
|
import ctypes
|
|
|
|
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
# Best-effort; if there's no console attached these just fail silently.
|
|
kernel32.SetConsoleCP(65001)
|
|
kernel32.SetConsoleOutputCP(65001)
|
|
except Exception:
|
|
# ctypes import, missing kernel32, or non-Windows — any failure here
|
|
# is non-fatal. We've still reconfigured Python's own streams below.
|
|
pass
|
|
|
|
|
|
def _reconfigure_stream(stream, *, encoding: str = "utf-8", errors: str = "replace") -> None:
|
|
"""Reconfigure a text stream to UTF-8 in place.
|
|
|
|
Skips rather than raising when the stream isn't a ``TextIOWrapper`` (e.g. redirected to an
|
|
``io.StringIO`` during tests).
|
|
"""
|
|
try:
|
|
reconfigure = getattr(stream, "reconfigure", None)
|
|
if reconfigure is None:
|
|
return
|
|
reconfigure(encoding=encoding, errors=errors)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def configure_windows_stdio() -> bool:
|
|
"""Force UTF-8 stdio on Windows. No-op elsewhere.
|
|
|
|
Idempotent; returns ``True`` only when something actually changed. Set
|
|
``HERMES_DISABLE_WINDOWS_UTF8=1`` to opt out (forces the old cp1252 path for diagnosing
|
|
encoding bugs). Also sets a default ``EDITOR`` on Windows if none is set (see
|
|
``_default_windows_editor``).
|
|
"""
|
|
global _CONFIGURED
|
|
|
|
if _CONFIGURED:
|
|
return False
|
|
if not is_windows():
|
|
# Mark configured so repeated calls on POSIX are true no-ops.
|
|
_CONFIGURED = True
|
|
return False
|
|
|
|
if os.environ.get("HERMES_DISABLE_WINDOWS_UTF8") in {"1", "true", "True", "yes"}:
|
|
_CONFIGURED = True
|
|
return False
|
|
|
|
# Encourage every child Python process spawned by the agent to also use
|
|
# UTF-8 for its stdio. PYTHONIOENCODING wins over the locale-based
|
|
# default in subprocesses. Don't override an explicit user setting.
|
|
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
|
# PYTHONUTF8 = 1 enables UTF-8 Mode globally for any Python subprocess
|
|
# (PEP 540). Again, don't override an explicit setting.
|
|
os.environ.setdefault("PYTHONUTF8", "1")
|
|
|
|
# Set EDITOR to a working Windows default if neither EDITOR nor VISUAL
|
|
# is set. prompt_toolkit's ``open_in_editor`` falls back to POSIX-only
|
|
# paths (``/usr/bin/nano``, ``/usr/bin/vi``) that don't exist on
|
|
# Windows — Ctrl+X Ctrl+E and ``/edit`` silently do nothing there
|
|
# otherwise. This happens even with full Git for Windows installed,
|
|
# so it's not a MinGit-specific issue.
|
|
_default_editor = _default_windows_editor()
|
|
if _default_editor and not os.environ.get("EDITOR") and not os.environ.get("VISUAL"):
|
|
os.environ["EDITOR"] = _default_editor
|
|
|
|
# Augment PATH with the Hermes-managed Git install directories so
|
|
# subprocess calls (bash, rg, grep, etc.) resolve even in sessions
|
|
# that started before the User PATH broadcast reached them. When
|
|
# install.ps1 adds these to User PATH via SetEnvironmentVariable,
|
|
# already-running shells don't see the change — which means hermes
|
|
# launched from the install session won't find rg / bash / grep
|
|
# even though they're "installed". Prepending the known paths here
|
|
# closes that gap. No-op when the paths don't exist (e.g. system-Git
|
|
# install without Hermes-managed PortableGit).
|
|
_augment_path_with_known_tools()
|
|
|
|
# Flip the console code page first so that any subprocess that
|
|
# inherits the console (e.g. a launched shell) also sees CP_UTF8.
|
|
_flip_console_code_page_to_utf8()
|
|
|
|
# Reconfigure Python's own stdio wrappers so ``print()`` calls from
|
|
# this process round-trip emoji / box-drawing / non-Latin text.
|
|
# ``errors="replace"`` means a genuinely unencodable byte sequence
|
|
# gets a ``?`` rather than crashing the interpreter — we prefer
|
|
# degraded output over a stack trace.
|
|
_reconfigure_stream(sys.stdout)
|
|
_reconfigure_stream(sys.stderr)
|
|
# stdin is re-configured for completeness; Hermes's interactive
|
|
# input path uses prompt_toolkit which manages its own encoding,
|
|
# but batch/pipe input benefits from UTF-8 decoding on stdin too.
|
|
_reconfigure_stream(sys.stdin)
|
|
|
|
_CONFIGURED = True
|
|
return True
|
|
|
|
|
|
def _default_windows_editor() -> str:
|
|
"""Return a Windows-appropriate default for ``$EDITOR``.
|
|
|
|
Priority order, first match wins:
|
|
|
|
1. ``notepad`` — ships with every Windows install, no deps, works as a blocking editor
|
|
(``subprocess.call(["notepad", file])`` blocks until the user closes the window). This is the
|
|
"always-works" default.
|
|
"""
|
|
import shutil
|
|
|
|
# notepad.exe is always in %SystemRoot%\System32 on Windows, so shutil.which
|
|
# will reliably find it. Return the bare name so prompt_toolkit's shlex
|
|
# split doesn't trip over a path containing spaces.
|
|
if shutil.which("notepad"):
|
|
return "notepad"
|
|
# On the extreme off-chance notepad is missing (WinPE, Nano Server), fall
|
|
# back to nothing and let prompt_toolkit's silent no-op do its thing.
|
|
return ""
|
|
|
|
|
|
def _augment_path_with_known_tools() -> None:
|
|
r"""Prepend well-known Hermes-managed tool directories to os.environ['PATH'].
|
|
|
|
Fixes the "User PATH was just updated but my process can't see it" gap on Windows. When
|
|
install.ps1 runs, it adds entries like ``%LOCALAPPDATA%\hermes\git\bin`` to the User PATH via
|
|
``SetEnvironmentVariable(..., "User")``.
|
|
|
|
Patch-up strategy: add the known Hermes-managed tool directories to our PATH at startup so
|
|
subprocess calls resolve correctly. No-op on POSIX and when the directories don't exist. The
|
|
User PATH broadcast still happens in the background for future shells; this just smooths over
|
|
the first-launch gap.
|
|
"""
|
|
if not is_windows():
|
|
return
|
|
|
|
local_appdata = os.environ.get("LOCALAPPDATA", "")
|
|
if not local_appdata:
|
|
return
|
|
|
|
# Known tool dirs installed by scripts/install.ps1. Kept in sync with
|
|
# the PATH entries that installer adds to User scope — the two lists
|
|
# should match so this prefill fully mirrors what a fresh shell would
|
|
# see on next launch.
|
|
candidate_dirs = [
|
|
os.path.join(local_appdata, "hermes", "git", "cmd"),
|
|
os.path.join(local_appdata, "hermes", "git", "bin"),
|
|
os.path.join(local_appdata, "hermes", "git", "usr", "bin"),
|
|
# Hermes venv Scripts directory — host of the hermes.exe shim itself,
|
|
# also where any pip-installed console scripts land. Usually already
|
|
# on PATH when the user invokes hermes, but harmless to include.
|
|
os.path.join(local_appdata, "hermes", "hermes-agent", "venv", "Scripts"),
|
|
# WinGet packages directory — where ``winget install`` drops CLI
|
|
# shims by default (ripgrep lands here as rg.exe). Covers the case
|
|
# of a system-Git install + ripgrep-via-winget that isn't yet on
|
|
# the spawning shell's PATH.
|
|
os.path.join(local_appdata, "Microsoft", "WinGet", "Links"),
|
|
]
|
|
|
|
existing = os.environ.get("PATH", "")
|
|
existing_lower = {p.lower() for p in existing.split(os.pathsep) if p}
|
|
prepend = []
|
|
for d in candidate_dirs:
|
|
if os.path.isdir(d) and d.lower() not in existing_lower:
|
|
prepend.append(d)
|
|
|
|
if prepend:
|
|
os.environ["PATH"] = os.pathsep.join([*prepend, existing])
|