mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 04:38:43 +08:00
Compare commits
19 Commits
feat/plugi
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
349f054760 | ||
|
|
41a3cedfac | ||
|
|
a653d6c3d4 | ||
|
|
cef942674b | ||
|
|
f7d5f7ee29 | ||
|
|
c9a63d80ee | ||
|
|
5b61bafebf | ||
|
|
9913545f77 | ||
|
|
57eaa63769 | ||
|
|
446e8f4c65 | ||
|
|
ccc0bb8a32 | ||
|
|
ec8ecca978 | ||
|
|
29c98268f1 | ||
|
|
5d98bb47de | ||
|
|
977b2dd2b4 | ||
|
|
a0824df421 | ||
|
|
4cd40dc512 | ||
|
|
220a8c0be8 | ||
|
|
940526dfa4 |
26
.github/workflows/upload_to_pypi.yml
vendored
26
.github/workflows/upload_to_pypi.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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/*
|
||||
@@ -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}))
|
||||
|
||||
@@ -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
106
hermes_cli/dep_ensure.py
Normal 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
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
35
tests/hermes_cli/test_banner_pip_update.py
Normal file
35
tests/hermes_cli/test_banner_pip_update.py
Normal 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")
|
||||
43
tests/hermes_cli/test_dep_ensure.py
Normal file
43
tests/hermes_cli/test_dep_ensure.py
Normal 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"
|
||||
31
tests/hermes_cli/test_gateway_service_paths.py
Normal file
31
tests/hermes_cli/test_gateway_service_paths.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
37
tests/hermes_cli/test_pip_install_detection.py
Normal file
37
tests/hermes_cli/test_pip_install_detection.py
Normal 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
|
||||
21
tests/hermes_cli/test_tui_bundled.py
Normal file
21
tests/hermes_cli/test_tui_bundled.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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: "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user