Compare commits

...

19 Commits

Author SHA1 Message Date
alt-glitch
349f054760 docs: add hermes postinstall to installation + quickstart, fix update --check description
- installation.md: add tip about `hermes postinstall` for upfront dep install
- quickstart.md: show `hermes postinstall` in pip install flow
- updating.md: fix --check description to mention PyPI path for pip installs
2026-05-15 13:49:58 +00:00
alt-glitch
41a3cedfac refactor: DRY cleanup from code review
- dep_ensure.py: use get_hermes_home() instead of hand-rolled env var
- dep_ensure.py: add "chrome" to browser name list (was inconsistent with browser_tool.py)
- main.py _cmd_update_check: use detect_install_method() directly instead of redundant .git check
- main.py _cmd_update_pip: build command list directly instead of fragile split() on display string
- banner.py: rename _check_via_pypi → check_via_pypi (cross-module public API)
2026-05-15 13:47:43 +00:00
alt-glitch
a653d6c3d4 docs: add pip install path to installation, quickstart, updating, and CLI reference
Document pip install hermes-agent as a first-class install option.
Clarify that PyPI releases track tagged versions (major/minor),
not every commit on main — git installer is for bleeding-edge.
2026-05-15 13:45:46 +00:00
alt-glitch
cef942674b feat: add hermes postinstall command for pip users
One-shot bootstrap that installs non-Python deps (node, browser,
ripgrep, ffmpeg) via ensure_dependency(), then runs setup if no
provider is configured. Closes the gap between `pip install` and
the full user-facing experience.

Also fixes 3 pre-existing test regressions caused by earlier commits:
- test_recommended_update_command: mock detect_install_method for git env
- test_check_for_updates_no_git_dir: now falls back to PyPI, not None
- test_plist_path_includes_node_modules_bin: skip when dir absent
2026-05-15 13:43:20 +00:00
alt-glitch
f7d5f7ee29 chore: gitignore hermes_cli/scripts/ (bundled at wheel build time) 2026-05-15 13:33:35 +00:00
alt-glitch
c9a63d80ee feat: wire ensure_dependency into TUI and browser tool call sites
Before: missing node → hard exit; missing browser → FileNotFoundError.
After: both try ensure_dependency() first, which prompts interactively
and delegates installation to install.sh --ensure.

ripgrep and ffmpeg already degrade gracefully (grep fallback, skip
conversion) so they don't need wiring.

Also documents the design rationale in dep_ensure.py: detection and
prompting live in Python (portable, instant, UX-integrated); only
the actual installation delegates to install.sh (1900 lines of
battle-tested OS/package-manager logic).
2026-05-15 13:28:21 +00:00
alt-glitch
5b61bafebf chore(ci): pin actions/setup-node to SHA for supply-chain consistency 2026-05-15 13:22:21 +00:00
alt-glitch
9913545f77 fix(update): handle --check for pip installs (missed code path)
_cmd_update_check() had its own `.git` gate separate from _cmd_update_impl.
For pip installs, fork to _check_via_pypi() and display the result with
the correct recommended_update_command().
2026-05-15 13:11:26 +00:00
alt-glitch
57eaa63769 refactor: fix review findings — remove duplicate imports and deduplicate update command
- banner.py: remove redundant `import json as _json` (json already at module level)
- main.py: _cmd_update_pip now delegates to recommended_update_command_for_method
  instead of duplicating the uv-vs-pip detection logic
- main.py: remove redundant `import subprocess as _sp` (subprocess already at module level)
2026-05-15 13:11:26 +00:00
alt-glitch
446e8f4c65 feat: add ensure_dependency() wrapper + ship install.sh in wheel
Includes paired change: browser tool now searches ~/.hermes/node_modules/.bin/
for agent-browser installed via install.sh --ensure browser.
2026-05-15 13:11:26 +00:00
alt-glitch
ccc0bb8a32 chore(config): expand ensure_hermes_home to create full directory scaffold
Match the full set of subdirs created by install.sh: pairing, hooks,
image_cache, audio_cache, and skills are now pre-created alongside the
existing cron, sessions, logs, logs/curator, and memories dirs. This
makes hermes doctor checks cleaner without changing any runtime behaviour.
2026-05-15 13:11:26 +00:00
alt-glitch
ec8ecca978 feat(update): support pip install --upgrade for PyPI installs
When .git is absent and detect_install_method returns "pip", fork
hermes update to run `uv pip install --upgrade hermes-agent` (or
`python -m pip install --upgrade hermes-agent` as fallback) instead of
hard-exiting with "Not a git repository".
2026-05-15 13:11:26 +00:00
alt-glitch
29c98268f1 feat(config): detect pip install method and recommend correct update command
Adds detect_install_method() to identify nixos/homebrew/git/pip installs,
and recommended_update_command_for_method() to return the right upgrade command
for each method. Updates recommended_update_command() to use these for pip-installed
instances (no .git dir, not managed).
2026-05-15 13:11:26 +00:00
alt-glitch
5d98bb47de feat(tui): find bundled entry.js from wheel before falling back to npm build
Add _find_bundled_tui() that checks for hermes_cli/tui_dist/entry.js
(present in wheel installs) and wire it into _make_tui_argv() between
the HERMES_TUI_DIR prebuilt path and the npm install fallback.
2026-05-15 13:11:26 +00:00
alt-glitch
977b2dd2b4 fix(gateway): build service PATH from existing dirs only, include ~/.hermes/node_modules
Extract PATH building into _build_service_path_dirs() that skips directories
which don't exist on disk (e.g. node_modules/.bin for pip installs) and also
includes ~/.hermes/node/bin and ~/.hermes/node_modules/.bin for agent-browser.
2026-05-15 13:11:26 +00:00
alt-glitch
a0824df421 fix(doctor): generate config from defaults when template file is missing
When cli-config.yaml.example is not present (e.g. pip wheel install),
fall back to writing DEFAULT_CONFIG via save_config() instead of
warning and requiring a manual fix.
2026-05-15 13:11:26 +00:00
alt-glitch
4cd40dc512 feat(install): add --ensure and --postinstall modes for targeted dep bootstrap
Adds --ensure DEPS for pip-runtime dep installation and --postinstall
for pip users who want the full post-install experience without cloning.
2026-05-15 13:11:26 +00:00
alt-glitch
220a8c0be8 feat(banner): check PyPI for updates when not a git install
For pip-installed hermes-agent (no .git directory), fall back to
querying PyPI's JSON API to compare __version__ against the latest
published release, using stdlib only (urllib + json, no packaging dep).
2026-05-15 13:11:26 +00:00
alt-glitch
940526dfa4 ci(pypi): build web dashboard + TUI bundle before creating wheel 2026-05-15 13:11:26 +00:00
23 changed files with 726 additions and 37 deletions

View File

@@ -50,6 +50,32 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Build web dashboard
run: cd web && npm ci && npm run build
- name: Build TUI bundle
run: cd ui-tui && npm ci && npm run build
- name: Bundle TUI into hermes_cli
run: |
mkdir -p hermes_cli/tui_dist
cp ui-tui/dist/entry.js hermes_cli/tui_dist/entry.js
- name: Verify frontend assets exist
run: |
test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; }
test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; }
- name: Bundle install.sh into wheel
run: |
mkdir -p hermes_cli/scripts
cp scripts/install.sh hermes_cli/scripts/install.sh
- name: Build wheel and sdist
run: uv build --sdist --wheel

3
.gitignore vendored
View File

@@ -70,3 +70,6 @@ mini-swe-agent/
result
website/static/api/skills-index.json
models-dev-upstream/
hermes_cli/tui_dist/*
hermes_cli/scripts/
docs/superpowers/*

View File

@@ -175,6 +175,48 @@ def _check_via_local_git(repo_dir: Path) -> Optional[int]:
return None
def _version_tuple(v: str) -> tuple[int, ...]:
"""Parse '0.13.0' into (0, 13, 0) for comparison. Non-numeric segments become 0."""
parts = []
for segment in v.split("."):
try:
parts.append(int(segment))
except ValueError:
parts.append(0)
return tuple(parts)
def _fetch_pypi_latest(package: str = "hermes-agent") -> Optional[str]:
"""Fetch the latest version of a package from PyPI. Returns None on failure."""
try:
import urllib.request
url = f"https://pypi.org/pypi/{package}/json"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read())
return data.get("info", {}).get("version")
except Exception:
return None
def check_via_pypi() -> Optional[int]:
"""Compare installed version against PyPI latest.
Returns 0 if up-to-date, 1 if behind, None on failure.
"""
latest = _fetch_pypi_latest()
if latest is None:
return None
if latest == VERSION:
return 0
try:
if _version_tuple(latest) > _version_tuple(VERSION):
return 1
return 0
except Exception:
return 1 if latest != VERSION else 0
def check_for_updates() -> Optional[int]:
"""Check whether a Hermes update is available.
@@ -213,8 +255,9 @@ def check_for_updates() -> Optional[int]:
if not (repo_dir / ".git").exists():
repo_dir = hermes_home / "hermes-agent"
if not (repo_dir / ".git").exists():
return None
behind = _check_via_local_git(repo_dir)
behind = check_via_pypi()
else:
behind = _check_via_local_git(repo_dir)
try:
cache_file.write_text(json.dumps({"ts": now, "behind": behind, "rev": embedded_rev}))

View File

@@ -199,9 +199,40 @@ def get_managed_update_command() -> Optional[str]:
return None
def detect_install_method(project_root: Optional[Path] = None) -> str:
"""Detect how Hermes was installed: 'nixos', 'homebrew', 'git', or 'pip'."""
managed = get_managed_system()
if managed:
return managed.lower().replace(" ", "-")
if project_root is None:
project_root = Path(__file__).parent.parent.resolve()
if (project_root / ".git").is_dir():
return "git"
return "pip"
def recommended_update_command_for_method(method: str) -> str:
"""Return the update command for a given install method."""
if method == "nixos":
return "sudo nixos-rebuild switch"
if method == "homebrew":
return "brew upgrade hermes-agent"
if method == "pip":
import shutil
uv = shutil.which("uv")
if uv:
return "uv pip install --upgrade hermes-agent"
return "pip install --upgrade hermes-agent"
return "hermes update"
def recommended_update_command() -> str:
"""Return the best update command for the current installation."""
return get_managed_update_command() or "hermes update"
managed_cmd = get_managed_update_command()
if managed_cmd:
return managed_cmd
method = detect_install_method()
return recommended_update_command_for_method(method)
def format_managed_message(action: str = "modify this Hermes installation") -> str:
@@ -401,7 +432,10 @@ def ensure_hermes_home():
else:
home.mkdir(parents=True, exist_ok=True)
_secure_dir(home)
for subdir in ("cron", "sessions", "logs", "logs/curator", "memories"):
for subdir in (
"cron", "sessions", "logs", "logs/curator", "memories",
"pairing", "hooks", "image_cache", "audio_cache", "skills",
):
d = home / subdir
d.mkdir(parents=True, exist_ok=True)
_secure_dir(d)

106
hermes_cli/dep_ensure.py Normal file
View File

@@ -0,0 +1,106 @@
"""Lazy dependency bootstrapper for non-Python runtime deps.
Detection and prompting live here in Python — not in install.sh — because:
1. shutil.which() works on every platform; install.sh needs bash.
2. Detection is instant; spawning bash for a "is node installed?" check is waste.
3. Python controls the UX (rich prompts, non-interactive fallback, TTY detection).
install.sh is still the *installation* backend because it has 1900 lines of
battle-tested OS detection and package-manager logic (apt/brew/pacman/dnf/
zypper/Termux/…). Reimplementing that in Python would be huge duplication.
Deps that degrade gracefully (ripgrep → grep fallback, ffmpeg → skip conversion)
don't need ensure_dependency wired in — only hard-fail sites do (TUI needs node,
browser tool needs agent-browser).
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
_DEP_CHECKS = {
"node": lambda: shutil.which("node") is not None,
"browser": lambda: (
shutil.which("agent-browser") is not None
or _has_system_browser()
or _has_hermes_agent_browser()
),
"ripgrep": lambda: shutil.which("rg") is not None,
"ffmpeg": lambda: shutil.which("ffmpeg") is not None,
}
_DEP_DESCRIPTIONS = {
"node": "Node.js (required for browser tools and TUI)",
"browser": "Browser engine (Chromium, for web browsing tools)",
"ripgrep": "ripgrep (fast file search)",
"ffmpeg": "ffmpeg (TTS voice messages)",
}
def _has_system_browser() -> bool:
for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"):
if shutil.which(name):
return True
return False
def _has_hermes_agent_browser() -> bool:
from hermes_constants import get_hermes_home
return (get_hermes_home() / "node_modules" / ".bin" / "agent-browser").is_file()
def _find_install_script(
package_dir: Path | None = None,
repo_root: Path | None = None,
) -> Path | None:
"""Locate install.sh — bundled in wheel or in git checkout."""
if package_dir is None:
package_dir = Path(__file__).parent
if repo_root is None:
repo_root = package_dir.parent
bundled = package_dir / "scripts" / "install.sh"
if bundled.is_file():
return bundled
repo = repo_root / "scripts" / "install.sh"
if repo.is_file():
return repo
return None
def ensure_dependency(dep: str, interactive: bool = True) -> bool:
"""Ensure a non-Python dependency is available. Returns True if available."""
check = _DEP_CHECKS.get(dep)
if check and check():
return True
script = _find_install_script()
if script is None:
if interactive:
desc = _DEP_DESCRIPTIONS.get(dep, dep)
print(f" {desc} is not installed and install.sh was not found.")
print(f" Install {dep} manually and try again.")
return False
if interactive and sys.stdin.isatty():
desc = _DEP_DESCRIPTIONS.get(dep, dep)
try:
reply = input(f"{desc} is not installed. Install now? [Y/n] ").strip().lower()
except (EOFError, KeyboardInterrupt):
return False
if reply not in ("", "y", "yes"):
return False
result = subprocess.run(
["bash", str(script), "--ensure", dep],
env={**os.environ, "IS_INTERACTIVE": "false"},
)
if result.returncode != 0:
return False
if check:
return check()
return True

View File

@@ -656,15 +656,17 @@ def run_doctor(args):
if fallback_config.exists():
check_ok("cli-config.yaml exists (in project directory)")
else:
example_config = PROJECT_ROOT / 'cli-config.yaml.example'
if should_fix and example_config.exists():
if should_fix:
config_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(example_config), str(config_path))
check_ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example")
example_config = PROJECT_ROOT / 'cli-config.yaml.example'
if example_config.exists():
shutil.copy2(str(example_config), str(config_path))
check_ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example")
else:
from hermes_cli.config import DEFAULT_CONFIG, save_config
save_config(DEFAULT_CONFIG)
check_ok(f"Created {_DHH}/config.yaml from defaults")
fixed_count += 1
elif should_fix:
check_warn("config.yaml not found and no example to copy from")
manual_issues.append(f"Create {_DHH}/config.yaml manually")
else:
check_warn("config.yaml not found", "(using defaults)")

View File

@@ -2103,15 +2103,41 @@ def _hermes_home_for_target_user(target_home_dir: str) -> str:
return str(current_hermes)
def _build_service_path_dirs(project_root: Path | None = None) -> list[str]:
"""Build PATH directory list for service units, excluding non-existent dirs."""
if project_root is None:
project_root = PROJECT_ROOT
candidates = []
venv_bin = project_root / "venv" / "bin"
if venv_bin.is_dir():
candidates.append(str(venv_bin))
elif sys.prefix != sys.base_prefix:
candidates.append(str(Path(sys.prefix) / "bin"))
node_bin = project_root / "node_modules" / ".bin"
if node_bin.is_dir():
candidates.append(str(node_bin))
hermes_home = get_hermes_home()
hermes_node = hermes_home / "node" / "bin"
if hermes_node.is_dir():
candidates.append(str(hermes_node))
hermes_nm = hermes_home / "node_modules" / ".bin"
if hermes_nm.is_dir():
candidates.append(str(hermes_nm))
return candidates
def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str:
python_path = get_python_path()
working_dir = str(PROJECT_ROOT)
detected_venv = _detect_venv_dir()
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
venv_bin = str(detected_venv / "bin") if detected_venv else str(PROJECT_ROOT / "venv" / "bin")
node_bin = str(PROJECT_ROOT / "node_modules" / ".bin")
path_entries = [venv_bin, node_bin]
path_entries = _build_service_path_dirs()
resolved_node = shutil.which("node")
if resolved_node:
resolved_node_dir = str(Path(resolved_node).resolve().parent)
@@ -2138,8 +2164,6 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
python_path = _remap_path_for_user(python_path, home_dir)
working_dir = _remap_path_for_user(working_dir, home_dir)
venv_dir = _remap_path_for_user(venv_dir, home_dir)
venv_bin = _remap_path_for_user(venv_bin, home_dir)
node_bin = _remap_path_for_user(node_bin, home_dir)
path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries]
path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries))
path_entries.extend(_build_wsl_interop_paths(path_entries))
@@ -2754,12 +2778,10 @@ def generate_launchd_plist() -> str:
# the systemd unit), then capture the user's full shell PATH so every
# user-installed tool (node, ffmpeg, …) is reachable.
detected_venv = _detect_venv_dir()
venv_bin = str(detected_venv / "bin") if detected_venv else str(PROJECT_ROOT / "venv" / "bin")
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
node_bin = str(PROJECT_ROOT / "node_modules" / ".bin")
# Resolve the directory containing the node binary (e.g. Homebrew, nvm)
# so it's explicitly in PATH even if the user's shell PATH changes later.
priority_dirs = [venv_bin, node_bin]
priority_dirs = _build_service_path_dirs()
resolved_node = shutil.which("node")
if resolved_node:
resolved_node_dir = str(Path(resolved_node).resolve().parent)

View File

@@ -1024,6 +1024,14 @@ def _ensure_tui_node() -> None:
os.environ["PATH"] = os.pathsep.join(parts)
def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None:
"""Find a pre-built TUI entry.js bundled in the wheel."""
if hermes_cli_dir is None:
hermes_cli_dir = Path(__file__).parent
bundled = hermes_cli_dir / "tui_dist" / "entry.js"
return bundled if bundled.is_file() else None
def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
"""TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR prebuilt or esbuild)."""
_ensure_tui_node()
@@ -1034,6 +1042,13 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK):
return env_node
path = shutil.which(bin)
if not path and bin == "node":
try:
from hermes_cli.dep_ensure import ensure_dependency
if ensure_dependency("node"):
path = shutil.which("node")
except Exception:
pass
if not path:
print(f"{bin} not found — install Node.js to use the TUI.")
sys.exit(1)
@@ -1058,6 +1073,12 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
node = _node_bin("node")
return [node, str(p / "dist" / "entry.js")], p
# 1b. Bundled in wheel (pip install)
bundled = _find_bundled_tui()
if bundled is not None:
node = _node_bin("node")
return [node, str(bundled)], bundled.parent
# 2. Normal flow: npm install if needed, always esbuild, then node dist/entry.js.
# --dev flow: npm install if needed, then tsx src/entry.tsx (no build).
if _tui_need_npm_install(tui_dir):
@@ -1677,6 +1698,24 @@ def cmd_setup(args):
run_setup_wizard(args)
def cmd_postinstall(args):
"""One-shot bootstrap for pip users: install non-Python deps + run setup."""
from hermes_cli.dep_ensure import ensure_dependency
print("⚕ Hermes post-install bootstrap")
print()
for dep in ("node", "browser", "ripgrep", "ffmpeg"):
ensure_dependency(dep)
if not _has_any_provider_configured():
print()
cmd_setup(args)
else:
print()
print("✓ Post-install complete.")
def cmd_model(args):
"""Select default model — starts with provider selection, then model picker."""
_require_tty("model")
@@ -7282,6 +7321,22 @@ def _finalize_update_output(state):
def _cmd_update_check():
"""Implement ``hermes update --check``: fetch and report without installing."""
from hermes_cli.config import detect_install_method
method = detect_install_method(PROJECT_ROOT)
if method == "pip":
from hermes_cli.config import recommended_update_command
from hermes_cli.banner import check_via_pypi
result = check_via_pypi()
if result is None:
print("✗ Could not reach PyPI to check for updates.")
sys.exit(1)
elif result == 0:
print("✓ Already up to date.")
else:
print("⚕ Update available on PyPI.")
print(f" Run '{recommended_update_command()}' to install.")
return
git_dir = PROJECT_ROOT / ".git"
if not git_dir.exists():
print("✗ Not a git repository — cannot check for updates.")
@@ -7559,6 +7614,28 @@ def cmd_update(args):
_finalize_update_output(_update_io_state)
def _cmd_update_pip(args):
"""Update Hermes via pip (for PyPI installs)."""
from hermes_cli import __version__
print(f"→ Current version: {__version__}")
print("→ Checking PyPI for updates...")
uv = shutil.which("uv")
if uv:
cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"]
else:
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"]
print(f"→ Running: {' '.join(cmd)}")
result = subprocess.run(cmd)
if result.returncode != 0:
print("✗ Update failed")
sys.exit(1)
print("✓ Update complete! Restart hermes to use the new version.")
def _cmd_update_impl(args, gateway_mode: bool):
"""Body of ``cmd_update`` — kept separate so the wrapper can always
restore stdio even on ``sys.exit``."""
@@ -7586,6 +7663,11 @@ def _cmd_update_impl(args, gateway_mode: bool):
if sys.platform == "win32":
use_zip_update = True
else:
from hermes_cli.config import detect_install_method
method = detect_install_method(PROJECT_ROOT)
if method == "pip":
_cmd_update_pip(args)
return
print("✗ Not a git repository. Please reinstall:")
print(
" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash"
@@ -9424,7 +9506,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"config", "cron", "curator", "dashboard", "debug", "doctor",
"dump", "fallback", "gateway", "hooks", "import", "insights",
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory",
"model", "pairing", "plugins", "profile", "proxy", "sessions", "setup",
"model", "pairing", "plugins", "postinstall", "profile", "proxy", "sessions", "setup",
"skills", "slack", "status", "tools", "uninstall", "update",
"version", "webhook", "whatsapp", "chat",
# Help-ish invocations — plugin commands not being listed in
@@ -9863,6 +9945,17 @@ def main():
)
setup_parser.set_defaults(func=cmd_setup)
# =========================================================================
# postinstall command
# =========================================================================
postinstall_parser = subparsers.add_parser(
"postinstall",
help="Bootstrap non-Python deps for pip installs (node, browser, ripgrep, ffmpeg)",
description="One-shot post-install for pip users. Installs system "
"dependencies that pip cannot provide, then runs setup if needed.",
)
postinstall_parser.set_defaults(func=cmd_postinstall)
# =========================================================================
# whatsapp command
# =========================================================================

View File

@@ -210,7 +210,7 @@ hermes-acp = "acp_adapter.entry:main"
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils"]
[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*"]
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh"]
gateway = ["assets/**/*"]
acp_adapter = ["bootstrap/*.sh", "bootstrap/*.ps1"]

View File

@@ -71,6 +71,8 @@ USE_VENV=true
RUN_SETUP=true
SKIP_BROWSER=false
BRANCH="main"
ENSURE_DEPS=""
POSTINSTALL_MODE=false
# Detect non-interactive mode (e.g. curl | bash)
# When stdin is not a terminal, read -p will fail with EOF,
@@ -109,6 +111,14 @@ while [[ $# -gt 0 ]]; do
HERMES_HOME="$2"
shift 2
;;
--ensure)
ENSURE_DEPS="$2"
shift 2
;;
--postinstall)
POSTINSTALL_MODE=true
shift
;;
-h|--help)
echo "Hermes Agent Installer"
echo ""
@@ -133,6 +143,12 @@ while [[ $# -gt 0 ]]; do
echo " (default /root/.hermes). This keeps Docker bind-mounted volumes"
echo " small and ensures the command is on PATH for all shells."
echo " Existing installs at \$HERMES_HOME/hermes-agent are preserved in-place."
echo " --ensure DEPS Install only specified deps (comma-separated)"
echo " Supported: node, browser, ripgrep, ffmpeg"
echo " Does NOT clone repo or create venv"
echo " --postinstall Run post-install setup only (for pip users)"
echo " Installs optional deps + runs hermes setup"
echo " Does NOT clone repo or create venv"
exit 0
;;
*)
@@ -1872,6 +1888,88 @@ print_success() {
fi
}
ensure_mode() {
detect_os
IFS=',' read -ra DEPS <<< "$ENSURE_DEPS"
for dep in "${DEPS[@]}"; do
dep="$(echo "$dep" | tr -d '[:space:]')"
case "$dep" in
node)
check_node
;;
browser)
check_node
if [ "$HAS_NODE" = true ]; then
DETECTED_BROWSER_EXECUTABLE="$(find_system_browser 2>/dev/null || true)"
if [ -z "$DETECTED_BROWSER_EXECUTABLE" ]; then
log_info "Installing agent-browser + Chromium..."
npm_bin="$(command -v npm 2>/dev/null || echo "")"
if [ -n "$npm_bin" ]; then
local agent_browser_dir="$HERMES_HOME/node_modules"
mkdir -p "$agent_browser_dir"
"$npm_bin" install --prefix "$HERMES_HOME" agent-browser 2>/dev/null || true
npx playwright install chromium 2>/dev/null || true
fi
else
log_success "System browser found: $DETECTED_BROWSER_EXECUTABLE"
fi
fi
;;
ripgrep)
if ! command -v rg &>/dev/null; then
HAS_RIPGREP=false
HAS_FFMPEG=true
install_system_packages
fi
;;
ffmpeg)
if ! command -v ffmpeg &>/dev/null; then
HAS_FFMPEG=false
HAS_RIPGREP=true
install_system_packages
fi
;;
*)
log_warn "Unknown dependency: $dep"
;;
esac
done
}
postinstall_mode() {
print_banner
detect_os
log_info "Post-install mode: setting up Hermes for pip install"
check_node
check_network_prerequisites
install_system_packages
if [ "$HAS_NODE" = true ] && [ "$SKIP_BROWSER" = false ]; then
DETECTED_BROWSER_EXECUTABLE="$(find_system_browser 2>/dev/null || true)"
if [ -z "$DETECTED_BROWSER_EXECUTABLE" ]; then
log_info "Installing browser engine..."
npm_bin="$(command -v npm 2>/dev/null || echo "")"
if [ -n "$npm_bin" ]; then
npx playwright install chromium 2>/dev/null || true
fi
else
log_success "System browser found: $DETECTED_BROWSER_EXECUTABLE"
fi
fi
HERMES_CMD="$(command -v hermes 2>/dev/null || echo "")"
if [ -n "$HERMES_CMD" ]; then
log_info "Running hermes setup..."
"$HERMES_CMD" setup
else
log_warn "hermes command not found on PATH"
log_info "Try: python -m hermes_cli.main setup"
fi
}
# ============================================================================
# Main
# ============================================================================
@@ -1900,4 +1998,10 @@ main() {
print_success
}
main
if [ -n "$ENSURE_DEPS" ]; then
ensure_mode
elif [ "$POSTINSTALL_MODE" = true ]; then
postinstall_mode
else
main
fi

View File

@@ -0,0 +1,35 @@
from unittest.mock import patch
def testcheck_via_pypi_detects_update():
"""check_via_pypi returns 1 when PyPI has newer version."""
from hermes_cli.banner import check_via_pypi
with patch("hermes_cli.banner.VERSION", "0.12.0"):
with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"):
result = check_via_pypi()
assert result == 1
def testcheck_via_pypi_up_to_date():
"""check_via_pypi returns 0 when versions match."""
from hermes_cli.banner import check_via_pypi
with patch("hermes_cli.banner.VERSION", "0.13.0"):
with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"):
result = check_via_pypi()
assert result == 0
def testcheck_via_pypi_network_failure():
"""check_via_pypi returns None on network error."""
from hermes_cli.banner import check_via_pypi
with patch("hermes_cli.banner._fetch_pypi_latest", return_value=None):
result = check_via_pypi()
assert result is None
def test_version_tuple_comparison():
"""Version comparison works with multi-segment versions."""
from hermes_cli.banner import _version_tuple
assert _version_tuple("0.13.0") > _version_tuple("0.12.0")
assert _version_tuple("0.13.0") == _version_tuple("0.13.0")
assert _version_tuple("1.0.0") > _version_tuple("0.99.99")

View File

@@ -0,0 +1,43 @@
from pathlib import Path
from unittest.mock import patch
def test_ensure_dependency_skips_when_present():
"""ensure_dependency is a no-op when the dep is already available."""
from hermes_cli.dep_ensure import ensure_dependency
with patch("hermes_cli.dep_ensure.shutil") as mock_shutil:
mock_shutil.which.return_value = "/usr/bin/node"
result = ensure_dependency("node", interactive=False)
assert result is True
def test_ensure_dependency_returns_false_when_missing_noninteractive():
"""ensure_dependency returns False for missing dep in non-interactive mode."""
from hermes_cli.dep_ensure import ensure_dependency
with patch("hermes_cli.dep_ensure.shutil") as mock_shutil:
mock_shutil.which.return_value = None
with patch("hermes_cli.dep_ensure._find_install_script", return_value=None):
result = ensure_dependency("node", interactive=False)
assert result is False
def test_find_install_script_from_checkout(tmp_path):
"""_find_install_script finds scripts/install.sh in a git checkout."""
from hermes_cli.dep_ensure import _find_install_script
scripts_dir = tmp_path / "scripts"
scripts_dir.mkdir()
(scripts_dir / "install.sh").write_text("#!/bin/bash", encoding="utf-8")
result = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path)
assert result is not None
assert result.name == "install.sh"
def test_find_install_script_from_wheel(tmp_path):
"""_find_install_script finds bundled install.sh in a wheel."""
from hermes_cli.dep_ensure import _find_install_script
bundled = tmp_path / "hermes_cli" / "scripts"
bundled.mkdir(parents=True)
(bundled / "install.sh").write_text("#!/bin/bash", encoding="utf-8")
result = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path)
assert result is not None
assert result.name == "install.sh"

View File

@@ -0,0 +1,31 @@
from pathlib import Path
from unittest.mock import patch
def test_service_path_skips_nonexistent_node_modules(tmp_path):
"""Service PATH should not include node_modules/.bin if it doesn't exist."""
from hermes_cli.gateway import _build_service_path_dirs
with patch("hermes_cli.gateway.get_hermes_home", return_value=tmp_path / ".hermes"):
dirs = _build_service_path_dirs(project_root=tmp_path)
node_modules_bin = str(tmp_path / "node_modules" / ".bin")
assert node_modules_bin not in dirs
def test_service_path_includes_node_modules_when_present(tmp_path):
"""Service PATH should include node_modules/.bin when it exists."""
nm_bin = tmp_path / "node_modules" / ".bin"
nm_bin.mkdir(parents=True)
from hermes_cli.gateway import _build_service_path_dirs
with patch("hermes_cli.gateway.get_hermes_home", return_value=tmp_path / ".hermes"):
dirs = _build_service_path_dirs(project_root=tmp_path)
assert str(nm_bin) in dirs
def test_service_path_includes_hermes_home_node_modules(tmp_path):
"""Service PATH should include ~/.hermes/node_modules/.bin when it exists."""
hermes_nm = tmp_path / ".hermes" / "node_modules" / ".bin"
hermes_nm.mkdir(parents=True)
from hermes_cli.gateway import _build_service_path_dirs
with patch("hermes_cli.gateway.get_hermes_home", return_value=tmp_path / ".hermes"):
dirs = _build_service_path_dirs(project_root=tmp_path)
assert str(hermes_nm) in dirs

View File

@@ -29,7 +29,8 @@ def test_format_managed_message_homebrew(monkeypatch):
def test_recommended_update_command_defaults_to_hermes_update(monkeypatch):
monkeypatch.delenv("HERMES_MANAGED", raising=False)
assert recommended_update_command() == "hermes update"
with patch("hermes_cli.config.detect_install_method", return_value="git"):
assert recommended_update_command() == "hermes update"
def test_cmd_update_blocks_managed_homebrew(monkeypatch, capsys):

View File

@@ -0,0 +1,37 @@
from pathlib import Path
from unittest.mock import patch
def test_pip_install_detected_when_no_git_dir(tmp_path):
"""When PROJECT_ROOT has no .git, detect as pip install."""
with patch("hermes_cli.config.get_managed_system", return_value=None):
from hermes_cli.config import detect_install_method
method = detect_install_method(project_root=tmp_path)
assert method == "pip"
def test_git_install_detected_when_git_dir_exists(tmp_path):
"""When PROJECT_ROOT has .git, detect as git install."""
(tmp_path / ".git").mkdir()
with patch("hermes_cli.config.get_managed_system", return_value=None):
from hermes_cli.config import detect_install_method
method = detect_install_method(project_root=tmp_path)
assert method == "git"
def test_managed_install_takes_precedence(tmp_path):
"""When HERMES_MANAGED is set, that takes precedence over git detection."""
(tmp_path / ".git").mkdir()
with patch("hermes_cli.config.get_managed_system", return_value="NixOS"):
from hermes_cli.config import detect_install_method
method = detect_install_method(project_root=tmp_path)
assert method == "nixos"
def test_recommended_update_command_pip():
"""Pip installs recommend pip install --upgrade."""
from hermes_cli.config import recommended_update_command_for_method
cmd = recommended_update_command_for_method("pip")
assert "pip install" in cmd or "uv pip install" in cmd
assert "--upgrade" in cmd
assert "hermes-agent" in cmd

View File

@@ -0,0 +1,21 @@
from pathlib import Path
def test_tui_finds_bundled_entry_js(tmp_path):
"""_find_bundled_tui finds entry.js bundled in the package."""
tui_dist = tmp_path / "hermes_cli" / "tui_dist"
tui_dist.mkdir(parents=True)
entry = tui_dist / "entry.js"
entry.write_text("// bundled TUI", encoding="utf-8")
from hermes_cli.main import _find_bundled_tui
result = _find_bundled_tui(hermes_cli_dir=tmp_path / "hermes_cli")
assert result is not None
assert result.name == "entry.js"
def test_tui_returns_none_when_no_bundle(tmp_path):
"""_find_bundled_tui returns None when no bundle exists."""
from hermes_cli.main import _find_bundled_tui
result = _find_bundled_tui(hermes_cli_dir=tmp_path / "hermes_cli")
assert result is None

View File

@@ -59,7 +59,7 @@ def test_check_for_updates_expired_cache(tmp_path, monkeypatch):
def test_check_for_updates_no_git_dir(tmp_path, monkeypatch):
"""Returns None when .git directory doesn't exist anywhere."""
"""Falls back to PyPI check when .git directory doesn't exist anywhere."""
import hermes_cli.banner as banner
# Create a fake banner.py so the fallback path also has no .git
@@ -70,8 +70,9 @@ def test_check_for_updates_no_git_dir(tmp_path, monkeypatch):
monkeypatch.setattr(banner, "__file__", str(fake_banner))
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
with patch("hermes_cli.banner.subprocess.run") as mock_run:
result = banner.check_for_updates()
assert result is None
with patch("hermes_cli.banner.check_via_pypi", return_value=0):
result = banner.check_for_updates()
assert result == 0
mock_run.assert_not_called()

View File

@@ -178,8 +178,11 @@ class TestLaunchdPlistPath:
raise AssertionError("PATH key not found in plist")
def test_plist_path_includes_node_modules_bin(self):
node_bin_dir = gateway_cli.PROJECT_ROOT / "node_modules" / ".bin"
if not node_bin_dir.is_dir():
pytest.skip("node_modules/.bin not present in this checkout")
plist = gateway_cli.generate_launchd_plist()
node_bin = str(gateway_cli.PROJECT_ROOT / "node_modules" / ".bin")
node_bin = str(node_bin_dir)
lines = plist.splitlines()
for i, line in enumerate(lines):
if "<key>PATH</key>" in line.strip():

View File

@@ -144,7 +144,8 @@ def _browser_candidate_path_dirs() -> list[str]:
"""Return ordered browser CLI PATH candidates shared by discovery and execution."""
hermes_home = get_hermes_home()
hermes_node_bin = str(hermes_home / "node" / "bin")
return [hermes_node_bin, *list(_discover_homebrew_node_dirs()), *_SANE_PATH_DIRS]
hermes_nm_bin = str(hermes_home / "node_modules" / ".bin")
return [hermes_node_bin, hermes_nm_bin, *list(_discover_homebrew_node_dirs()), *_SANE_PATH_DIRS]
def _merge_browser_path(existing_path: str = "") -> str:
@@ -1702,7 +1703,23 @@ def _find_agent_browser() -> str:
_agent_browser_resolved = True
return _cached_agent_browser
# Nothing found — cache the failure so subsequent calls don't re-scan.
# Nothing found — try lazy installation before giving up.
try:
from hermes_cli.dep_ensure import ensure_dependency
if ensure_dependency("browser"):
recheck = shutil.which("agent-browser")
if not recheck and extended_path:
recheck = shutil.which("agent-browser", path=extended_path)
if not recheck:
hermes_nm = str(get_hermes_home() / "node_modules" / ".bin")
recheck = shutil.which("agent-browser", path=hermes_nm)
if recheck:
_cached_agent_browser = recheck
_agent_browser_resolved = True
return recheck
except Exception:
pass
_agent_browser_resolved = True
raise FileNotFoundError(
"agent-browser CLI not found. Install it with: "

View File

@@ -10,7 +10,34 @@ Get Hermes Agent up and running in under two minutes with the one-line installer
## Quick Install
### Linux / macOS / WSL2
### pip (recommended for most users)
```bash
pip install hermes-agent
```
This gives you the full Hermes Agent — CLI, web dashboard, and TUI — with zero external dependencies for core usage. Node.js, browser engines, and other optional tools are bootstrapped lazily on first use (e.g. when you run `hermes --tui` or use browser tools).
PyPI releases track **tagged versions** (major and minor releases), not every commit on `main`. If you want bleeding-edge changes as they land, use the git install below.
After installing, run:
```bash
hermes setup # interactive wizard — configures your LLM provider and API key
hermes # start chatting
```
:::tip Optional: install everything upfront
`hermes postinstall` installs Node.js, browser engines, ripgrep, and ffmpeg in one shot — then runs the setup wizard. Use this if you want the full experience (TUI, browser tools, voice) without waiting for lazy installs on first use.
:::
:::tip
If you have [uv](https://docs.astral.sh/uv/) installed, `uv pip install hermes-agent` is faster.
:::
### One-Line Installer (Linux / macOS / WSL2)
For a git-based install that tracks `main` and gives you the latest changes immediately:
```bash
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
@@ -80,7 +107,8 @@ Where the installer puts things depends on whether you're installing as a normal
| Installer | Code lives at | `hermes` binary | Data directory |
|---|---|---|---|
| Per-user (normal) | `~/.hermes/hermes-agent/` | `~/.local/bin/hermes` (symlink) | `~/.hermes/` |
| pip install | Python site-packages | `~/.local/bin/hermes` (console_scripts) | `~/.hermes/` |
| Per-user (git installer) | `~/.hermes/hermes-agent/` | `~/.local/bin/hermes` (symlink) | `~/.hermes/` |
| Root-mode (`sudo curl … \| sudo bash`) | `/usr/local/lib/hermes-agent/` | `/usr/local/bin/hermes` | `/root/.hermes/` (or `$HERMES_HOME`) |
The root-mode **FHS layout** (`/usr/local/lib/…`, `/usr/local/bin/hermes`) matches where other system-wide developer tools land on Linux. It's useful for shared-machine deployments where one system install should serve every user. Per-user config (auth, skills, sessions) still lives under each user's `~/.hermes/` or explicit `HERMES_HOME`.
@@ -108,7 +136,9 @@ hermes setup # Or run the full setup wizard to configure everything at
## Prerequisites
The only prerequisite is **Git**. The installer automatically handles everything else:
**pip install:** No prerequisites beyond Python 3.11+. Everything else is handled automatically.
**Git installer:** The only prerequisite is **Git**. The installer automatically handles everything else:
- **uv** (fast Python package manager)
- **Python 3.11** (via uv, no sudo needed)

View File

@@ -48,7 +48,16 @@ Pick the row that matches your goal:
## 1. Install Hermes Agent
Run the one-line installer:
**Option A — pip (simplest):**
```bash
pip install hermes-agent
hermes postinstall # optional: installs Node.js, browser, ripgrep, ffmpeg + runs setup
```
PyPI releases track tagged versions (major/minor releases), not every commit on `main`. For bleeding-edge, use Option B.
**Option B — git installer (tracks main branch):**
```bash
# Linux / macOS / WSL2 / Android (Termux)

View File

@@ -8,19 +8,36 @@ description: "How to update Hermes Agent to the latest version or uninstall it"
## Updating
### Git installs
Update to the latest version with a single command:
```bash
hermes update
```
This pulls the latest code, updates dependencies, and prompts you to configure any new options that were added since your last update.
This pulls the latest code from `main`, updates dependencies, and prompts you to configure any new options that were added since your last update.
### pip installs
PyPI releases track **tagged versions** (major and minor releases), not every commit on `main`. Check for updates and upgrade with:
```bash
hermes update --check # see if a newer release is on PyPI
hermes update # runs pip install --upgrade hermes-agent
```
Or manually:
```bash
pip install --upgrade hermes-agent # or: uv pip install --upgrade hermes-agent
```
:::tip
`hermes update` automatically detects new configuration options and prompts you to add them. If you skipped that prompt, you can manually run `hermes config check` to see missing options, then `hermes config migrate` to interactively add them.
:::
### What happens during an update
### What happens during an update (git installs)
When you run `hermes update`, the following steps occur:
@@ -32,7 +49,7 @@ When you run `hermes update`, the following steps occur:
### Preview-only: `hermes update --check`
Want to know if you're behind `origin/main` before actually pulling? Run `hermes update --check` — it fetches, prints your local commit and the latest remote commit side-by-side, and exits `0` if in sync or `1` if behind. No files are modified, no gateway is restarted. Useful in scripts and cron jobs that gate on "is there an update".
Want to know if an update is available before pulling? Run `hermes update --check` — for git installs it fetches and compares commits against `origin/main`; for pip installs it queries PyPI for the latest release. No files are modified, no gateway is restarted. Useful in scripts and cron jobs that gate on "is there an update".
### Full pre-update backup: `--backup`
@@ -189,12 +206,21 @@ See [Nix Setup](./nix-setup.md) for more details.
## Uninstalling
### Git installs
```bash
hermes uninstall
```
The uninstaller gives you the option to keep your configuration files (`~/.hermes/`) for a future reinstall.
### pip installs
```bash
pip uninstall hermes-agent
rm -rf ~/.hermes # Optional — keep if you plan to reinstall
```
### Manual Uninstall
```bash

View File

@@ -76,7 +76,7 @@ hermes [global-options] <command> [subcommand/options]
| `hermes profile` | Manage profiles — multiple isolated Hermes instances. |
| `hermes completion` | Print shell completion scripts (bash/zsh/fish). |
| `hermes version` | Show version information. |
| `hermes update` | Pull latest code and reinstall dependencies. `--check` prints commit diff without pulling; `--backup` takes a pre-pull `HERMES_HOME` snapshot. |
| `hermes update` | Pull latest code and reinstall dependencies (git installs), or check PyPI and `pip install --upgrade` (pip installs). `--check` previews without installing; `--backup` takes a pre-pull `HERMES_HOME` snapshot. |
| `hermes uninstall` | Remove Hermes from the system. |
## `hermes chat`
@@ -1188,6 +1188,8 @@ hermes update [--check] [--backup] [--restart-gateway]
Pulls the latest `hermes-agent` code and reinstalls dependencies in your venv, then re-runs the post-install hooks (MCP servers, skills sync, completion install). Safe to run on a live install.
**pip installs:** `hermes update` detects pip-based installations automatically — it queries PyPI for the latest release and runs `pip install --upgrade hermes-agent` instead of `git pull`. PyPI releases track tagged versions (major/minor releases), not every commit on `main`. Use `--check` to see if a newer PyPI release is available without installing.
| Option | Description |
|--------|-------------|
| `--check` | Print the current commit and the latest `origin/main` commit side by side, and exit 0 if in sync or 1 if behind. Does not pull, install, or restart anything. |