feat: add tested Termux install path and EOF-aware gh auth
This commit is contained in:
@@ -33,8 +33,10 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Works on Linux, macOS, and WSL2. The installer handles everything — Python, Node.js, dependencies, and the `hermes` command. No prerequisites except git.
|
||||
Works on Linux, macOS, WSL2, and Android via Termux. The installer handles the platform-specific setup for you.
|
||||
|
||||
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
|
||||
>
|
||||
> **Windows:** Native Windows is not supported. Please install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and run the command above.
|
||||
|
||||
After installation:
|
||||
|
||||
15
constraints-termux.txt
Normal file
15
constraints-termux.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
# Termux / Android dependency constraints for Hermes Agent.
|
||||
#
|
||||
# Usage:
|
||||
# python -m pip install -e '.[termux]' -c constraints-termux.txt
|
||||
#
|
||||
# These pins keep the tested Android install path stable when upstream packages
|
||||
# move faster than Termux-compatible wheels / sdists.
|
||||
|
||||
ipython<10
|
||||
jedi>=0.18.1,<0.20
|
||||
parso>=0.8.4,<0.9
|
||||
stack-data>=0.6,<0.7
|
||||
pexpect>4.3,<5
|
||||
matplotlib-inline>=0.1.7,<0.2
|
||||
asttokens>=2.1,<3
|
||||
@@ -54,6 +54,23 @@ _PROVIDER_ENV_HINTS = (
|
||||
)
|
||||
|
||||
|
||||
def _is_termux() -> bool:
|
||||
prefix = os.getenv("PREFIX", "")
|
||||
return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
|
||||
|
||||
|
||||
def _python_install_cmd() -> str:
|
||||
return "python -m pip install" if _is_termux() else "uv pip install"
|
||||
|
||||
|
||||
def _system_package_install_cmd(pkg: str) -> str:
|
||||
if _is_termux():
|
||||
return f"pkg install {pkg}"
|
||||
if sys.platform == "darwin":
|
||||
return f"brew install {pkg}"
|
||||
return f"sudo apt install {pkg}"
|
||||
|
||||
|
||||
def _has_provider_env_config(content: str) -> bool:
|
||||
"""Return True when ~/.hermes/.env contains provider auth/base URL settings."""
|
||||
return any(key in content for key in _PROVIDER_ENV_HINTS)
|
||||
@@ -200,7 +217,7 @@ def run_doctor(args):
|
||||
check_ok(name)
|
||||
except ImportError:
|
||||
check_fail(name, "(missing)")
|
||||
issues.append(f"Install {name}: uv pip install {module}")
|
||||
issues.append(f"Install {name}: {_python_install_cmd()} {module}")
|
||||
|
||||
for module, name in optional_packages:
|
||||
try:
|
||||
@@ -503,7 +520,7 @@ def run_doctor(args):
|
||||
check_ok("ripgrep (rg)", "(faster file search)")
|
||||
else:
|
||||
check_warn("ripgrep (rg) not found", "(file search uses grep fallback)")
|
||||
check_info("Install for faster search: sudo apt install ripgrep")
|
||||
check_info(f"Install for faster search: {_system_package_install_cmd('ripgrep')}")
|
||||
|
||||
# Docker (optional)
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "local")
|
||||
@@ -577,6 +594,8 @@ def run_doctor(args):
|
||||
check_warn("agent-browser not installed", "(run: npm install)")
|
||||
else:
|
||||
check_warn("Node.js not found", "(optional, needed for browser tools)")
|
||||
if _is_termux():
|
||||
check_info("Install Node.js on Termux with: pkg install nodejs")
|
||||
|
||||
# npm audit for all Node.js packages
|
||||
if shutil.which("npm"):
|
||||
@@ -739,8 +758,9 @@ def run_doctor(args):
|
||||
__import__("tinker_atropos")
|
||||
check_ok("tinker-atropos", "(RL training backend)")
|
||||
except ImportError:
|
||||
check_warn("tinker-atropos found but not installed", "(run: uv pip install -e ./tinker-atropos)")
|
||||
issues.append("Install tinker-atropos: uv pip install -e ./tinker-atropos")
|
||||
install_cmd = f"{_python_install_cmd()} -e ./tinker-atropos"
|
||||
check_warn("tinker-atropos found but not installed", f"(run: {install_cmd})")
|
||||
issues.append(f"Install tinker-atropos: {install_cmd}")
|
||||
else:
|
||||
check_warn("tinker-atropos requires Python 3.11+", f"(current: {py_version.major}.{py_version.minor})")
|
||||
else:
|
||||
|
||||
@@ -63,6 +63,17 @@ homeassistant = ["aiohttp>=3.9.0,<4"]
|
||||
sms = ["aiohttp>=3.9.0,<4"]
|
||||
acp = ["agent-client-protocol>=0.9.0,<1.0"]
|
||||
mistral = ["mistralai>=2.3.0,<3"]
|
||||
termux = [
|
||||
# Tested Android / Termux path: keeps the core CLI feature-rich while
|
||||
# avoiding extras that currently depend on non-Android wheels (notably
|
||||
# faster-whisper -> ctranslate2 via the voice extra).
|
||||
"hermes-agent[cron]",
|
||||
"hermes-agent[cli]",
|
||||
"hermes-agent[pty]",
|
||||
"hermes-agent[mcp]",
|
||||
"hermes-agent[honcho]",
|
||||
"hermes-agent[acp]",
|
||||
]
|
||||
dingtalk = ["dingtalk-stream>=0.1.0,<1"]
|
||||
feishu = ["lark-oapi>=1.5.3,<2"]
|
||||
rl = [
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# ============================================================================
|
||||
# Hermes Agent Installer
|
||||
# ============================================================================
|
||||
# Installation script for Linux and macOS.
|
||||
# Uses uv for fast Python provisioning and package management.
|
||||
# Installation script for Linux, macOS, and Android/Termux.
|
||||
# Uses uv for desktop/server installs and Python's stdlib venv + pip on Termux.
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
@@ -117,6 +117,10 @@ log_error() {
|
||||
echo -e "${RED}✗${NC} $1"
|
||||
}
|
||||
|
||||
is_termux() {
|
||||
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# System detection
|
||||
# ============================================================================
|
||||
@@ -124,12 +128,17 @@ log_error() {
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Linux*)
|
||||
OS="linux"
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
DISTRO="$ID"
|
||||
if is_termux; then
|
||||
OS="android"
|
||||
DISTRO="termux"
|
||||
else
|
||||
DISTRO="unknown"
|
||||
OS="linux"
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
DISTRO="$ID"
|
||||
else
|
||||
DISTRO="unknown"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
Darwin*)
|
||||
@@ -158,6 +167,12 @@ detect_os() {
|
||||
# ============================================================================
|
||||
|
||||
install_uv() {
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Termux detected — using Python's stdlib venv + pip instead of uv"
|
||||
UV_CMD=""
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Checking for uv package manager..."
|
||||
|
||||
# Check common locations for uv
|
||||
@@ -209,6 +224,25 @@ install_uv() {
|
||||
}
|
||||
|
||||
check_python() {
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Checking Termux Python..."
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
PYTHON_PATH="$(command -v python)"
|
||||
if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
|
||||
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
||||
log_success "Python found: $PYTHON_FOUND_VERSION"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log_info "Installing Python via pkg..."
|
||||
pkg install -y python >/dev/null
|
||||
PYTHON_PATH="$(command -v python)"
|
||||
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
||||
log_success "Python installed: $PYTHON_FOUND_VERSION"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Checking Python $PYTHON_VERSION..."
|
||||
|
||||
# Let uv handle Python — it can download and manage Python versions
|
||||
@@ -243,6 +277,17 @@ check_git() {
|
||||
fi
|
||||
|
||||
log_error "Git not found"
|
||||
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Installing Git via pkg..."
|
||||
pkg install -y git >/dev/null
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
GIT_VERSION=$(git --version | awk '{print $3}')
|
||||
log_success "Git $GIT_VERSION installed"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log_info "Please install Git:"
|
||||
|
||||
case "$OS" in
|
||||
@@ -262,6 +307,9 @@ check_git() {
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
android)
|
||||
log_info " pkg install git"
|
||||
;;
|
||||
macos)
|
||||
log_info " xcode-select --install"
|
||||
log_info " Or: brew install git"
|
||||
@@ -290,11 +338,29 @@ check_node() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Node.js not found — installing Node.js $NODE_VERSION LTS..."
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Node.js not found — installing Node.js via pkg..."
|
||||
else
|
||||
log_info "Node.js not found — installing Node.js $NODE_VERSION LTS..."
|
||||
fi
|
||||
install_node
|
||||
}
|
||||
|
||||
install_node() {
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Installing Node.js via pkg..."
|
||||
if pkg install -y nodejs >/dev/null; then
|
||||
local installed_ver
|
||||
installed_ver=$(node --version 2>/dev/null)
|
||||
log_success "Node.js $installed_ver installed via pkg"
|
||||
HAS_NODE=true
|
||||
else
|
||||
log_warn "Failed to install Node.js via pkg"
|
||||
HAS_NODE=false
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
local arch=$(uname -m)
|
||||
local node_arch
|
||||
case "$arch" in
|
||||
@@ -413,6 +479,30 @@ install_system_packages() {
|
||||
need_ffmpeg=true
|
||||
fi
|
||||
|
||||
# Termux always needs the Android build toolchain for the tested pip path,
|
||||
# even when ripgrep/ffmpeg are already present.
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
local termux_pkgs=(clang rust make pkg-config libffi openssl)
|
||||
if [ "$need_ripgrep" = true ]; then
|
||||
termux_pkgs+=("ripgrep")
|
||||
fi
|
||||
if [ "$need_ffmpeg" = true ]; then
|
||||
termux_pkgs+=("ffmpeg")
|
||||
fi
|
||||
|
||||
log_info "Installing Termux packages: ${termux_pkgs[*]}"
|
||||
if pkg install -y "${termux_pkgs[@]}" >/dev/null; then
|
||||
[ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
|
||||
[ "$need_ffmpeg" = true ] && HAS_FFMPEG=true && log_success "ffmpeg installed"
|
||||
log_success "Termux build dependencies installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_warn "Could not auto-install all Termux packages"
|
||||
log_info "Install manually: pkg install ${termux_pkgs[*]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Nothing to install — done
|
||||
if [ "$need_ripgrep" = false ] && [ "$need_ffmpeg" = false ]; then
|
||||
return 0
|
||||
@@ -550,6 +640,9 @@ show_manual_install_hint() {
|
||||
*) log_info " Use your package manager or visit the project homepage" ;;
|
||||
esac
|
||||
;;
|
||||
android)
|
||||
log_info " pkg install $pkg"
|
||||
;;
|
||||
macos) log_info " brew install $pkg" ;;
|
||||
esac
|
||||
}
|
||||
@@ -646,6 +739,19 @@ setup_venv() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Creating virtual environment with Termux Python..."
|
||||
|
||||
if [ -d "venv" ]; then
|
||||
log_info "Virtual environment already exists, recreating..."
|
||||
rm -rf venv
|
||||
fi
|
||||
|
||||
"$PYTHON_PATH" -m venv venv
|
||||
log_success "Virtual environment ready ($(./venv/bin/python --version 2>/dev/null))"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Creating virtual environment with Python $PYTHON_VERSION..."
|
||||
|
||||
if [ -d "venv" ]; then
|
||||
@@ -662,6 +768,46 @@ setup_venv() {
|
||||
install_deps() {
|
||||
log_info "Installing dependencies..."
|
||||
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
if [ "$USE_VENV" = true ]; then
|
||||
export VIRTUAL_ENV="$INSTALL_DIR/venv"
|
||||
PIP_PYTHON="$INSTALL_DIR/venv/bin/python"
|
||||
else
|
||||
PIP_PYTHON="$PYTHON_PATH"
|
||||
fi
|
||||
|
||||
if [ -z "${ANDROID_API_LEVEL:-}" ]; then
|
||||
ANDROID_API_LEVEL="$(getprop ro.build.version.sdk 2>/dev/null || true)"
|
||||
if [ -z "$ANDROID_API_LEVEL" ]; then
|
||||
ANDROID_API_LEVEL=24
|
||||
fi
|
||||
export ANDROID_API_LEVEL
|
||||
log_info "Using ANDROID_API_LEVEL=$ANDROID_API_LEVEL for Android wheel builds"
|
||||
fi
|
||||
|
||||
"$PIP_PYTHON" -m pip install --upgrade pip setuptools wheel >/dev/null
|
||||
if ! "$PIP_PYTHON" -m pip install -e '.[termux]' -c constraints-termux.txt; then
|
||||
log_warn "Termux feature install (.[termux]) failed, trying base install..."
|
||||
if ! "$PIP_PYTHON" -m pip install -e '.' -c constraints-termux.txt; then
|
||||
log_error "Package installation failed on Termux."
|
||||
log_info "Ensure these packages are installed: pkg install clang rust make pkg-config libffi openssl"
|
||||
log_info "Then re-run: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log_success "Main package installed"
|
||||
log_info "Termux note: browser/WhatsApp tooling is not installed by default; see the Termux guide for optional follow-up steps."
|
||||
|
||||
if [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then
|
||||
log_info "tinker-atropos submodule found — skipping install (optional, for RL training)"
|
||||
log_info " To install later: $PIP_PYTHON -m pip install -e \"./tinker-atropos\""
|
||||
fi
|
||||
|
||||
log_success "All dependencies installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$USE_VENV" = true ]; then
|
||||
# Tell uv to install into our venv (no need to activate)
|
||||
export VIRTUAL_ENV="$INSTALL_DIR/venv"
|
||||
@@ -743,7 +889,11 @@ setup_path() {
|
||||
if [ ! -x "$HERMES_BIN" ]; then
|
||||
log_warn "hermes entry point not found at $HERMES_BIN"
|
||||
log_info "This usually means the pip install didn't complete successfully."
|
||||
log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'"
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Try: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt"
|
||||
else
|
||||
log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -878,6 +1028,13 @@ install_node_deps() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Skipping automatic Node/browser dependency setup on Termux"
|
||||
log_info "Browser automation and WhatsApp bridge are not part of the tested Termux install path yet."
|
||||
log_info "If you want to experiment manually later, run: cd $INSTALL_DIR && npm install"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f "$INSTALL_DIR/package.json" ]; then
|
||||
log_info "Installing Node.js dependencies (browser tools)..."
|
||||
cd "$INSTALL_DIR"
|
||||
@@ -1090,7 +1247,11 @@ print_success() {
|
||||
echo -e "${YELLOW}"
|
||||
echo "Note: Node.js could not be installed automatically."
|
||||
echo "Browser tools need Node.js. Install manually:"
|
||||
echo " https://nodejs.org/en/download/"
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
echo " pkg install nodejs"
|
||||
else
|
||||
echo " https://nodejs.org/en/download/"
|
||||
fi
|
||||
echo -e "${NC}"
|
||||
fi
|
||||
|
||||
@@ -1099,7 +1260,11 @@ print_success() {
|
||||
echo -e "${YELLOW}"
|
||||
echo "Note: ripgrep (rg) was not found. File search will use"
|
||||
echo "grep as a fallback. For faster search in large codebases,"
|
||||
echo "install ripgrep: sudo apt install ripgrep (or brew install ripgrep)"
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
echo "install ripgrep: pkg install ripgrep"
|
||||
else
|
||||
echo "install ripgrep: sudo apt install ripgrep (or brew install ripgrep)"
|
||||
fi
|
||||
echo -e "${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -14,6 +14,23 @@ from hermes_cli import doctor as doctor_mod
|
||||
from hermes_cli.doctor import _has_provider_env_config
|
||||
|
||||
|
||||
class TestDoctorPlatformHints:
|
||||
def test_termux_package_hint(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
assert doctor._is_termux() is True
|
||||
assert doctor._python_install_cmd() == "python -m pip install"
|
||||
assert doctor._system_package_install_cmd("ripgrep") == "pkg install ripgrep"
|
||||
|
||||
def test_non_termux_package_hint_defaults_to_apt(self, monkeypatch):
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
assert doctor._is_termux() is False
|
||||
assert doctor._python_install_cmd() == "uv pip install"
|
||||
assert doctor._system_package_install_cmd("ripgrep") == "sudo apt install ripgrep"
|
||||
|
||||
|
||||
class TestProviderEnvDetection:
|
||||
def test_detects_openai_api_key(self):
|
||||
content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***"
|
||||
|
||||
@@ -135,6 +135,64 @@ class TestReadLog:
|
||||
assert "5 lines" in result["showing"]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Stdin helpers
|
||||
# =========================================================================
|
||||
|
||||
class TestStdinHelpers:
|
||||
def test_close_stdin_not_found(self, registry):
|
||||
result = registry.close_stdin("nonexistent")
|
||||
assert result["status"] == "not_found"
|
||||
|
||||
def test_close_stdin_pipe_mode(self, registry):
|
||||
proc = MagicMock()
|
||||
proc.stdin = MagicMock()
|
||||
s = _make_session()
|
||||
s.process = proc
|
||||
registry._running[s.id] = s
|
||||
|
||||
result = registry.close_stdin(s.id)
|
||||
|
||||
proc.stdin.close.assert_called_once()
|
||||
assert result["status"] == "ok"
|
||||
|
||||
def test_close_stdin_pty_mode(self, registry):
|
||||
pty = MagicMock()
|
||||
s = _make_session()
|
||||
s._pty = pty
|
||||
registry._running[s.id] = s
|
||||
|
||||
result = registry.close_stdin(s.id)
|
||||
|
||||
pty.sendeof.assert_called_once()
|
||||
assert result["status"] == "ok"
|
||||
|
||||
def test_close_stdin_allows_eof_driven_process_to_finish(self, registry, tmp_path):
|
||||
session = registry.spawn_local(
|
||||
'python3 -c "import sys; print(sys.stdin.read().strip())"',
|
||||
cwd=str(tmp_path),
|
||||
use_pty=False,
|
||||
)
|
||||
|
||||
try:
|
||||
time.sleep(0.5)
|
||||
assert registry.submit_stdin(session.id, "hello")["status"] == "ok"
|
||||
assert registry.close_stdin(session.id)["status"] == "ok"
|
||||
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
poll = registry.poll(session.id)
|
||||
if poll["status"] == "exited":
|
||||
assert poll["exit_code"] == 0
|
||||
assert "hello" in poll["output_preview"]
|
||||
return
|
||||
time.sleep(0.2)
|
||||
|
||||
pytest.fail("process did not exit after stdin was closed")
|
||||
finally:
|
||||
registry.kill_process(session.id)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# List sessions
|
||||
# =========================================================================
|
||||
|
||||
91
tests/tools/test_terminal_tool_pty_fallback.py
Normal file
91
tests/tools/test_terminal_tool_pty_fallback.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import tools.terminal_tool as terminal_tool_module
|
||||
from tools import process_registry as process_registry_module
|
||||
|
||||
|
||||
def _base_config(tmp_path):
|
||||
return {
|
||||
"env_type": "local",
|
||||
"docker_image": "",
|
||||
"singularity_image": "",
|
||||
"modal_image": "",
|
||||
"daytona_image": "",
|
||||
"cwd": str(tmp_path),
|
||||
"timeout": 30,
|
||||
}
|
||||
|
||||
|
||||
def test_command_requires_pipe_stdin_detects_gh_with_token():
|
||||
assert terminal_tool_module._command_requires_pipe_stdin(
|
||||
"gh auth login --hostname github.com --git-protocol https --with-token"
|
||||
) is True
|
||||
assert terminal_tool_module._command_requires_pipe_stdin(
|
||||
"gh auth login --web"
|
||||
) is False
|
||||
|
||||
|
||||
def test_terminal_background_disables_pty_for_gh_with_token(monkeypatch, tmp_path):
|
||||
config = _base_config(tmp_path)
|
||||
dummy_env = SimpleNamespace(env={})
|
||||
captured = {}
|
||||
|
||||
def fake_spawn_local(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(id="proc_test", pid=1234, notify_on_complete=False)
|
||||
|
||||
monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: config)
|
||||
monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None)
|
||||
monkeypatch.setattr(terminal_tool_module, "_check_all_guards", lambda *_args, **_kwargs: {"approved": True})
|
||||
monkeypatch.setattr(process_registry_module.process_registry, "spawn_local", fake_spawn_local)
|
||||
monkeypatch.setitem(terminal_tool_module._active_environments, "default", dummy_env)
|
||||
monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0)
|
||||
|
||||
try:
|
||||
result = json.loads(
|
||||
terminal_tool_module.terminal_tool(
|
||||
command="gh auth login --hostname github.com --git-protocol https --with-token",
|
||||
background=True,
|
||||
pty=True,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
terminal_tool_module._active_environments.pop("default", None)
|
||||
terminal_tool_module._last_activity.pop("default", None)
|
||||
|
||||
assert captured["use_pty"] is False
|
||||
assert result["session_id"] == "proc_test"
|
||||
assert "PTY disabled" in result["pty_note"]
|
||||
|
||||
|
||||
def test_terminal_background_keeps_pty_for_regular_interactive_commands(monkeypatch, tmp_path):
|
||||
config = _base_config(tmp_path)
|
||||
dummy_env = SimpleNamespace(env={})
|
||||
captured = {}
|
||||
|
||||
def fake_spawn_local(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(id="proc_test", pid=1234, notify_on_complete=False)
|
||||
|
||||
monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: config)
|
||||
monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None)
|
||||
monkeypatch.setattr(terminal_tool_module, "_check_all_guards", lambda *_args, **_kwargs: {"approved": True})
|
||||
monkeypatch.setattr(process_registry_module.process_registry, "spawn_local", fake_spawn_local)
|
||||
monkeypatch.setitem(terminal_tool_module._active_environments, "default", dummy_env)
|
||||
monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0)
|
||||
|
||||
try:
|
||||
result = json.loads(
|
||||
terminal_tool_module.terminal_tool(
|
||||
command="python3 -c \"print(input())\"",
|
||||
background=True,
|
||||
pty=True,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
terminal_tool_module._active_environments.pop("default", None)
|
||||
terminal_tool_module._last_activity.pop("default", None)
|
||||
|
||||
assert captured["use_pty"] is True
|
||||
assert "pty_note" not in result
|
||||
@@ -700,6 +700,29 @@ class ProcessRegistry:
|
||||
"""Send data + newline to a running process's stdin (like pressing Enter)."""
|
||||
return self.write_stdin(session_id, data + "\n")
|
||||
|
||||
def close_stdin(self, session_id: str) -> dict:
|
||||
"""Close a running process's stdin / send EOF without killing the process."""
|
||||
session = self.get(session_id)
|
||||
if session is None:
|
||||
return {"status": "not_found", "error": f"No process with ID {session_id}"}
|
||||
if session.exited:
|
||||
return {"status": "already_exited", "error": "Process has already finished"}
|
||||
|
||||
if hasattr(session, '_pty') and session._pty:
|
||||
try:
|
||||
session._pty.sendeof()
|
||||
return {"status": "ok", "message": "EOF sent"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
if not session.process or not session.process.stdin:
|
||||
return {"status": "error", "error": "Process stdin not available (non-local backend or stdin closed)"}
|
||||
try:
|
||||
session.process.stdin.close()
|
||||
return {"status": "ok", "message": "stdin closed"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
def list_sessions(self, task_id: str = None) -> list:
|
||||
"""List all running and recently-finished processes."""
|
||||
with self._lock:
|
||||
@@ -915,14 +938,14 @@ PROCESS_SCHEMA = {
|
||||
"Actions: 'list' (show all), 'poll' (check status + new output), "
|
||||
"'log' (full output with pagination), 'wait' (block until done or timeout), "
|
||||
"'kill' (terminate), 'write' (send raw stdin data without newline), "
|
||||
"'submit' (send data + Enter, for answering prompts)."
|
||||
"'submit' (send data + Enter, for answering prompts), 'close' (close stdin/send EOF)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list", "poll", "log", "wait", "kill", "write", "submit"],
|
||||
"enum": ["list", "poll", "log", "wait", "kill", "write", "submit", "close"],
|
||||
"description": "Action to perform on background processes"
|
||||
},
|
||||
"session_id": {
|
||||
@@ -962,7 +985,7 @@ def _handle_process(args, **kw):
|
||||
|
||||
if action == "list":
|
||||
return _json.dumps({"processes": process_registry.list_sessions(task_id=task_id)}, ensure_ascii=False)
|
||||
elif action in ("poll", "log", "wait", "kill", "write", "submit"):
|
||||
elif action in ("poll", "log", "wait", "kill", "write", "submit", "close"):
|
||||
if not session_id:
|
||||
return tool_error(f"session_id is required for {action}")
|
||||
if action == "poll":
|
||||
@@ -978,7 +1001,9 @@ def _handle_process(args, **kw):
|
||||
return _json.dumps(process_registry.write_stdin(session_id, str(args.get("data", ""))), ensure_ascii=False)
|
||||
elif action == "submit":
|
||||
return _json.dumps(process_registry.submit_stdin(session_id, str(args.get("data", ""))), ensure_ascii=False)
|
||||
return tool_error(f"Unknown process action: {action}. Use: list, poll, log, wait, kill, write, submit")
|
||||
elif action == "close":
|
||||
return _json.dumps(process_registry.close_stdin(session_id), ensure_ascii=False)
|
||||
return tool_error(f"Unknown process action: {action}. Use: list, poll, log, wait, kill, write, submit, close")
|
||||
|
||||
|
||||
registry.register(
|
||||
|
||||
@@ -1112,6 +1112,21 @@ def _interpret_exit_code(command: str, exit_code: int) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _command_requires_pipe_stdin(command: str) -> bool:
|
||||
"""Return True when PTY mode would break stdin-driven commands.
|
||||
|
||||
Some CLIs change behavior when stdin is a TTY. In particular,
|
||||
`gh auth login --with-token` expects the token to arrive via piped stdin and
|
||||
waits for EOF; when we launch it under a PTY, `process.submit()` only sends a
|
||||
newline, so the command appears to hang forever with no visible progress.
|
||||
"""
|
||||
normalized = " ".join(command.lower().split())
|
||||
return (
|
||||
normalized.startswith("gh auth login")
|
||||
and "--with-token" in normalized
|
||||
)
|
||||
|
||||
|
||||
def terminal_tool(
|
||||
command: str,
|
||||
background: bool = False,
|
||||
@@ -1332,6 +1347,17 @@ def terminal_tool(
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# Prepare command for execution
|
||||
pty_disabled_reason = None
|
||||
effective_pty = pty
|
||||
if pty and _command_requires_pipe_stdin(command):
|
||||
effective_pty = False
|
||||
pty_disabled_reason = (
|
||||
"PTY disabled for this command because it expects piped stdin/EOF "
|
||||
"(for example gh auth login --with-token). For local background "
|
||||
"processes, call process(action='close') after writing so it receives "
|
||||
"EOF."
|
||||
)
|
||||
|
||||
if background:
|
||||
# Spawn a tracked background process via the process registry.
|
||||
# For local backends: uses subprocess.Popen with output buffering.
|
||||
@@ -1349,7 +1375,7 @@ def terminal_tool(
|
||||
task_id=effective_task_id,
|
||||
session_key=session_key,
|
||||
env_vars=env.env if hasattr(env, 'env') else None,
|
||||
use_pty=pty,
|
||||
use_pty=effective_pty,
|
||||
)
|
||||
else:
|
||||
proc_session = process_registry.spawn_via_env(
|
||||
@@ -1369,6 +1395,8 @@ def terminal_tool(
|
||||
}
|
||||
if approval_note:
|
||||
result_data["approval"] = approval_note
|
||||
if pty_disabled_reason:
|
||||
result_data["pty_note"] = pty_disabled_reason
|
||||
|
||||
# Transparent timeout clamping note
|
||||
max_timeout = effective_timeout
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: "Installation"
|
||||
description: "Install Hermes Agent on Linux, macOS, or WSL2"
|
||||
description: "Install Hermes Agent on Linux, macOS, WSL2, or Android via Termux"
|
||||
---
|
||||
|
||||
# Installation
|
||||
@@ -16,6 +16,23 @@ Get Hermes Agent up and running in under two minutes with the one-line installer
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
### Android / Termux
|
||||
|
||||
Hermes now ships a Termux-aware installer path too:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
The installer detects Termux automatically and switches to a tested Android flow:
|
||||
- uses Termux `pkg` for system dependencies (`git`, `python`, `nodejs`, `ripgrep`, `ffmpeg`, build tools)
|
||||
- creates the virtualenv with `python -m venv`
|
||||
- exports `ANDROID_API_LEVEL` automatically for Android wheel builds
|
||||
- installs a curated `.[termux]` extra with `pip`
|
||||
- skips the untested browser / WhatsApp bootstrap by default
|
||||
|
||||
If you want the fully explicit path, follow the dedicated [Termux guide](./termux.md).
|
||||
|
||||
:::warning Windows
|
||||
Native Windows is **not supported**. Please install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and run Hermes Agent from there. The install command above works inside WSL2.
|
||||
:::
|
||||
@@ -125,6 +142,7 @@ uv pip install -e "."
|
||||
| `tts-premium` | ElevenLabs premium voices | `uv pip install -e ".[tts-premium]"` |
|
||||
| `voice` | CLI microphone input + audio playback | `uv pip install -e ".[voice]"` |
|
||||
| `pty` | PTY terminal support | `uv pip install -e ".[pty]"` |
|
||||
| `termux` | Tested Android / Termux bundle (`cron`, `cli`, `pty`, `mcp`, `honcho`, `acp`) | `python -m pip install -e ".[termux]" -c constraints-termux.txt` |
|
||||
| `honcho` | AI-native memory (Honcho integration) | `uv pip install -e ".[honcho]"` |
|
||||
| `mcp` | Model Context Protocol support | `uv pip install -e ".[mcp]"` |
|
||||
| `homeassistant` | Home Assistant integration | `uv pip install -e ".[homeassistant]"` |
|
||||
@@ -134,6 +152,10 @@ uv pip install -e "."
|
||||
|
||||
You can combine extras: `uv pip install -e ".[messaging,cron]"`
|
||||
|
||||
:::tip Termux users
|
||||
`.[all]` is not currently available on Android because the `voice` extra pulls `faster-whisper`, which depends on `ctranslate2` wheels that are not published for Android. Use `.[termux]` for the tested mobile install path, then add individual extras only as needed.
|
||||
:::
|
||||
|
||||
</details>
|
||||
|
||||
### Step 4: Install Optional Submodules (if needed)
|
||||
|
||||
@@ -13,10 +13,14 @@ This guide walks you through installing Hermes Agent, setting up a provider, and
|
||||
Run the one-line installer:
|
||||
|
||||
```bash
|
||||
# Linux / macOS / WSL2
|
||||
# Linux / macOS / WSL2 / Android (Termux)
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
:::tip Android / Termux
|
||||
If you're installing on a phone, see the dedicated [Termux guide](./termux.md) for the tested manual path, supported extras, and current Android-specific limitations.
|
||||
:::
|
||||
|
||||
:::tip Windows Users
|
||||
Install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) first, then run the command above inside your WSL2 terminal.
|
||||
:::
|
||||
|
||||
228
website/docs/getting-started/termux.md
Normal file
228
website/docs/getting-started/termux.md
Normal file
@@ -0,0 +1,228 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
title: "Android / Termux"
|
||||
description: "Run Hermes Agent directly on an Android phone with Termux"
|
||||
---
|
||||
|
||||
# Hermes on Android with Termux
|
||||
|
||||
This is the tested path for running Hermes Agent directly on an Android phone through [Termux](https://termux.dev/).
|
||||
|
||||
It gives you a working local CLI on the phone, plus the core extras that are currently known to install cleanly on Android.
|
||||
|
||||
## What is supported in the tested path?
|
||||
|
||||
The tested Termux bundle installs:
|
||||
- the Hermes CLI
|
||||
- cron support
|
||||
- PTY/background terminal support
|
||||
- MCP support
|
||||
- Honcho memory support
|
||||
- ACP support
|
||||
|
||||
Concretely, it maps to:
|
||||
|
||||
```bash
|
||||
python -m pip install -e '.[termux]' -c constraints-termux.txt
|
||||
```
|
||||
|
||||
## What is not part of the tested path yet?
|
||||
|
||||
A few features still need desktop/server-style dependencies that are not published for Android, or have not been validated on phones yet:
|
||||
|
||||
- `.[all]` is not supported on Android today
|
||||
- the `voice` extra is blocked by `faster-whisper -> ctranslate2`, and `ctranslate2` does not publish Android wheels
|
||||
- automatic browser / Playwright bootstrap is skipped in the Termux installer
|
||||
- Docker-based terminal isolation is not available inside Termux
|
||||
|
||||
That does not stop Hermes from working well as a phone-native CLI agent — it just means the recommended mobile install is intentionally narrower than the desktop/server install.
|
||||
|
||||
---
|
||||
|
||||
## Option 1: One-line installer
|
||||
|
||||
Hermes now ships a Termux-aware installer path:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
On Termux, the installer automatically:
|
||||
- uses `pkg` for system packages
|
||||
- creates the venv with `python -m venv`
|
||||
- installs `.[termux]` with `pip`
|
||||
- skips the untested browser / WhatsApp bootstrap
|
||||
|
||||
If you want the explicit commands or need to debug a failed install, use the manual path below.
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Manual install (fully explicit)
|
||||
|
||||
### 1. Update Termux and install system packages
|
||||
|
||||
```bash
|
||||
pkg update
|
||||
pkg install -y git python clang rust make pkg-config libffi openssl nodejs ripgrep ffmpeg
|
||||
```
|
||||
|
||||
Why these packages?
|
||||
- `python` — runtime + venv support
|
||||
- `git` — clone/update the repo
|
||||
- `clang`, `rust`, `make`, `pkg-config`, `libffi`, `openssl` — needed to build a few Python dependencies on Android
|
||||
- `nodejs` — optional Node runtime for experiments beyond the tested core path
|
||||
- `ripgrep` — fast file search
|
||||
- `ffmpeg` — media / TTS conversions
|
||||
|
||||
### 2. Clone Hermes
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
```
|
||||
|
||||
If you already cloned without submodules:
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
### 3. Create a virtual environment
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk)"
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
```
|
||||
|
||||
`ANDROID_API_LEVEL` is important for Rust / maturin-based packages such as `jiter`.
|
||||
|
||||
### 4. Install the tested Termux bundle
|
||||
|
||||
```bash
|
||||
python -m pip install -e '.[termux]' -c constraints-termux.txt
|
||||
```
|
||||
|
||||
If you only want the minimal core agent, this also works:
|
||||
|
||||
```bash
|
||||
python -m pip install -e '.' -c constraints-termux.txt
|
||||
```
|
||||
|
||||
### 5. Verify the install
|
||||
|
||||
```bash
|
||||
hermes version
|
||||
hermes doctor
|
||||
```
|
||||
|
||||
### 6. Start Hermes
|
||||
|
||||
```bash
|
||||
hermes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommended follow-up setup
|
||||
|
||||
### Configure a model
|
||||
|
||||
```bash
|
||||
hermes model
|
||||
```
|
||||
|
||||
Or set keys directly in `~/.hermes/.env`.
|
||||
|
||||
### Re-run the full interactive setup wizard later
|
||||
|
||||
```bash
|
||||
hermes setup
|
||||
```
|
||||
|
||||
### Install optional Node dependencies manually
|
||||
|
||||
The tested Termux path skips Node/browser bootstrap on purpose. If you want to experiment later:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Treat browser / WhatsApp tooling on Android as experimental until documented otherwise.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `No solution found` when installing `.[all]`
|
||||
|
||||
Use the tested Termux bundle instead:
|
||||
|
||||
```bash
|
||||
python -m pip install -e '.[termux]' -c constraints-termux.txt
|
||||
```
|
||||
|
||||
The blocker is currently the `voice` extra:
|
||||
- `voice` pulls `faster-whisper`
|
||||
- `faster-whisper` depends on `ctranslate2`
|
||||
- `ctranslate2` does not publish Android wheels
|
||||
|
||||
### `uv pip install` fails on Android
|
||||
|
||||
Use the Termux path with the stdlib venv + `pip` instead:
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk)"
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
python -m pip install -e '.[termux]' -c constraints-termux.txt
|
||||
```
|
||||
|
||||
### `jiter` / `maturin` complains about `ANDROID_API_LEVEL`
|
||||
|
||||
Set the API level explicitly before installing:
|
||||
|
||||
```bash
|
||||
export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk)"
|
||||
python -m pip install -e '.[termux]' -c constraints-termux.txt
|
||||
```
|
||||
|
||||
### `hermes doctor` says ripgrep or Node is missing
|
||||
|
||||
Install them with Termux packages:
|
||||
|
||||
```bash
|
||||
pkg install ripgrep nodejs
|
||||
```
|
||||
|
||||
### Build failures while installing Python packages
|
||||
|
||||
Make sure the build toolchain is installed:
|
||||
|
||||
```bash
|
||||
pkg install clang rust make pkg-config libffi openssl
|
||||
```
|
||||
|
||||
Then retry:
|
||||
|
||||
```bash
|
||||
python -m pip install -e '.[termux]' -c constraints-termux.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known limitations on phones
|
||||
|
||||
- Docker backend is unavailable
|
||||
- local voice transcription via `faster-whisper` is unavailable in the tested path
|
||||
- browser automation setup is intentionally skipped by the installer
|
||||
- some optional extras may work, but only `.[termux]` is currently documented as the tested Android bundle
|
||||
|
||||
If you hit a new Android-specific issue, please open a GitHub issue with:
|
||||
- your Android version
|
||||
- `termux-info`
|
||||
- `python --version`
|
||||
- `hermes doctor`
|
||||
- the exact install command and full error output
|
||||
@@ -36,6 +36,20 @@ Set your provider with `hermes model` or by editing `~/.hermes/.env`. See the [E
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
### Does it work on Android / Termux?
|
||||
|
||||
Yes — Hermes now has a tested Termux install path for Android phones.
|
||||
|
||||
Quick install:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
For the fully explicit manual steps, supported extras, and current limitations, see the [Termux guide](../getting-started/termux.md).
|
||||
|
||||
Important caveat: the full `.[all]` extra is not currently available on Android because the `voice` extra depends on `faster-whisper` → `ctranslate2`, and `ctranslate2` does not publish Android wheels. Use the tested `.[termux]` extra instead.
|
||||
|
||||
### Is my data sent anywhere?
|
||||
|
||||
API calls go **only to the LLM provider you configure** (e.g., OpenRouter, your local Ollama instance). Hermes Agent does not collect telemetry, usage data, or analytics. Your conversations, memory, and skills are stored locally in `~/.hermes/`.
|
||||
|
||||
@@ -9,6 +9,7 @@ const sidebars: SidebarsConfig = {
|
||||
items: [
|
||||
'getting-started/quickstart',
|
||||
'getting-started/installation',
|
||||
'getting-started/termux',
|
||||
'getting-started/nix-setup',
|
||||
'getting-started/updating',
|
||||
'getting-started/learning-path',
|
||||
|
||||
Reference in New Issue
Block a user