mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
* feat(security): supply-chain advisory checker + lazy-install framework + tiered install fallback
Three coordinated mitigations for the Mini Shai-Hulud worm hitting
mistralai 2.4.6 on PyPI (2026-05-12) and for the next single-package
compromise that follows.
# What this PR makes true
1. Users with the poisoned mistralai 2.4.6 in their venv get a loud
detection banner with copy-pasteable remediation steps the moment
they run hermes (and on every gateway startup).
2. One quarantined / yanked PyPI package can no longer silently demote
a fresh install to 'core only' — the installer keeps every other
extra and tells the user which tier landed.
3. Future opt-in backends (Mistral, ElevenLabs, Honcho, etc.) can
lazy-install on first use under a strict allowlist, instead of
eagerly pulling everything at install time.
# Detection: hermes_cli/security_advisories.py
- ADVISORIES catalog (one entry currently: shai-hulud-2026-05 for
mistralai==2.4.6). Adding the next one is a single dataclass.
- detect_compromised() uses importlib.metadata.version() — no pip
dependency, works in uv venvs that lack pip.
- Banner cache (~/.hermes/cache/advisory_banner_seen) rate-limits
the startup banner to once per 24h per advisory.
- Acks persisted to security.acked_advisories in config.yaml; never
re-banner after ack.
- Wired into:
* hermes doctor — runs first, prints full remediation block
* hermes doctor --ack <id> — dismisses an advisory
* cli.py interactive run() and single-query branches — short
stderr banner pointing at hermes doctor
* gateway/run.py startup — operator-visible warning in gateway.log
# Lazy-install framework: tools/lazy_deps.py
- LAZY_DEPS allowlist maps namespaced feature keys (tts.elevenlabs,
memory.honcho, provider.bedrock, etc.) to pip specs.
- ensure(feature) installs missing deps in the active venv via the
uv → pip → ensurepip ladder (matches tools_config._pip_install).
- Strict spec safety regex rejects URLs, file paths, shell metas,
pip flag injection, control chars — only PyPI-by-name accepted.
- Gated on security.allow_lazy_installs (default true) plus the
HERMES_DISABLE_LAZY_INSTALLS env var for restricted/audited envs.
- Migrated three backends as proof of pattern:
* tools/tts_tool.py — _import_elevenlabs() calls ensure first
* plugins/memory/honcho/client.py — get_honcho_client lazy-installs
* tts.mistral / stt.mistral entries pre-registered for when PyPI
restores mistralai
# Installer fallback tiers
scripts/install.sh, scripts/install.ps1, setup-hermes.sh:
- Centralised _BROKEN_EXTRAS list (currently: mistral). Edit one
array when a transitive breaks; users keep every other extra.
- New 'all minus known-broken' tier between [all] and the existing
PyPI-only-extras tier. Only kicks in when [all] fails resolve.
- All three tiers explicit: every fallback announces which tier
landed and prints a re-run hint when not on Tier 1.
- install.ps1 and install.sh both regenerate their tier specs from
the same _BROKEN_EXTRAS array so updates stay in sync.
Side effect: install.ps1 Tier 2 spec previously hardcoded 'mistral'
in its extra list — bug fixed by the refactor (mistral is filtered
out).
# Config
hermes_cli/config.py — DEFAULT_CONFIG.security gains:
- acked_advisories: [] (advisory IDs the user has dismissed)
- allow_lazy_installs: True (security gate for ensure())
No config version bump needed — both keys nest under existing
security: block, and load_config's deep-merge picks up DEFAULT_CONFIG
defaults for users with older configs.
# Tests
tests/hermes_cli/test_security_advisories.py — 23 tests covering:
- detect_compromised matches/non-matches, wildcard frozenset
- ack persistence, idempotence, blank rejection, config-failure path
- banner cache rate limiting + 24h re-banner + ack-stops-banner
- short_banner_lines / full_remediation_text / render_doctor_section /
gateway_log_message
- shipped catalog well-formedness invariant
tests/tools/test_lazy_deps.py — 40 tests covering:
- spec safety: 11 safe parametrized + 18 unsafe parametrized
- allowlist: unknown-feature rejection, namespace.name shape,
every shipped spec passes the safety regex
- security gating: config flag, env var, default, fail-open
- ensure() happy/sad paths: already-satisfied, install success,
pip stderr surfaced on failure, install-succeeds-but-still-missing
- is_available, feature_install_command
Combined: 63 new tests, all passing under scripts/run_tests.sh.
# Validation
- scripts/run_tests.sh tests/hermes_cli/test_security_advisories.py
tests/tools/test_lazy_deps.py → 63/63 passing
- scripts/run_tests.sh tests/hermes_cli/test_doctor.py
tests/hermes_cli/test_doctor_command_install.py
tests/tools/test_tts_mistral.py tests/tools/test_transcription_tools.py
tests/tools/test_transcription_dotenv_fallback.py → 165/165 passing
- scripts/run_tests.sh tests/hermes_cli/ tests/tools/ →
9191 passed, 8 pre-existing failures (verified on origin/main
before this change)
- bash -n on install.sh and setup-hermes.sh → OK
- py_compile on all modified .py files → OK
- End-to-end smoke test of detect_compromised + render_doctor_section
+ gateway_log_message with mocked installed version → produces
copy-pasteable remediation output
# Community
Full advisory + remediation steps:
website/docs/community/security-advisories/shai-hulud-mistralai-2026-05.md
Short-form post drafts (Discord, GitHub pinned issue, README banner):
scripts/community-announcement-shai-hulud.md
Refs: PR #24205 (mistral disabled), Socket Security advisory
<https://socket.dev/blog/mini-shai-hulud-worm-pypi>
* build(deps): pin every direct dep to ==X.Y.Z (no ranges)
Companion to the supply-chain advisory work: replace every >=/</~= range
in pyproject.toml's [project.dependencies] and [project.optional-dependencies]
with an exact ==X.Y.Z pin sourced from uv.lock.
Why: ranges allow PyPI to ship a fresh version of any direct dep at any
time without a code review on our side. With ranges, the malicious
mistralai 2.4.6 release would have been pulled by every fresh
'pip install -e .[all]' for the hours between upload and PyPI's
quarantine — exactly the install window we got hit on. Exact pins close
that window: the only way a new package version reaches a user is via
an intentional update on our end.
What the user-facing change is: nothing, behavior-wise. Every package
resolves to the same version it was already resolving to via uv.lock —
the pins just remove the resolver's freedom to pick a different one.
Cost: any user installing Hermes alongside another package that requires
a newer pin gets a resolver conflict. Acceptable for our isolated-venv
install path; documented in the new comment block.
Build-system requires line (setuptools>=61.0) is intentionally left
as a range — pinning the build backend would block fresh pip from
bootstrapping the build on architectures where that exact wheel isn't
available.
mistral extra (mistralai==2.3.0) is pinned but stays out of [all]
(per PR #24205). 'uv lock' regeneration will fail until PyPI restores
mistralai; lockfile regeneration is gated behind that, NOT on every PR.
LAZY_DEPS in tools/lazy_deps.py also moved to exact pins so the lazy-
install pathway can never resolve a different version than the one
declared in pyproject.toml.
Validation:
- Cross-checked all 77 pinned direct deps in pyproject.toml against
uv.lock — every pin matches the resolved version exactly.
- Cross-checked all LAZY_DEPS specs against uv.lock — same.
- 'uv pip install -e .[all] --dry-run' resolves 205 packages cleanly.
- tests/tools/test_lazy_deps.py + tests/hermes_cli/test_security_advisories.py
→ 63/63 passing (every shipped spec passes the safety regex).
- Doctor + TTS + transcription targeted suite → 146/146 passing.
* build(deps): hash-verify transitives via uv.lock; remove unresolvable [mistral] extra
You asked: 'what about the dependencies the dependencies rely on?' —
correctly noting that exact-pinning direct deps in pyproject.toml does
NOT cover the transitive graph. `pip install` and `uv pip install` both
re-resolve transitives fresh from PyPI at install time, so a compromised
transitive (e.g. `httpcore` if it got worm-poisoned tomorrow) would
still hit our users even with every direct dep exact-pinned.
# What this commit fixes
1. **Both real installer scripts now prefer `uv sync --locked` as Tier 0.**
uv.lock records SHA256 hashes for every transitive — a compromised
package with a different hash gets REJECTED. Falls through to the
existing `uv pip install` cascade if the lockfile is missing or
stale, with a loud warning that the fallback path does NOT
hash-verify transitives. Previously only `setup-hermes.sh` (the dev
path) used the lockfile; `scripts/install.sh` and `scripts/install.ps1`
(the paths fresh users actually run) skipped it.
2. **Removed the `[mistral]` extra entirely.** The `mistralai` PyPI
project is fully quarantined right now — every version returns 404,
so any pin we wrote was unresolvable, which broke `uv lock --check`
in CI. Restoration is documented in pyproject.toml as a 5-step
checklist (verify, re-add extra, re-enable in 4 modules, regenerate
lock, optionally re-add to [all]).
3. **Regenerated uv.lock.** 262 packages, mistralai/eval-type-backport/
jsonpath-python pruned. `uv lock --check` now passes.
# Defense-in-depth view
| Layer | Where | Protects against |
|----------------------------|-------------------|-------------------------------------------|
| Exact pins in pyproject | direct deps | new mistralai 2.4.6-style direct compromise |
| uv.lock + `--locked` install | transitive graph | transitive worm injection |
| Tier-0 hash-verified path | install.sh / .ps1 | actually USE the lockfile in fresh installs |
| `uv lock --check` CI gate | every PR | drift between pyproject and lockfile |
| `hermes_cli/security_advisories.py` | runtime | cleanup for users who already got hit |
The exact pinning + hash verification together close the supply-chain
gap. Without the lockfile path, exact pins alone are theater.
# Validation
- `uv lock --check` → passes (262 packages resolved, no drift).
- `bash -n` on install.sh + setup-hermes.sh → OK.
- 209/209 tests passing across new + adjacent test files
(test_lazy_deps.py, test_security_advisories.py, test_doctor.py,
test_tts_mistral.py, test_transcription_tools.py).
- TOML parse OK.
* chore: remove community announcement drafts (PR body covers it)
* build(deps): lazy-install every opt-in backend (anthropic, search, terminal, platforms, dashboard)
Extends the lazy-install framework to cover everything that's not used by
every hermes session. Base install drops from ~60 packages to 45.
Moved out of core dependencies = []:
- anthropic (only when provider=anthropic native, not via aggregators)
- exa-py, firecrawl-py, parallel-web (search backends; only when picked)
- fal-client (image gen; only when picked)
- edge-tts (default TTS but still optional)
New extras in pyproject.toml: [anthropic] [exa] [firecrawl] [parallel-web]
[fal] [edge-tts]. All added to [all].
New LAZY_DEPS entries: provider.anthropic, search.{exa,firecrawl,parallel},
tts.edge, image.fal, memory.hindsight, platform.{telegram,discord,matrix},
terminal.{modal,daytona,vercel}, tool.dashboard.
Each import site now calls ensure() before importing the SDK. Where the
module had a top-level try/except (telegram, discord, fastapi), the
graceful-fallback pattern was extended to lazy-install on first
check_*_requirements() call and re-bind module globals.
Updated test_windows_native_support.py tzdata check from snapshot
(>=2023.3 literal) to invariant (any version + win32 marker).
Validation:
- Base install: 45 packages (was ~60); 6 newly-extracted packages absent
- uv lock --check: passes (262 packages, no drift)
- 209/209 lazy_deps + advisory + doctor + tts/transcription tests passing
- py_compile clean on all 12 modified modules
443 lines
16 KiB
Bash
Executable file
443 lines
16 KiB
Bash
Executable file
#!/bin/bash
|
|
# ============================================================================
|
|
# Hermes Agent Setup Script
|
|
# ============================================================================
|
|
# Quick setup for developers who cloned the repo manually.
|
|
# Uses uv for desktop/server setup and Python's stdlib venv + pip on Termux.
|
|
#
|
|
# Usage:
|
|
# ./setup-hermes.sh
|
|
#
|
|
# This script:
|
|
# 1. Detects desktop/server vs Android/Termux setup path
|
|
# 2. Creates a Python 3.11 virtual environment
|
|
# 3. Installs the appropriate dependency set for the platform
|
|
# 4. Creates .env from template (if not exists)
|
|
# 5. Symlinks the 'hermes' CLI command into a user-facing bin dir
|
|
# 6. Runs the setup wizard (optional)
|
|
# ============================================================================
|
|
|
|
set -e
|
|
|
|
# Colors
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[0;33m'
|
|
CYAN='\033[0;36m'
|
|
RED='\033[0;31m'
|
|
NC='\033[0m'
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
cd "$SCRIPT_DIR"
|
|
|
|
# Prevent uv from discovering config files (uv.toml, pyproject.toml) from the
|
|
# wrong user's home directory when running under sudo -u <user>. See #21269.
|
|
export UV_NO_CONFIG=1
|
|
|
|
PYTHON_VERSION="3.11"
|
|
|
|
is_termux() {
|
|
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
|
|
}
|
|
|
|
get_command_link_dir() {
|
|
if is_termux && [ -n "${PREFIX:-}" ]; then
|
|
echo "$PREFIX/bin"
|
|
else
|
|
echo "$HOME/.local/bin"
|
|
fi
|
|
}
|
|
|
|
get_command_link_display_dir() {
|
|
if is_termux && [ -n "${PREFIX:-}" ]; then
|
|
echo '$PREFIX/bin'
|
|
else
|
|
echo '~/.local/bin'
|
|
fi
|
|
}
|
|
|
|
echo ""
|
|
echo -e "${CYAN}⚕ Hermes Agent Setup${NC}"
|
|
echo ""
|
|
|
|
# ============================================================================
|
|
# Install / locate uv
|
|
# ============================================================================
|
|
|
|
echo -e "${CYAN}→${NC} Checking for uv..."
|
|
|
|
UV_CMD=""
|
|
if is_termux; then
|
|
echo -e "${CYAN}→${NC} Termux detected — using Python's stdlib venv + pip instead of uv"
|
|
else
|
|
if command -v uv &> /dev/null; then
|
|
UV_CMD="uv"
|
|
elif [ -x "$HOME/.local/bin/uv" ]; then
|
|
UV_CMD="$HOME/.local/bin/uv"
|
|
elif [ -x "$HOME/.cargo/bin/uv" ]; then
|
|
UV_CMD="$HOME/.cargo/bin/uv"
|
|
fi
|
|
|
|
if [ -n "$UV_CMD" ]; then
|
|
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
|
echo -e "${GREEN}✓${NC} uv found ($UV_VERSION)"
|
|
else
|
|
echo -e "${CYAN}→${NC} Installing uv..."
|
|
if curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null; then
|
|
if [ -x "$HOME/.local/bin/uv" ]; then
|
|
UV_CMD="$HOME/.local/bin/uv"
|
|
elif [ -x "$HOME/.cargo/bin/uv" ]; then
|
|
UV_CMD="$HOME/.cargo/bin/uv"
|
|
fi
|
|
|
|
if [ -n "$UV_CMD" ]; then
|
|
UV_VERSION=$($UV_CMD --version 2>/dev/null)
|
|
echo -e "${GREEN}✓${NC} uv installed ($UV_VERSION)"
|
|
else
|
|
echo -e "${RED}✗${NC} uv installed but not found. Add ~/.local/bin to PATH and retry."
|
|
exit 1
|
|
fi
|
|
else
|
|
echo -e "${RED}✗${NC} Failed to install uv. Visit https://docs.astral.sh/uv/"
|
|
exit 1
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ============================================================================
|
|
# Python check (uv can provision it automatically)
|
|
# ============================================================================
|
|
|
|
echo -e "${CYAN}→${NC} Checking Python $PYTHON_VERSION..."
|
|
|
|
if is_termux; then
|
|
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)
|
|
echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION found"
|
|
else
|
|
echo -e "${RED}✗${NC} Termux Python must be 3.11+"
|
|
echo " Run: pkg install python"
|
|
exit 1
|
|
fi
|
|
else
|
|
echo -e "${RED}✗${NC} Python not found in Termux"
|
|
echo " Run: pkg install python"
|
|
exit 1
|
|
fi
|
|
else
|
|
if $UV_CMD python find "$PYTHON_VERSION" &> /dev/null; then
|
|
PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION")
|
|
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
|
echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION found"
|
|
else
|
|
echo -e "${CYAN}→${NC} Python $PYTHON_VERSION not found, installing via uv..."
|
|
$UV_CMD python install "$PYTHON_VERSION"
|
|
PYTHON_PATH=$($UV_CMD python find "$PYTHON_VERSION")
|
|
PYTHON_FOUND_VERSION=$($PYTHON_PATH --version 2>/dev/null)
|
|
echo -e "${GREEN}✓${NC} $PYTHON_FOUND_VERSION installed"
|
|
fi
|
|
fi
|
|
|
|
# ============================================================================
|
|
# Virtual environment
|
|
# ============================================================================
|
|
|
|
echo -e "${CYAN}→${NC} Setting up virtual environment..."
|
|
|
|
if [ -d "venv" ]; then
|
|
echo -e "${CYAN}→${NC} Removing old venv..."
|
|
rm -rf venv
|
|
fi
|
|
|
|
if is_termux; then
|
|
"$PYTHON_PATH" -m venv venv
|
|
echo -e "${GREEN}✓${NC} venv created with stdlib venv"
|
|
else
|
|
$UV_CMD venv venv --python "$PYTHON_VERSION"
|
|
echo -e "${GREEN}✓${NC} venv created (Python $PYTHON_VERSION)"
|
|
fi
|
|
|
|
export VIRTUAL_ENV="$SCRIPT_DIR/venv"
|
|
SETUP_PYTHON="$SCRIPT_DIR/venv/bin/python"
|
|
|
|
# ============================================================================
|
|
# Dependencies
|
|
# ============================================================================
|
|
|
|
echo -e "${CYAN}→${NC} Installing dependencies..."
|
|
|
|
if is_termux; then
|
|
export ANDROID_API_LEVEL="$(getprop ro.build.version.sdk 2>/dev/null || printf '%s' "${ANDROID_API_LEVEL:-}")"
|
|
echo -e "${CYAN}→${NC} Termux detected — installing the tested Android bundle"
|
|
"$SETUP_PYTHON" -m pip install --upgrade pip setuptools wheel
|
|
if [ -f "constraints-termux.txt" ]; then
|
|
"$SETUP_PYTHON" -m pip install -e ".[termux]" -c constraints-termux.txt || {
|
|
echo -e "${YELLOW}⚠${NC} Termux bundle install failed, falling back to base install..."
|
|
"$SETUP_PYTHON" -m pip install -e "." -c constraints-termux.txt
|
|
}
|
|
else
|
|
"$SETUP_PYTHON" -m pip install -e ".[termux]" || "$SETUP_PYTHON" -m pip install -e "."
|
|
fi
|
|
echo -e "${GREEN}✓${NC} Dependencies installed"
|
|
else
|
|
# Prefer uv sync with lockfile (hash-verified installs) when available,
|
|
# fall back to pip install for compatibility or when lockfile is stale.
|
|
#
|
|
# Multi-tier pip fallback. Goal: ONE compromised PyPI package
|
|
# (mistralai 2.4.6 in May 2026 → quarantined) shouldn't silently demote
|
|
# a fresh setup to "core only". Edit _BROKEN_EXTRAS when a transitive
|
|
# breaks; users keep voice / honcho / google / slack / matrix etc. even
|
|
# if mistral can't resolve.
|
|
_BROKEN_EXTRAS=() # populate when an extra becomes unresolvable
|
|
_ALL_EXTRAS=(
|
|
modal daytona vercel messaging matrix cron cli dev tts-premium slack
|
|
pty honcho mcp homeassistant sms acp voice dingtalk feishu google
|
|
bedrock web youtube
|
|
)
|
|
_SAFE_EXTRAS=()
|
|
for _e in "${_ALL_EXTRAS[@]}"; do
|
|
_skip=false
|
|
for _b in "${_BROKEN_EXTRAS[@]}"; do
|
|
[ "$_e" = "$_b" ] && _skip=true && break
|
|
done
|
|
[ "$_skip" = false ] && _SAFE_EXTRAS+=("$_e")
|
|
done
|
|
_SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]"
|
|
_try_install() {
|
|
$UV_CMD pip install -e ".[all]" \
|
|
|| $UV_CMD pip install -e "$_SAFE_SPEC" \
|
|
|| $UV_CMD pip install -e "."
|
|
}
|
|
|
|
if [ -f "uv.lock" ]; then
|
|
# Hash-verified install (preferred). The lockfile records SHA256
|
|
# hashes for every transitive — a compromised transitive would have
|
|
# a different hash and be REJECTED by uv. This is the only path
|
|
# that protects against transitive-package supply-chain attacks
|
|
# (the direct deps in pyproject.toml are exact-pinned, but
|
|
# `uv pip install` re-resolves transitives fresh from PyPI).
|
|
echo -e "${CYAN}→${NC} Using uv.lock for hash-verified installation..."
|
|
_UV_SYNC_LOG=$(mktemp)
|
|
if UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --all-extras --locked 2>"$_UV_SYNC_LOG"; then
|
|
echo -e "${GREEN}✓${NC} Dependencies installed (hash-verified via uv.lock)"
|
|
rm -f "$_UV_SYNC_LOG"
|
|
else
|
|
echo -e "${YELLOW}⚠${NC} Lockfile sync failed (lockfile may be stale)."
|
|
echo -e "${YELLOW}⚠${NC} Falling back to PyPI resolve — transitives will NOT be hash-verified."
|
|
head -5 "$_UV_SYNC_LOG" | sed 's/^/ /'
|
|
rm -f "$_UV_SYNC_LOG"
|
|
_try_install
|
|
echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)"
|
|
fi
|
|
else
|
|
echo -e "${YELLOW}⚠${NC} uv.lock not found — installing without hash verification of transitives."
|
|
_try_install
|
|
echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)"
|
|
fi
|
|
fi
|
|
|
|
# ============================================================================
|
|
# Submodules (terminal backend + RL training)
|
|
# ============================================================================
|
|
|
|
echo -e "${CYAN}→${NC} Installing optional submodules..."
|
|
|
|
# tinker-atropos (RL training backend)
|
|
if is_termux; then
|
|
echo -e "${CYAN}→${NC} Skipping tinker-atropos on Termux (not part of the tested Android path)"
|
|
elif [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then
|
|
$UV_CMD pip install -e "./tinker-atropos" && \
|
|
echo -e "${GREEN}✓${NC} tinker-atropos installed" || \
|
|
echo -e "${YELLOW}⚠${NC} tinker-atropos install failed (RL tools may not work)"
|
|
else
|
|
echo -e "${YELLOW}⚠${NC} tinker-atropos not found (run: git submodule update --init --recursive)"
|
|
fi
|
|
|
|
# ============================================================================
|
|
# Optional: ripgrep (for faster file search)
|
|
# ============================================================================
|
|
|
|
echo -e "${CYAN}→${NC} Checking ripgrep (optional, for faster search)..."
|
|
|
|
if command -v rg &> /dev/null; then
|
|
echo -e "${GREEN}✓${NC} ripgrep found"
|
|
else
|
|
echo -e "${YELLOW}⚠${NC} ripgrep not found (file search will use grep fallback)"
|
|
read -p "Install ripgrep for faster search? [Y/n] " -n 1 -r
|
|
echo
|
|
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
|
|
INSTALLED=false
|
|
|
|
if is_termux; then
|
|
pkg install -y ripgrep && INSTALLED=true
|
|
else
|
|
# Check if sudo is available
|
|
if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then
|
|
if command -v apt &> /dev/null; then
|
|
sudo apt install -y ripgrep && INSTALLED=true
|
|
elif command -v dnf &> /dev/null; then
|
|
sudo dnf install -y ripgrep && INSTALLED=true
|
|
fi
|
|
fi
|
|
|
|
# Try brew (no sudo needed)
|
|
if [ "$INSTALLED" = false ] && command -v brew &> /dev/null; then
|
|
brew install ripgrep && INSTALLED=true
|
|
fi
|
|
|
|
# Try cargo (no sudo needed)
|
|
if [ "$INSTALLED" = false ] && command -v cargo &> /dev/null; then
|
|
echo -e "${CYAN}→${NC} Trying cargo install (no sudo required)..."
|
|
cargo install ripgrep && INSTALLED=true
|
|
fi
|
|
fi
|
|
|
|
if [ "$INSTALLED" = true ]; then
|
|
echo -e "${GREEN}✓${NC} ripgrep installed"
|
|
else
|
|
echo -e "${YELLOW}⚠${NC} Auto-install failed. Install options:"
|
|
if is_termux; then
|
|
echo " pkg install ripgrep # Termux / Android"
|
|
else
|
|
echo " sudo apt install ripgrep # Debian/Ubuntu"
|
|
echo " brew install ripgrep # macOS"
|
|
echo " cargo install ripgrep # With Rust (no sudo)"
|
|
fi
|
|
echo " https://github.com/BurntSushi/ripgrep#installation"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ============================================================================
|
|
# Environment file
|
|
# ============================================================================
|
|
|
|
if [ ! -f ".env" ]; then
|
|
if [ -f ".env.example" ]; then
|
|
cp .env.example .env
|
|
echo -e "${GREEN}✓${NC} Created .env from template"
|
|
fi
|
|
else
|
|
echo -e "${GREEN}✓${NC} .env exists"
|
|
fi
|
|
|
|
# ============================================================================
|
|
# PATH setup — symlink hermes into a user-facing bin dir
|
|
# ============================================================================
|
|
|
|
echo -e "${CYAN}→${NC} Setting up hermes command..."
|
|
|
|
HERMES_BIN="$SCRIPT_DIR/venv/bin/hermes"
|
|
COMMAND_LINK_DIR="$(get_command_link_dir)"
|
|
COMMAND_LINK_DISPLAY_DIR="$(get_command_link_display_dir)"
|
|
mkdir -p "$COMMAND_LINK_DIR"
|
|
ln -sf "$HERMES_BIN" "$COMMAND_LINK_DIR/hermes"
|
|
echo -e "${GREEN}✓${NC} Symlinked hermes → $COMMAND_LINK_DISPLAY_DIR/hermes"
|
|
|
|
if is_termux; then
|
|
export PATH="$COMMAND_LINK_DIR:$PATH"
|
|
echo -e "${GREEN}✓${NC} $COMMAND_LINK_DISPLAY_DIR is already on PATH in Termux"
|
|
else
|
|
# Determine the appropriate shell config file
|
|
SHELL_CONFIG=""
|
|
if [[ "$SHELL" == *"zsh"* ]]; then
|
|
SHELL_CONFIG="$HOME/.zshrc"
|
|
elif [[ "$SHELL" == *"bash"* ]]; then
|
|
SHELL_CONFIG="$HOME/.bashrc"
|
|
[ ! -f "$SHELL_CONFIG" ] && SHELL_CONFIG="$HOME/.bash_profile"
|
|
else
|
|
# Fallback to checking existing files
|
|
if [ -f "$HOME/.zshrc" ]; then
|
|
SHELL_CONFIG="$HOME/.zshrc"
|
|
elif [ -f "$HOME/.bashrc" ]; then
|
|
SHELL_CONFIG="$HOME/.bashrc"
|
|
elif [ -f "$HOME/.bash_profile" ]; then
|
|
SHELL_CONFIG="$HOME/.bash_profile"
|
|
fi
|
|
fi
|
|
|
|
if [ -n "$SHELL_CONFIG" ]; then
|
|
# Touch the file just in case it doesn't exist yet but was selected
|
|
touch "$SHELL_CONFIG" 2>/dev/null || true
|
|
|
|
if ! echo "$PATH" | tr ':' '\n' | grep -q "^$HOME/.local/bin$"; then
|
|
if ! grep -q '\.local/bin' "$SHELL_CONFIG" 2>/dev/null; then
|
|
echo "" >> "$SHELL_CONFIG"
|
|
echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$SHELL_CONFIG"
|
|
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$SHELL_CONFIG"
|
|
echo -e "${GREEN}✓${NC} Added ~/.local/bin to PATH in $SHELL_CONFIG"
|
|
else
|
|
echo -e "${GREEN}✓${NC} ~/.local/bin already in $SHELL_CONFIG"
|
|
fi
|
|
else
|
|
echo -e "${GREEN}✓${NC} ~/.local/bin already on PATH"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ============================================================================
|
|
# Seed bundled skills into ~/.hermes/skills/
|
|
# ============================================================================
|
|
|
|
HERMES_SKILLS_DIR="${HERMES_HOME:-$HOME/.hermes}/skills"
|
|
mkdir -p "$HERMES_SKILLS_DIR"
|
|
|
|
echo ""
|
|
echo "Syncing bundled skills to ~/.hermes/skills/ ..."
|
|
if "$SCRIPT_DIR/venv/bin/python" "$SCRIPT_DIR/tools/skills_sync.py" 2>/dev/null; then
|
|
echo -e "${GREEN}✓${NC} Skills synced"
|
|
else
|
|
# Fallback: copy if sync script fails (missing deps, etc.)
|
|
if [ -d "$SCRIPT_DIR/skills" ]; then
|
|
cp -rn "$SCRIPT_DIR/skills/"* "$HERMES_SKILLS_DIR/" 2>/dev/null || true
|
|
echo -e "${GREEN}✓${NC} Skills copied"
|
|
fi
|
|
fi
|
|
|
|
# ============================================================================
|
|
# Done
|
|
# ============================================================================
|
|
|
|
echo ""
|
|
echo -e "${GREEN}✓ Setup complete!${NC}"
|
|
echo ""
|
|
echo "Next steps:"
|
|
echo ""
|
|
if is_termux; then
|
|
echo " 1. Run the setup wizard to configure API keys:"
|
|
echo " hermes setup"
|
|
echo ""
|
|
echo " 2. Start chatting:"
|
|
echo " hermes"
|
|
echo ""
|
|
else
|
|
echo " 1. Reload your shell:"
|
|
echo " source $SHELL_CONFIG"
|
|
echo ""
|
|
echo " 2. Run the setup wizard to configure API keys:"
|
|
echo " hermes setup"
|
|
echo ""
|
|
echo " 3. Start chatting:"
|
|
echo " hermes"
|
|
echo ""
|
|
fi
|
|
echo "Other commands:"
|
|
echo " hermes status # Check configuration"
|
|
if is_termux; then
|
|
echo " hermes gateway # Run gateway in foreground"
|
|
else
|
|
echo " hermes gateway install # Install gateway service (messaging + cron)"
|
|
fi
|
|
echo " hermes cron list # View scheduled jobs"
|
|
echo " hermes doctor # Diagnose issues"
|
|
echo ""
|
|
|
|
# Ask if they want to run setup wizard now
|
|
read -p "Would you like to run the setup wizard now? [Y/n] " -n 1 -r
|
|
echo
|
|
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
|
|
echo ""
|
|
# Run directly with venv Python (no activation needed)
|
|
"$SCRIPT_DIR/venv/bin/python" -m hermes_cli.main setup
|
|
fi
|