mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-09 03:11:58 +00:00
Pre-existing Windows bug surfaced while reviewing the portable-MinGit
install: prompt_toolkit's Buffer.open_in_editor() falls back to POSIX
absolute paths (/usr/bin/nano, /usr/bin/vi, /usr/bin/emacs) that don't
exist on native Windows. When neither $EDITOR nor $VISUAL is set,
Ctrl+X Ctrl+E ("open prompt in editor") and /edit both silently do
nothing on Windows — the user hits the key, nothing happens, no error.
This wasn't caused by MinGit (full Git for Windows doesn't fix it either,
because the Windows Python subprocess call resolves `/usr/bin/nano` as
`C:\usr\bin\nano`, which doesn't exist even with nano installed).
Fixes:
- hermes_cli/stdio.py::configure_windows_stdio now sets EDITOR=notepad
on Windows if neither EDITOR nor VISUAL is set. notepad.exe is in
every Windows install, works as a blocking editor (subprocess.call
waits for the window to close), and writes back to the file.
- hermes_cli/config.py (hermes config edit): reorder fallback list so
Windows tries notepad first — previously nano led the list, which
required Git Bash / WSL to be in PATH.
- Users who want VSCode / Neovim / Notepad++ can still override via
$env:EDITOR — that's checked before our default kicks in. Docstring
spells out the common overrides.
The Ink TUI (`hermes --tui`) already handled Windows correctly via
ui-tui/src/lib/editor.ts falling back to notepad.exe on win32 — this
commit brings the classic prompt_toolkit CLI into parity.
3 new tests in test_windows_native_support.py verify:
- EDITOR=notepad gets set when unset on Windows
- Explicit $EDITOR is respected
- $VISUAL is respected (not overwritten by our default)
180 lines
7.3 KiB
Python
180 lines
7.3 KiB
Python
"""Windows-safe stdio configuration.
|
|
|
|
On Windows, Python's ``sys.stdout``/``sys.stderr`` default to the console's
|
|
active code page (often ``cp1252``, sometimes ``cp437``, occasionally ``cp932``
|
|
on Japanese locales, etc.). Hermes's banners, tool output feed, and slash
|
|
command listings all contain Unicode: box-drawing characters (``─┌┐└┘├┤``),
|
|
mathematical and geometric symbols (``◆ ◇ ◎ ▣ ⚔ ⚖ →``), and user-supplied
|
|
text in any language. Printing those to a cp1252 console raises
|
|
``UnicodeEncodeError: 'charmap' codec can't encode character…`` and kills the
|
|
whole CLI before the REPL even opens.
|
|
|
|
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.
|
|
|
|
This module is a no-op on every non-Windows platform, and idempotent.
|
|
Entry points (``cli.py`` ``main``, ``hermes_cli/main.py`` CLI dispatch,
|
|
``gateway/run.py`` startup) call :func:`configure_windows_stdio` exactly
|
|
once early in startup.
|
|
|
|
Patterns cribbed from Claude Code (``src/utils/platform.ts``), OpenCode
|
|
(``packages/opencode/src/pty/index.ts`` env injection), and OpenAI Codex
|
|
(``codex-rs/core/src/unified_exec/process_manager.rs``). None of those
|
|
actually flip the console code page — they rely on their runtime (Node or
|
|
Rust) writing UTF-16 to the Win32 console API and letting the terminal
|
|
sort it out. Python doesn't get that luxury.
|
|
"""
|
|
|
|
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`` via ``ctypes``. Failure
|
|
is silent — if there's no attached console (e.g. Hermes is running
|
|
behind a redirected stdout, under a service, or inside a PTY-less CI
|
|
runner) these calls simply return 0 and we move on.
|
|
|
|
CP_UTF8 is 65001.
|
|
"""
|
|
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.
|
|
|
|
Uses ``TextIOWrapper.reconfigure`` (Python 3.7+). If the stream isn't
|
|
a ``TextIOWrapper`` (e.g. it's been redirected to an ``io.StringIO``
|
|
during tests), we skip rather than blow up.
|
|
"""
|
|
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 — safe to call multiple times from different entry points.
|
|
|
|
Returns ``True`` if anything was actually changed, ``False`` on
|
|
non-Windows or on a repeat call.
|
|
|
|
Set ``HERMES_DISABLE_WINDOWS_UTF8=1`` in the environment to opt out
|
|
(for diagnosing encoding-related bugs by forcing the old cp1252 path).
|
|
|
|
Also sets a sensible default ``EDITOR`` on Windows if none is already
|
|
set — see :func:`_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
|
|
|
|
# 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.
|
|
|
|
The prompt_toolkit buffer's ``open_in_editor`` and Hermes's
|
|
``hermes config edit`` both honour ``$EDITOR``. Users who prefer a
|
|
different editor can override:
|
|
|
|
- VSCode: ``$env:EDITOR = "code --wait"`` (``--wait`` is critical;
|
|
without it the editor returns immediately and any input is lost)
|
|
- Notepad++: ``$env:EDITOR = "'C:\\Program Files\\Notepad++\\notepad++.exe' -multiInst -nosession"``
|
|
- Neovim: ``$env:EDITOR = "nvim"`` (if installed)
|
|
|
|
Set this before launching Hermes (User env var in Windows Settings, or
|
|
export in a PowerShell profile) and Hermes picks it up automatically.
|
|
"""
|
|
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 ""
|