feat: Windows native support via Git Bash

- Add scripts/install.cmd batch wrapper for CMD users (delegates to install.ps1)
- Add _find_shell() in local.py: detects Git Bash on Windows via
  HERMES_GIT_BASH_PATH env var, shutil.which, or common install paths
  (same pattern as Claude Code's CLAUDE_CODE_GIT_BASH_PATH)
- Use _find_shell() in process_registry.py for background processes
- Fix hermes_cli/gateway.py: use wmic instead of ps aux on Windows,
  skip SIGKILL (doesn't exist on Windows), fix venv path
  (Scripts/python.exe vs bin/python)
- Update README with three install commands (Linux/macOS, PowerShell, CMD)
  and Windows native documentation

Requires Git for Windows, which bundles bash.exe. The terminal tool
transparently uses Git Bash for shell commands regardless of whether
the user launched hermes from PowerShell or CMD.
This commit is contained in:
teknium1 2026-03-02 22:03:29 -08:00
parent 68cc81a74d
commit de59d91add
5 changed files with 133 additions and 37 deletions

View file

@ -32,7 +32,7 @@ Built by [Nous Research](https://nousresearch.com). Under the hood, the same arc
## Quick Install ## Quick Install
**Linux/macOS:** **Linux / macOS / WSL:**
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
``` ```
@ -42,18 +42,25 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri
irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex
``` ```
**Windows (CMD):**
```cmd
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd
```
> **Windows note:** [Git for Windows](https://git-scm.com/download/win) is required. Hermes uses Git Bash internally for shell commands.
The installer will: The installer will:
- Install [uv](https://docs.astral.sh/uv/) (fast Python package manager) if not present - Install [uv](https://docs.astral.sh/uv/) (fast Python package manager) if not present
- Install Python 3.11 via uv if not already available (no sudo needed) - Install Python 3.11 via uv if not already available (no sudo needed)
- Clone to `~/.hermes/hermes-agent` (with submodules: mini-swe-agent, tinker-atropos) - Clone to `~/.hermes/hermes-agent` (with submodules: mini-swe-agent, tinker-atropos)
- Create a virtual environment with Python 3.11 - Create a virtual environment with Python 3.11
- Install all dependencies and submodule packages - Install all dependencies and submodule packages
- Symlink `hermes` into `~/.local/bin` so it works globally (no venv activation needed) - Set up the `hermes` command globally (no venv activation needed)
- Run the interactive setup wizard - Run the interactive setup wizard
After installation, reload your shell and run: After installation, reload your shell and run:
```bash ```bash
source ~/.bashrc # or: source ~/.zshrc source ~/.bashrc # or: source ~/.zshrc (Windows: restart your terminal)
hermes setup # Configure API keys (if you skipped during install) hermes setup # Configure API keys (if you skipped during install)
hermes # Start chatting! hermes # Start chatting!
``` ```
@ -1237,8 +1244,8 @@ brew install git
brew install ripgrep node brew install ripgrep node
``` ```
**Windows (WSL recommended):** **Windows (native):**
Use the [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/install) and follow the Ubuntu instructions above. Alternatively, use the PowerShell quick-install script at the top of this README. Hermes runs natively on Windows using [Git for Windows](https://git-scm.com/download/win) (which provides Git Bash for shell commands). Install Git for Windows first, then use the PowerShell or CMD quick-install command at the top of this README. WSL also works — follow the Ubuntu instructions above.
</details> </details>

View file

@ -21,36 +21,56 @@ PROJECT_ROOT = Path(__file__).parent.parent.resolve()
def find_gateway_pids() -> list: def find_gateway_pids() -> list:
"""Find PIDs of running gateway processes.""" """Find PIDs of running gateway processes."""
pids = [] pids = []
patterns = [
"hermes_cli.main gateway",
"hermes gateway",
"gateway/run.py",
]
try: try:
# Look for gateway processes with multiple patterns if is_windows():
patterns = [ # Windows: use wmic to search command lines
"hermes_cli.main gateway", result = subprocess.run(
"hermes gateway", ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"],
"gateway/run.py", capture_output=True, text=True
] )
# Parse WMIC LIST output: blocks of "CommandLine=...\nProcessId=...\n"
result = subprocess.run( current_cmd = ""
["ps", "aux"], for line in result.stdout.split('\n'):
capture_output=True, line = line.strip()
text=True if line.startswith("CommandLine="):
) current_cmd = line[len("CommandLine="):]
elif line.startswith("ProcessId="):
for line in result.stdout.split('\n'): pid_str = line[len("ProcessId="):]
# Skip grep and current process if any(p in current_cmd for p in patterns):
if 'grep' in line or str(os.getpid()) in line:
continue
for pattern in patterns:
if pattern in line:
parts = line.split()
if len(parts) > 1:
try: try:
pid = int(parts[1]) pid = int(pid_str)
if pid not in pids: if pid != os.getpid() and pid not in pids:
pids.append(pid) pids.append(pid)
except ValueError: except ValueError:
continue pass
break current_cmd = ""
else:
result = subprocess.run(
["ps", "aux"],
capture_output=True,
text=True
)
for line in result.stdout.split('\n'):
# Skip grep and current process
if 'grep' in line or str(os.getpid()) in line:
continue
for pattern in patterns:
if pattern in line:
parts = line.split()
if len(parts) > 1:
try:
pid = int(parts[1])
if pid not in pids:
pids.append(pid)
except ValueError:
continue
break
except Exception: except Exception:
pass pass
@ -64,7 +84,7 @@ def kill_gateway_processes(force: bool = False) -> int:
for pid in pids: for pid in pids:
try: try:
if force: if force and not is_windows():
os.kill(pid, signal.SIGKILL) os.kill(pid, signal.SIGKILL)
else: else:
os.kill(pid, signal.SIGTERM) os.kill(pid, signal.SIGTERM)
@ -102,7 +122,10 @@ def get_launchd_plist_path() -> Path:
return Path.home() / "Library" / "LaunchAgents" / "ai.hermes.gateway.plist" return Path.home() / "Library" / "LaunchAgents" / "ai.hermes.gateway.plist"
def get_python_path() -> str: def get_python_path() -> str:
venv_python = PROJECT_ROOT / "venv" / "bin" / "python" if is_windows():
venv_python = PROJECT_ROOT / "venv" / "Scripts" / "python.exe"
else:
venv_python = PROJECT_ROOT / "venv" / "bin" / "python"
if venv_python.exists(): if venv_python.exists():
return str(venv_python) return str(venv_python)
return sys.executable return sys.executable

28
scripts/install.cmd Normal file
View file

@ -0,0 +1,28 @@
@echo off
REM ============================================================================
REM Hermes Agent Installer for Windows (CMD wrapper)
REM ============================================================================
REM This batch file launches the PowerShell installer for users running CMD.
REM
REM Usage:
REM curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd
REM
REM Or if you're already in PowerShell, use the direct command instead:
REM irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex
REM ============================================================================
echo.
echo Hermes Agent Installer
echo Launching PowerShell installer...
echo.
powershell -ExecutionPolicy ByPass -NoProfile -Command "irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex"
if %ERRORLEVEL% NEQ 0 (
echo.
echo Installation failed. Please try running PowerShell directly:
echo powershell -ExecutionPolicy ByPass -c "irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex"
echo.
pause
exit /b 1
)

View file

@ -12,6 +12,43 @@ _IS_WINDOWS = platform.system() == "Windows"
from tools.environments.base import BaseEnvironment from tools.environments.base import BaseEnvironment
def _find_shell() -> str:
"""Find the best shell for command execution.
On Unix: uses $SHELL, falls back to bash.
On Windows: uses Git Bash (bundled with Git for Windows).
Raises RuntimeError if no suitable shell is found on Windows.
"""
if not _IS_WINDOWS:
return os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash"
# Windows: look for Git Bash (installed with Git for Windows).
# Allow override via env var (same pattern as Claude Code).
custom = os.environ.get("HERMES_GIT_BASH_PATH")
if custom and os.path.isfile(custom):
return custom
# shutil.which finds bash.exe if Git\bin is on PATH
found = shutil.which("bash")
if found:
return found
# Check common Git for Windows install locations
for candidate in (
os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Git", "bin", "bash.exe"),
os.path.join(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), "Git", "bin", "bash.exe"),
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Git", "bin", "bash.exe"),
):
if candidate and os.path.isfile(candidate):
return candidate
raise RuntimeError(
"Git Bash not found. Hermes Agent requires Git for Windows on Windows.\n"
"Install it from: https://git-scm.com/download/win\n"
"Or set HERMES_GIT_BASH_PATH to your bash.exe location."
)
# Noise lines emitted by interactive shells when stdin is not a terminal. # Noise lines emitted by interactive shells when stdin is not a terminal.
# Filtered from output to keep tool results clean. # Filtered from output to keep tool results clean.
_SHELL_NOISE_SUBSTRINGS = ( _SHELL_NOISE_SUBSTRINGS = (
@ -66,7 +103,7 @@ class LocalEnvironment(BaseEnvironment):
# tools like nvm, pyenv, and cargo install their init scripts. # tools like nvm, pyenv, and cargo install their init scripts.
# -l alone isn't enough: .profile sources .bashrc, but the guard # -l alone isn't enough: .profile sources .bashrc, but the guard
# returns early because the shell isn't interactive. # returns early because the shell isn't interactive.
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash" user_shell = _find_shell()
proc = subprocess.Popen( proc = subprocess.Popen(
[user_shell, "-lic", exec_command], [user_shell, "-lic", exec_command],
text=True, text=True,

View file

@ -42,6 +42,7 @@ import time
import uuid import uuid
_IS_WINDOWS = platform.system() == "Windows" _IS_WINDOWS = platform.system() == "Windows"
from tools.environments.local import _find_shell
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -148,7 +149,7 @@ class ProcessRegistry:
# Try PTY mode for interactive CLI tools # Try PTY mode for interactive CLI tools
try: try:
import ptyprocess import ptyprocess
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash" user_shell = _find_shell()
pty_env = os.environ | (env_vars or {}) pty_env = os.environ | (env_vars or {})
pty_env["PYTHONUNBUFFERED"] = "1" pty_env["PYTHONUNBUFFERED"] = "1"
pty_proc = ptyprocess.PtyProcess.spawn( pty_proc = ptyprocess.PtyProcess.spawn(
@ -186,7 +187,7 @@ class ProcessRegistry:
# Standard Popen path (non-PTY or PTY fallback) # Standard Popen path (non-PTY or PTY fallback)
# Use the user's login shell for consistency with LocalEnvironment -- # Use the user's login shell for consistency with LocalEnvironment --
# ensures rc files are sourced and user tools are available. # ensures rc files are sourced and user tools are available.
user_shell = os.environ.get("SHELL") or shutil.which("bash") or "/bin/bash" user_shell = _find_shell()
# Force unbuffered output for Python scripts so progress is visible # Force unbuffered output for Python scripts so progress is visible
# during background execution (libraries like tqdm/datasets buffer when # during background execution (libraries like tqdm/datasets buffer when
# stdout is a pipe, hiding output from process(action="poll")). # stdout is a pipe, hiding output from process(action="poll")).