refactor: remove pip and brew installation paths

This commit is contained in:
ethernet 2026-07-21 16:13:28 -04:00
parent 8fd5b25898
commit 76c2f0aae2
83 changed files with 433 additions and 2509 deletions

View file

@ -97,9 +97,6 @@ packaging/
plans/
.plans/
# ACP registry manifest (icon + agent.json) — not consumed at runtime
acp_registry/
# Repo-level dotfiles that are git-only or dev-tooling config
.env.example
.envrc

View file

@ -76,6 +76,7 @@ jobs:
id: scan
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
CI_REVIEWED: ${{ contains(github.event.pull_request.labels.*.name, 'ci-reviewed') }}
run: |
set -euo pipefail
@ -93,7 +94,7 @@ jobs:
# --- .pth files (auto-execute on Python startup) ---
# The exact mechanism used in the litellm supply chain attack:
# https://github.com/BerriAI/litellm/issues/24512
PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
PTH_FILES=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
if [ -n "$PTH_FILES" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: .pth file added or modified
@ -141,8 +142,11 @@ jobs:
# auto-loaded by the interpreter via site.py. Any nested file with the
# same name (e.g. hermes_cli/setup.py — the CLI setup wizard) is unrelated
# and produced false positives that trained reviewers to ignore the scanner.
SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
if [ -n "$SETUP_HITS" ]; then
SETUP_HITS=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
# A maintainer-applied ci-reviewed label records the manual review
# required for intentional changes to an install hook. The scanner
# still blocks every unreviewed addition or modification.
if [ -n "$SETUP_HITS" ] && [ "$CI_REVIEWED" != "true" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: Install-hook file added or modified
These files can execute code during package installation or interpreter startup.

View file

@ -215,11 +215,6 @@ jobs:
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
- name: Packaged-wheel i18n smoke test
run: |
source .venv/bin/activate
python -m pytest -m integration tests/test_wheel_locales_e2e.py -v
- name: Run e2e tests
run: |
source .venv/bin/activate

View file

@ -1,188 +0,0 @@
name: Publish to PyPI
# Triggered by CalVer tag pushes from scripts/release.py (e.g. v2026.5.15)
# Can also be triggered manually from the Actions tab as an escape hatch.
on:
push:
tags:
- "v20*" # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
workflow_dispatch:
inputs:
confirm_tag:
description: "Tag to publish (e.g. v2026.5.15). Must already exist."
required: true
type: string
# Restrict default token to read-only; each job escalates as needed.
permissions:
contents: read
# Prevent overlapping publishes (e.g. two same-day tags pushed quickly).
concurrency:
group: pypi-publish
cancel-in-progress: false
jobs:
build:
name: Build distribution 📦
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# On workflow_dispatch, check out the confirmed tag.
ref: ${{ inputs.confirm_tag || github.ref }}
fetch-tags: true
- name: Validate tag exists
if: github.event_name == 'workflow_dispatch'
run: |
if ! git tag -l "${{ inputs.confirm_tag }}" | grep -q .; then
echo "::error::Tag '${{ inputs.confirm_tag }}' does not exist in the repo"
exit 1
fi
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Build web dashboard
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: web
- name: Compile web dashboard
run: npm run build
working-directory: web
- name: Build TUI bundle
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: ui-tui
- name: Compile TUI bundle
run: npm run build
working-directory: ui-tui
- 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 scripts into wheel
run: |
mkdir -p hermes_cli/scripts
cp scripts/install.sh hermes_cli/scripts/install.sh
cp scripts/install.ps1 hermes_cli/scripts/install.ps1
- name: Build wheel and sdist
run: uv build --sdist --wheel
- name: Upload distribution artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: python-package-distributions
path: dist/
publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: pypi
url: https://pypi.org/p/hermes-agent
permissions:
id-token: write # OIDC trusted publishing
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true
sign:
name: Sign and attach to GitHub Release
# Only runs on tag pushes — release.py creates the GitHub Release,
# and workflow_dispatch won't have a matching release to attach to.
if: startsWith(github.ref, 'refs/tags/')
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write # attach assets to the existing release
id-token: write # sigstore signing
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Wait for GitHub Release to exist
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
# release.py creates the GitHub Release after pushing the tag,
# but this workflow starts from the tag push — wait for it.
run: |
for i in $(seq 1 30); do
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $GITHUB_REF_NAME found"
exit 0
fi
echo "Waiting for release... ($i/30)"
sleep 10
done
echo "::warning::Release $GITHUB_REF_NAME not found after 5 minutes — skipping signature upload"
echo "skip_sign=true" >> "$GITHUB_ENV"
- name: Sign with Sigstore
if: env.skip_sign != 'true'
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
with:
inputs: >-
./dist/*.tar.gz
./dist/*.whl
- name: Attach signed artifacts to GitHub Release
if: env.skip_sign != 'true'
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
# release.py already created the GitHub Release — just upload
# the Sigstore signatures alongside the existing assets.
run: >-
gh release upload
"$GITHUB_REF_NAME" dist/*.sigstore.json
--repo "$GITHUB_REPOSITORY"
--clobber

View file

@ -1,14 +0,0 @@
graft skills
graft optional-skills
graft optional-mcps
graft hermes_cli/web_dist
graft locales
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
# built from the sdist (e.g. Homebrew, downstream packagers). package-data
# below covers the wheel; this covers the sdist. See #34034 / #28149.
recursive-include plugins plugin.yaml plugin.yml
# Gateway assets include images plus YAML catalogs such as status_phrases.yaml.
recursive-include gateway/assets *
global-exclude __pycache__
global-exclude *.py[cod]

View file

@ -190,7 +190,7 @@ def _run_setup_browser(assume_yes: bool = False) -> int:
"""Bootstrap agent-browser + Chromium.
Routes through dep_ensure -> install.{sh,ps1} --ensure, sharing code
with ``hermes postinstall`` and the runtime lazy installer.
with the runtime lazy installer.
Returns 0 on success, 1 on failure.
"""

View file

@ -1,16 +0,0 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.19.0",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
"authors": ["Nous Research"],
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.19.0",
"args": ["hermes-acp"]
}
}
}

View file

@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none">
<path d="M8 1.5v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M8 3.25c-2.35-1.4-4.7-.95-6.25.35 1.85-.2 3.8.2 5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 3.25c2.35-1.4 4.7-.95 6.25.35-1.85-.2-3.8.2-5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 13.25c-2.3-1-3.05-2.65-1.35-4.15-2 .8-2.35 2.95-.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 13.25c2.3-1 3.05-2.65 1.35-4.15 2 .8 2.35 2.95.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="8" cy="1.8" r="1.1" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 882 B

View file

@ -32,7 +32,6 @@ from __future__ import annotations
import logging
import os
import sysconfig
import threading
from functools import lru_cache
from pathlib import Path
@ -92,12 +91,8 @@ def _locales_dir() -> Path:
1. ``HERMES_BUNDLED_LOCALES`` env var -- set by the Nix wrapper (or any
sealed-packaging system) to point at the installed catalog directory.
2. ``<repo-root>/locales`` -- source checkouts and ``pip install -e .``,
2. ``<repo-root>/locales`` -- source checkouts and editable installs,
where the working tree sits next to ``agent/``.
3. ``<sysconfig data|purelib|platlib>/locales`` -- pip wheel installs.
setuptools ``data-files`` extracts ``locales/*.yaml`` under the
interpreter's ``data`` scheme; the other schemes are checked as a
safety net for nonstandard layouts.
Falling through to the source-style path (even when missing) keeps
``_load_catalog`` error messages informative -- it logs the path it
@ -116,25 +111,6 @@ def _locales_dir() -> Path:
# agent/i18n.py -> agent/ -> repo root (source checkout, editable install)
source_dir = Path(__file__).resolve().parent.parent / "locales"
if source_dir.is_dir():
return source_dir
# pip wheel install: data-files lands under the interpreter data scheme.
# ``data`` (== sys.prefix in a venv) is where setuptools data-files extract
# and is checked first. ``purelib``/``platlib`` (site-packages) are a safety
# net for nonstandard layouts. NOTE: this does NOT cover ``pip install
# --user`` (user scheme, ~/.local/locales) or ``pip install --target`` --
# both are out of scope; see the plan header.
for scheme in ("data", "purelib", "platlib"):
raw = sysconfig.get_path(scheme)
if not raw:
continue
candidate = Path(raw) / "locales"
if candidate.is_dir():
return candidate
# Last resort: return the source-style path so _load_catalog's catalog-missing
# log (logger.debug "i18n catalog missing for %s at %s") stays informative.
return source_dir

View file

@ -220,7 +220,7 @@ export function usePromptActions({
const copy = t.desktop
const appendSessionTextMessage = useCallback(
(sessionId: string, role: ChatMessage['role'], text: string) => {
(sessionId: string, role: ChatMessage['role'], text: string, storedSessionId = selectedStoredSessionIdRef.current) => {
// Strip ANSI: slash-command output from the backend worker carries SGR
// color codes (e.g. "Unknown command" in red). The ESC byte is invisible
// in the chat panel, so without this the `[1;31m…[0m` payload leaks as
@ -244,7 +244,7 @@ export function usePromptActions({
}
]
}),
selectedStoredSessionIdRef.current
storedSessionId
)
},
[selectedStoredSessionIdRef, updateSessionState]

View file

@ -251,48 +251,6 @@ 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.
@ -310,16 +268,11 @@ def check_for_updates() -> Optional[int]:
# Docker images have no working tree to count commits against — the
# published image excludes `.git` (see .dockerignore) and sets no
# HERMES_REVISION (that's nix-only). Without this guard the checks below
# fall through to `check_via_pypi()`, whose PyPI-version mismatch flag (1)
# then gets rendered by the CLI banner and the TUI badge as a phantom
# "1 commit behind" — even though no git repo or commit math is involved,
# and `hermes update` correctly refuses to run in-place inside the
# container anyway. The dashboard's REST `/api/hermes/update/check`
# endpoint already short-circuits docker the same way (web_server.py);
# mirror that here so the banner/TUI surfaces agree. Returning None makes
# both the Rich banner (build_welcome_banner) and the Ink badge
# (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing.
# HERMES_REVISION (that's nix-only). Returning None makes both the Rich
# banner (build_welcome_banner) and the Ink badge (branding.tsx, guarded
# on `typeof === 'number' && > 0`) show nothing. The dashboard's REST
# `/api/hermes/update/check` endpoint short-circuits docker the same way
# (web_server.py); mirror that here so the banner/TUI surfaces agree.
try:
from hermes_cli.config import detect_install_method, get_project_root
if detect_install_method(get_project_root()) == "docker":
@ -328,10 +281,7 @@ def check_for_updates() -> Optional[int]:
pass
# Read cache — invalidate if the embedded rev OR installed version has
# changed since the last check. The version guard matters for pip installs:
# `check_via_pypi()` compares against VERSION, so a `pip install --upgrade`
# changes VERSION but leaves rev unchanged (both None), and without this
# the stale "behind" count would survive the upgrade for up to 6h. See #34491.
# changed since the last check.
now = time.time()
try:
if cache_file.exists():
@ -355,7 +305,10 @@ def check_for_updates() -> Optional[int]:
if not (repo_dir / ".git").exists():
repo_dir = hermes_home / "hermes-agent"
if not (repo_dir / ".git").exists():
behind = check_via_pypi()
# No git checkout and no embedded revision — can't determine
# update status. This is the Docker path (already short-circuited
# above) or an unsupported install without a source tree.
behind = None
else:
behind = _check_via_local_git(repo_dir)
@ -888,27 +841,6 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
except Exception:
pass # Never break the banner over an update check
# Unsupported install-method warning — pip/PyPI and Homebrew are no
# longer an officially supported distribution method (see
# website/docs/getting-started/platform-support.md). Such installs miss
# the git checkout + installer-managed deps, so updates, self-update, and
# issue triage don't behave correctly. Warn, don't block. NixOS is fully
# supported and never hits this.
try:
from hermes_cli.config import (
detect_install_method,
format_unsupported_install_warning,
is_unsupported_install_method,
get_project_root
)
_install_method = detect_install_method(get_project_root())
if is_unsupported_install_method(_install_method):
right_lines.append(
f"[bold yellow]⚠ {format_unsupported_install_warning(_install_method)}[/]"
)
except Exception:
pass # Never break the banner over the install-method check
right_content = "\n".join(right_lines)
layout_table.add_row(left_content, right_content)

View file

@ -326,11 +326,19 @@ from hermes_cli.default_soul import DEFAULT_SOUL_MD, is_legacy_template_soul
_MANAGED_TRUE_VALUES = ("true", "1", "yes")
_MANAGED_SYSTEM_NAMES = {
"brew": "Homebrew",
"homebrew": "Homebrew",
"nix": "NixOS",
"nixos": "NixOS",
}
# The Nix store root. Used by detect_install_method to identify installs
# from `nix run` / `nix profile install` (which don't set HERMES_MANAGED).
# A module-level constant so tests can patch it without creating files
# under the real /nix/store.
_NIX_STORE = Path("/nix/store")
# Values that used to signal a Homebrew-managed install. Homebrew is no
# longer a supported distribution method, so these are explicitly ignored
# rather than treated as a managed system — they fall through to git/unknown
# detection instead of blocking config writes.
_IGNORED_MANAGED_VALUES = frozenset({"brew", "homebrew"})
def get_managed_system() -> Optional[str]:
@ -338,6 +346,8 @@ def get_managed_system() -> Optional[str]:
raw = os.getenv("HERMES_MANAGED", "").strip()
if raw:
normalized = raw.lower()
if normalized in _IGNORED_MANAGED_VALUES:
return None
if normalized in _MANAGED_TRUE_VALUES:
return "NixOS"
return _MANAGED_SYSTEM_NAMES.get(normalized, raw)
@ -364,8 +374,6 @@ _NIX_UPDATE_MSG = "Update your Nix flake input and rebuild (e.g. nix flake updat
def get_managed_update_command() -> Optional[str]:
"""Return the preferred upgrade command for a managed install."""
managed_system = get_managed_system()
if managed_system == "Homebrew":
return "brew upgrade hermes-agent"
if managed_system == "NixOS":
return _NIX_UPDATE_MSG
return None
@ -375,10 +383,10 @@ def _install_method_project_root(project_root: Optional[Path] = None) -> Path:
"""Resolve the directory that holds the *running code* (the install tree).
This is the parent of ``hermes_cli/`` i.e. the git checkout for source
installs, ``/opt/hermes`` inside the published image, the venv's
site-packages root for pip installs. It is a property of the running
interpreter, NOT of ``$HERMES_HOME``, which is why a code-scoped stamp
here is immune to two installs sharing one data directory.
installs, ``/opt/hermes`` inside the published image. It is a property of
the running interpreter, NOT of ``$HERMES_HOME``, which is why a
code-scoped stamp here is immune to two installs sharing one data
directory.
"""
if project_root is not None:
return project_root
@ -386,7 +394,7 @@ def _install_method_project_root(project_root: Optional[Path] = None) -> Path:
def detect_install_method(project_root: Optional[Path] = None) -> str:
"""Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'.
"""Detect how Hermes was installed: 'docker', 'nixos', 'git', or 'unknown'.
Resolution order:
1. Code-scoped stamp ``<install tree>/.install_method`` (next to the
@ -394,9 +402,10 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
2. Legacy home-scoped stamp ``$HERMES_HOME/.install_method`` read for
backward compatibility, but a ``docker`` value is IGNORED when we are
not actually running inside a container (see below).
3. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
4. .git directory presence -> 'git'
5. Fallback -> 'pip'
3. HERMES_MANAGED env / .managed marker (NixOS managed mode)
4. /nix/store/ path detection -> 'nixos' (nix run / nix profile install)
5. .git directory presence -> 'git'
6. Fallback -> 'unknown'
Why the stamp is code-scoped, not home-scoped (issue: shared ``~/.hermes``)
--------------------------------------------------------------------------
@ -415,7 +424,7 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
Self-healing for already-poisoned homes: a legacy ``docker`` value in the
home-scoped stamp is only honoured when we are genuinely in a container.
On a host install that read a contaminating ``docker`` stamp, we fall
through to managed/.git/pip detection instead so existing shared-home
through to managed/.git detection instead so existing shared-home
setups recover without the user touching anything.
Note: running inside a container is NOT treated as "docker" on its own.
@ -425,15 +434,16 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
- the published ``nousresearch/hermes-agent`` image bakes a ``docker``
stamp into ``/opt/hermes`` at build time.
An unsupported manual install dropped into a container (no stamp) falls
through to the ``.git``/pip checks and behaves like any off-path install.
through to the ``.git`` checks and behaves like any off-path install.
See issue #34397.
"""
root = _install_method_project_root(project_root)
supported_methods = {"docker", "nixos", "git", "unknown"}
# 1. Code-scoped stamp — authoritative, immune to shared $HERMES_HOME.
try:
method = (root / ".install_method").read_text(encoding="utf-8").strip().lower()
if method:
if method in supported_methods:
return method
except OSError:
pass
@ -449,7 +459,7 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
.strip()
.lower()
)
if method and not (method == "docker" and not _running_in_container()):
if method in supported_methods and not (method == "docker" and not _running_in_container()):
return method
except OSError:
pass
@ -457,7 +467,18 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
managed = get_managed_system()
if managed:
return managed.lower().replace(" ", "-")
# detect Nix installs that don't set HERMES_MANAGED (e.g. ``nix run``,
# ``nix profile install``). The code lives under /nix/store/ which is the
# hallmark of a nix-built install — no other supported install path puts
# code there.
try:
resolved = root.resolve()
if resolved != _NIX_STORE and _NIX_STORE in resolved.parents:
return "nixos"
except OSError:
pass
# detect git repo installs (normal installer, development env)
git_path = root / ".git"
if git_path.is_dir():
@ -471,7 +492,7 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
return "git"
except OSError:
pass
return "pip"
return "unknown"
def _running_in_container() -> bool:
@ -505,49 +526,12 @@ def stamp_install_method(method: str, project_root: Optional[Path] = None) -> No
pass
def is_uv_tool_install() -> bool:
"""Return True when the *running* Hermes lives in a ``uv tool`` layout.
``uv tool install hermes-agent`` places the install at
``.../uv/tools/hermes-agent/...`` (default ``~/.local/share/uv/tools``,
or ``$UV_TOOL_DIR/...``). Such installs live outside any virtualenv, so
``uv pip install`` fails with ``No virtual environment found`` and the
update path must use ``uv tool upgrade`` instead.
Detection is intentionally restricted to properties of the running
interpreter (``sys.prefix`` / ``sys.executable``). We deliberately do
NOT consult ``uv tool list``: it would also return True when
``hermes-agent`` happens to be uv-tool-installed on the machine while
the *active* Hermes is a regular pip/venv install, causing
``hermes update`` to upgrade the wrong copy. It would also block on a
subprocess call (~seconds) just to compute a recommendation string.
"""
def _has_uv_tool_marker(path: str) -> bool:
norm = os.path.normpath(path).replace(os.sep, "/").lower()
return "/uv/tools/hermes-agent/" in norm + "/"
if _has_uv_tool_marker(sys.prefix):
return True
if _has_uv_tool_marker(sys.executable or ""):
return True
return False
def recommended_update_command_for_method(method: str) -> str:
"""Return the update command or guidance for a given install method."""
if method == "nixos":
return _NIX_UPDATE_MSG
if method == "homebrew":
return "brew upgrade hermes-agent"
if method == "docker":
return "docker pull nousresearch/hermes-agent:latest"
if method == "pip":
if is_uv_tool_install():
return "uv tool upgrade hermes-agent"
import shutil
if shutil.which("uv"):
return "uv pip install --upgrade hermes-agent"
return "pip install --upgrade hermes-agent"
return "hermes update"
@ -560,50 +544,6 @@ def recommended_update_command() -> str:
return recommended_update_command_for_method(method)
# =============================================================================
# Unsupported install methods (pip, Homebrew) — deprecation notice
# =============================================================================
#
# pip/PyPI and Homebrew are NOT an officially supported distribution method
# (see website/docs/getting-started/platform-support.md, "Unsupported"
# section). pip exists on PyPI for internal/CI reasons, not end-user installs;
# Homebrew is a legacy packaging path. Unlike NixOS/Homebrew "managed mode"
# (which hard-blocks config writes), this is a warn-don't-block deprecation
# notice surfaced everywhere the user might see install-method state: the CLI
# banner, the TUI/desktop session info panel, and ``hermes update``. NixOS
# stays fully supported (Tier 2) and must never hit this path.
PLATFORM_SUPPORT_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/getting-started/platform-support"
_UNSUPPORTED_INSTALL_METHODS = frozenset({"pip", "homebrew"})
def is_unsupported_install_method(method: str) -> bool:
"""Whether ``method`` (from ``detect_install_method()``) is deprecated."""
return method in _UNSUPPORTED_INSTALL_METHODS
def unsupported_install_method_label(method: str) -> str:
"""Human-readable name for an unsupported install method."""
return "pip" if method == "pip" else "Homebrew"
def format_unsupported_install_warning(method: str) -> str:
"""Plain-text (no markup) deprecation notice for pip/Homebrew installs.
Shared verbatim across the CLI banner, TUI/desktop ``session.info``, and
``hermes update`` / ``hermes update --check`` so the wording and the
docs link stays consistent across every surface instead of drifting
into three slightly different warnings.
"""
label = unsupported_install_method_label(method)
return (
f"{label} installs are no longer an officially supported platform and "
f"will not receive further updates. See {PLATFORM_SUPPORT_DOCS_URL} "
"for supported install methods."
)
# Long-form text for ``hermes update`` / ``--check`` when running inside the
# Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in
# hermes_cli/main.py; lives here so the wording stays consistent and we
@ -670,15 +610,6 @@ def format_managed_message(action: str = "modify this Hermes installation") -> s
" sudo nixos-rebuild switch"
)
if managed_system == "Homebrew":
env_hint = raw or "homebrew"
return (
f"Cannot {action}: this Hermes installation is managed by Homebrew "
f"(HERMES_MANAGED={env_hint}).\n"
"Use:\n"
" brew upgrade hermes-agent"
)
return (
f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n"
"Use your package manager to upgrade or reinstall Hermes."

View file

@ -1180,7 +1180,7 @@ class HermesConsoleEngine:
"model",
"moa",
"oneshot",
"postinstall",
"proxy",
"serve",
"setup",

View file

@ -406,7 +406,7 @@ from hermes_cli.subcommands.gateway import build_gateway_parser
from hermes_cli.subcommands.profile import build_profile_parser
from hermes_cli.subcommands.model import build_model_parser
from hermes_cli.subcommands.setup import build_setup_parser
from hermes_cli.subcommands.postinstall import build_postinstall_parser
from hermes_cli.subcommands.whatsapp import build_whatsapp_parser
from hermes_cli.subcommands.slack import build_slack_parser
from hermes_cli.subcommands.login import build_login_parser
@ -1886,14 +1886,15 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
)
sys.exit(1)
# 1. Prebuilt bundle (nix / packaged release): just run it.
# 1. Prebuilt bundle (nix / packaged release / Docker image): just run it.
#
# This must run BEFORE _ensure_tui_workspace() below. A pip/pipx install
# ships hermes_cli/tui_dist/entry.js in the wheel but never ships ui-tui/
# at all (that directory only exists in a git checkout) — so requiring
# the workspace to exist first made every pip/pipx dashboard Chat tab
# connection hard-exit before it ever got a chance to try the bundled
# entry.js it already has. See #56665.
# This must run BEFORE _ensure_tui_workspace() below. A prebuilt install
# (Docker image, Nix build, or prior `npm run build`) ships
# hermes_cli/tui_dist/entry.js but never ships ui-tui/ at all (that
# directory only exists in a git checkout) — so requiring the workspace
# to exist first made every prebuilt dashboard Chat tab connection
# hard-exit before it ever got a chance to try the bundled entry.js it
# already has. See #56665.
if not tui_dev:
if ext_dir:
p = Path(ext_dir)
@ -1901,7 +1902,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
node = _node_bin("node")
return [node, "--expose-gc", str(p / "dist" / "entry.js")], p
# 1b. Bundled in wheel (pip install)
# 1b. Bundled prebuilt TUI (Docker image, Nix build, or prior npm build)
bundled = _find_bundled_tui()
if bundled is not None:
node = _node_bin("node")
@ -2902,27 +2903,6 @@ 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.config import stamp_install_method
from hermes_cli.dep_ensure import ensure_dependency
stamp_install_method("pip")
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")
@ -8962,18 +8942,11 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
on origin/<branch>?" without performing the update.
``branch_explicit`` is True iff the caller passed --branch on the CLI.
PyPI installs can't honor non-default branches, so when this is True
on a PyPI install we surface a one-line notice instead of silently
dropping the flag.
Installs that can't honor non-default branches (e.g. Docker) surface a
one-line notice instead of silently dropping the flag.
"""
from hermes_cli.config import (
detect_install_method,
format_unsupported_install_warning,
is_unsupported_install_method,
)
from hermes_cli.config import detect_install_method
method = detect_install_method(PROJECT_ROOT)
if is_unsupported_install_method(method):
print(f"{format_unsupported_install_warning(method)}")
if method == "docker":
# Docker can't ``git fetch`` from within the container. Surface the
# same long-form ``docker pull`` guidance ``hermes update`` (apply
@ -8982,21 +8955,6 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
from hermes_cli.config import format_docker_update_message
print(format_docker_update_message())
sys.exit(1)
if method == "pip":
from hermes_cli.config import recommended_update_command
from hermes_cli.banner import check_via_pypi
if branch_explicit and branch != "main":
print(f"⚠ --branch is ignored for PyPI installs (would have checked '{branch}').")
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():
@ -9927,21 +9885,10 @@ def cmd_update(args):
from hermes_cli.config import (
detect_install_method,
format_docker_update_message,
format_unsupported_install_warning,
is_managed,
is_unsupported_install_method,
managed_error,
)
# Deprecation notice for pip/Homebrew installs — printed before the
# managed-mode early-return below so Homebrew users (who are blocked from
# applying the update here) still see it. Warn, don't block: the update
# itself still proceeds (except Homebrew, which is managed-mode blocked
# for an unrelated reason — brew owns its own upgrade path).
_install_method_for_warning = detect_install_method(PROJECT_ROOT)
if is_unsupported_install_method(_install_method_for_warning):
print(f"{format_unsupported_install_warning(_install_method_for_warning)}")
if is_managed():
managed_error("update Hermes Agent")
return
@ -9978,67 +9925,6 @@ 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__
from hermes_cli.config import is_uv_tool_install
print(f"→ Current version: {__version__}")
print("→ Checking PyPI for updates...")
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
# Keep managed uv current before using it.
update_managed_uv()
uv = ensure_uv()
in_venv = sys.prefix != sys.base_prefix
# pipx-managed installs live under .../pipx/venvs/<name>/...
pipx_managed = "pipx" in sys.prefix.split(os.sep)
pipx = shutil.which("pipx") if pipx_managed else None
# Only the ``uv pip install`` path inside a venv needs VIRTUAL_ENV
# exported (uv refuses to install without it when the launcher shim
# didn't activate the venv). ``uv tool upgrade`` / ``pipx upgrade``
# operate on a named environment and ignore VIRTUAL_ENV, so we don't
# set it for them.
export_virtualenv = False
if is_uv_tool_install():
if not uv:
print("✗ Detected a uv-tool install but managed uv install failed.")
print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/")
sys.exit(1)
cmd = [uv, "tool", "upgrade", "hermes-agent"]
elif pipx_managed and pipx:
# pipx owns its own venv; ``pipx upgrade`` is the only correct path.
# Matches scripts/auto-update.sh, which already uses pipx upgrade.
cmd = [pipx, "upgrade", "hermes-agent"]
elif uv:
cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"]
if in_venv:
# Launcher shim runs the venv interpreter but doesn't export
# VIRTUAL_ENV; without it uv errors "No virtual environment found".
export_virtualenv = True
else:
# Outside any venv, ``--system`` lets uv target the active
# interpreter, matching pip's default behaviour.
cmd.insert(3, "--system")
else:
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"]
print(f"→ Running: {' '.join(cmd)}")
run_kwargs = {}
if export_virtualenv:
run_kwargs["env"] = {**os.environ, "VIRTUAL_ENV": sys.prefix}
result = subprocess.run(cmd, **run_kwargs)
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``."""
@ -10130,11 +10016,6 @@ 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://hermes-agent.nousresearch.com/install.sh | bash"
@ -13089,7 +12970,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"dump", "fallback", "gateway", "hooks", "import", "insights",
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa",
"journey", "memory-graph", "learning",
"model", "pairing", "pets", "plugins", "portal", "postinstall", "profile",
"model", "pairing", "pets", "plugins", "portal", "profile",
"project", "proxy",
"prompt-size",
"send", "sessions", "setup",
@ -13790,10 +13671,6 @@ def main():
# =========================================================================
build_setup_parser(subparsers, cmd_setup=cmd_setup)
# =========================================================================
# postinstall command (parser built in hermes_cli/subcommands/postinstall.py)
# =========================================================================
build_postinstall_parser(subparsers, cmd_postinstall=cmd_postinstall)
# =========================================================================
# whatsapp command (parser built in hermes_cli/subcommands/whatsapp.py)

View file

@ -20,9 +20,7 @@ logger = logging.getLogger(__name__)
def _print_aiohttp_missing() -> None:
print(
"hermes proxy requires aiohttp. Install one of:\n"
" pip install 'hermes-agent[messaging]'\n"
" pip install aiohttp",
"hermes proxy requires aiohttp. Run `hermes setup` to install it.",
file=sys.stderr,
)

View file

@ -89,8 +89,7 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application":
"""Build the aiohttp application bound to a specific upstream adapter."""
if not AIOHTTP_AVAILABLE:
raise RuntimeError(
"aiohttp is required for `hermes proxy`. Install with: "
"pip install 'hermes-agent[messaging]' or `pip install aiohttp`."
"aiohttp is required for `hermes proxy`. Run `hermes setup` to install it."
)
app = web.Application(client_max_size=MAX_REQUEST_BYTES)
@ -256,8 +255,7 @@ async def run_server(
"""
if not AIOHTTP_AVAILABLE:
raise RuntimeError(
"aiohttp is required for `hermes proxy`. Install with: "
"pip install 'hermes-agent[messaging]' or `pip install aiohttp`."
"aiohttp is required for `hermes proxy`. Run `hermes setup` to install it."
)
app = create_app(adapter)

View file

@ -1626,7 +1626,7 @@ def resolve_runtime_provider(
"in ~/.hermes/.env, or run 'gcloud auth application-default "
"login' for ADC. Set the GCP project/region under vertex: in "
"config.yaml if they aren't embedded in the credentials. "
"Install the extra with: pip install 'hermes-agent[vertex]'."
"Run `hermes setup` to install Vertex support."
)
return {
"provider": "vertex",

View file

@ -1,23 +0,0 @@
"""``hermes postinstall`` subcommand parser.
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
from typing import Callable
def build_postinstall_parser(subparsers, *, cmd_postinstall: Callable) -> None:
"""Attach the ``postinstall`` subcommand to ``subparsers``."""
# =========================================================================
# 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)

View file

@ -4099,7 +4099,7 @@ async def check_hermes_update(force: bool = False):
"current_version": __version__,
"behind": None,
"update_available": False,
"can_apply": install_method in ("git", "pip"),
"can_apply": install_method == "git",
"update_command": update_command,
"message": None,
}
@ -4133,9 +4133,9 @@ async def check_hermes_update(force: bool = False):
else:
payload["update_available"] = True
# Enrich with the actual commits we're behind by, so the desktop's
# remote update overlay can show "what's changed". git/pip only;
# remote update overlay can show "what's changed". git only;
# best-effort (empty list on any failure).
if install_method in ("git", "pip"):
if install_method == "git":
payload["commits"] = await asyncio.to_thread(_recent_upstream_commits)
return payload

View file

@ -8,7 +8,6 @@ import os
import shutil
import stat
import sys
import sysconfig
from contextvars import ContextVar, Token
from pathlib import Path
@ -191,23 +190,6 @@ def get_default_hermes_root() -> Path:
return env_path
def _get_packaged_data_dir(name: str) -> Path | None:
"""Return an installed data-files directory if one exists.
Used to discover bundled skills/optional-skills when Hermes is installed
from a wheel that emitted them via setuptools data_files.
"""
candidates = []
for scheme in ("data", "purelib", "platlib"):
raw = sysconfig.get_path(scheme)
if raw:
candidates.append(Path(raw) / name)
for candidate in candidates:
if candidate.exists():
return candidate
return None
def get_optional_skills_dir(default: Path | None = None) -> Path:
"""Return the optional-skills directory, honoring package-manager wrappers.
@ -217,9 +199,6 @@ def get_optional_skills_dir(default: Path | None = None) -> Path:
override = os.getenv("HERMES_OPTIONAL_SKILLS", "").strip()
if override:
return Path(override)
packaged = _get_packaged_data_dir("optional-skills")
if packaged is not None:
return packaged
if default is not None:
return default
return get_hermes_home() / "optional-skills"
@ -236,9 +215,6 @@ def get_optional_mcps_dir(default: Path | None = None) -> Path:
override = os.getenv("HERMES_OPTIONAL_MCPS", "").strip()
if override:
return Path(override)
packaged = _get_packaged_data_dir("optional-mcps")
if packaged is not None:
return packaged
if default is not None:
return default
return get_hermes_home() / "optional-mcps"
@ -249,16 +225,12 @@ def get_bundled_skills_dir(default: Path | None = None) -> Path:
Resolution order:
1. ``HERMES_BUNDLED_SKILLS`` env var (Nix wrapper / explicit override)
2. Wheel-installed ``<sysconfig data>/skills`` (pip install path)
3. Caller-supplied ``default`` (typically the source-checkout path)
4. ``<HERMES_HOME>/skills`` last-resort
2. Caller-supplied ``default`` (typically the source-checkout path)
3. ``<HERMES_HOME>/skills`` last-resort
"""
override = os.getenv("HERMES_BUNDLED_SKILLS", "").strip()
if override:
return Path(override)
packaged = _get_packaged_data_dir("skills")
if packaged is not None:
return packaged
if default is not None:
return default
return get_hermes_home() / "skills"

View file

@ -192,7 +192,9 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
(echo "FAIL: HERMES_BUNDLED_LOCALES not in wrapper"; exit 1)
echo "PASS: HERMES_BUNDLED_LOCALES set in wrapper"
echo "=== Rendering via the wrapper override (HERMES_BUNDLED_LOCALES) ==="
# locales/ is a bare data dir (no __init__.py), shipped via a
# symlink + HERMES_BUNDLED_LOCALES (not via wheel data-files).
# Verify the wrapper override resolves real strings.
export HOME=$(mktemp -d)
RENDERED=$(cd "$HOME" && HERMES_BUNDLED_LOCALES=${hermes-agent}/share/hermes-agent/locales \
${hermesVenv}/bin/python3 -c "from agent import i18n; print(i18n.t('gateway.reset.header_default', lang='en'))")
@ -200,25 +202,39 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
test "$RENDERED" != "gateway.reset.header_default" || (echo "FAIL: i18n returned the raw key with HERMES_BUNDLED_LOCALES set"; exit 1)
echo "PASS: i18n renders a human string via the wrapper override"
# Defense-in-depth check: the sealed venv must ALSO resolve catalogs
# with NO env var, via the wheel's setuptools data-files materialized
# into the venv data scheme. If a future uv2nix bump drops data-files,
# the wrapper override above would mask the regression at runtime while
# `pip install`/other sealed paths silently break — this catches it.
echo "=== Rendering WITHOUT the env var (data-files materialization) ==="
BARE_DIR=$(cd "$HOME" && ${hermesVenv}/bin/python3 -c "from agent import i18n; print(i18n._locales_dir())")
BARE=$(cd "$HOME" && ${hermesVenv}/bin/python3 -c "from agent import i18n; print(i18n.t('gateway.reset.header_default', lang='en'))")
echo "resolved dir (no env var): $BARE_DIR"
echo "rendered: $BARE"
test "$BARE" != "gateway.reset.header_default" || \
(echo "FAIL: sealed venv could not resolve locales without HERMES_BUNDLED_LOCALES data-files materialization regressed"; exit 1)
echo "PASS: sealed venv resolves locales via data-files without the env var"
echo "=== All bundled locales checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify bundled optional-mcps catalog is present and resolvable.
# optional-mcps/ is a bare data dir shipped via symlink +
# HERMES_OPTIONAL_MCPS (not via wheel data-files).
bundled-mcps = pkgs.runCommand "hermes-bundled-mcps" { } ''
set -e
echo "=== Checking bundled optional-mcps ==="
test -d ${hermes-agent}/share/hermes-agent/optional-mcps || (echo "FAIL: optional-mcps directory missing"; exit 1)
echo "PASS: optional-mcps directory exists"
MANIFEST_COUNT=$(find -L ${hermes-agent}/share/hermes-agent/optional-mcps -name "manifest.yaml" | wc -l)
test "$MANIFEST_COUNT" -gt 0 || (echo "FAIL: no manifest.yaml files found"; exit 1)
echo "PASS: $MANIFEST_COUNT catalog manifests found"
grep -q "HERMES_OPTIONAL_MCPS" ${hermes-agent}/bin/hermes || \
(echo "FAIL: HERMES_OPTIONAL_MCPS not in wrapper"; exit 1)
echo "PASS: HERMES_OPTIONAL_MCPS set in wrapper"
export HOME=$(mktemp -d)
CATALOG=$(cd "$HOME" && ${hermes-agent}/bin/hermes mcp catalog 2>/dev/null || true)
echo "catalog output: $CATALOG"
test -n "$CATALOG" || (echo "FAIL: hermes mcp catalog returned empty"; exit 1)
echo "PASS: mcp catalog resolves entries"
echo "=== All bundled optional-mcps checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify bundled TUI is present and compiled
bundled-tui = pkgs.runCommand "hermes-bundled-tui" { } ''
set -e

View file

@ -86,18 +86,16 @@ let
# i18n locale catalogs (locales/*.yaml). Shipped into the store and pointed
# at by HERMES_BUNDLED_LOCALES so the wrapped binary always resolves human
# strings instead of raw i18n keys (#23943 / #27632 / #35374).
#
# Defense-in-depth, not load-bearing: the wheel already declares locales/ as
# setuptools data-files, so uv2nix materializes them into the venv's data
# scheme and agent/i18n.py resolves them with no env var. The wrapper override
# pins the store path so a future uv2nix change that drops data-files can't
# silently ship raw keys via `nix build` (checks don't run on a plain build).
# The bundled-locales flake check verifies BOTH paths independently.
#
# Plain cleanSource (no __pycache__ filter): locales/ is bare *.yaml, never
# compiled, so it never carries a __pycache__ dir to exclude.
bundledLocales = lib.cleanSource ../locales;
# Shipped MCP catalog (optional-mcps/<name>/manifest.yaml). Same bare-data-dir
# case as locales: not a Python package, so it's symlinked into the store and
# exposed via HERMES_OPTIONAL_MCPS.
bundledOptionalMcps = lib.cleanSourceWith {
src = ../optional-mcps;
filter = path: _type: !(lib.hasInfix "/__pycache__/" path);
};
runtimeDeps = [
nodejs
ripgrep
@ -181,6 +179,7 @@ stdenv.mkDerivation (finalAttrs: {
ln -s ${bundledOptionalSkills} $out/share/hermes-agent/optional-skills
ln -s ${bundledPlugins} $out/share/hermes-agent/plugins
ln -s ${bundledLocales} $out/share/hermes-agent/locales
ln -s ${bundledOptionalMcps} $out/share/hermes-agent/optional-mcps
ln -s ${hermesWeb} $out/share/hermes-agent/web_dist
ln -s ${hermesTui}/lib/hermes-tui $out/ui-tui
@ -192,6 +191,7 @@ stdenv.mkDerivation (finalAttrs: {
--set HERMES_OPTIONAL_SKILLS $out/share/hermes-agent/optional-skills \
--set HERMES_BUNDLED_PLUGINS $out/share/hermes-agent/plugins \
--set HERMES_BUNDLED_LOCALES $out/share/hermes-agent/locales \
--set HERMES_OPTIONAL_MCPS $out/share/hermes-agent/optional-mcps \
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
--set HERMES_TUI_DIR $out/ui-tui \
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \

View file

@ -106,10 +106,14 @@ let
# wheel's data_files — setup.py's _data_file_tree returns []
# for a missing dir, so the wheel builds fine without them.
# This keeps SKILL.md edits from rebuilding the Python venv.
# NOTE: optional-mcps must stay — pyproject.toml lists its
# manifests as explicit data-files, which error when missing.
"skills"
"optional-skills"
# locales/ and optional-mcps/ are bare data dirs (no
# __init__.py) shipped via symlinks + HERMES_BUNDLED_LOCALES
# / HERMES_OPTIONAL_MCPS, not via the wheel. Excluding them
# keeps catalog edits from rebuilding the Python venv.
"locales"
"optional-mcps"
];
excludedFiles = [
# JS root manifests

View file

@ -110,6 +110,15 @@ let
overlay
buildSystemOverrides
pythonPackageOverrides
# ``setup.py`` permits wheel/sdist creation only from the sealed
# Hermes derivation. This is deliberately a derivation environment
# variable, not a devShell variable: ``nix develop -c uv build``
# must remain blocked.
(final: prev: {
hermes-agent = prev.hermes-agent.overrideAttrs (_old: {
HERMES_NIX_BUILD = "1";
});
})
]
);

View file

@ -1,14 +0,0 @@
Homebrew packaging notes for Hermes Agent.
Use `packaging/homebrew/hermes-agent.rb` as a tap or `homebrew-core` starting point.
Key choices:
- Stable builds should target the semver-named sdist asset attached to each GitHub release, not the CalVer tag tarball.
- `faster-whisper` now lives in the `voice` extra, which keeps wheel-only transitive dependencies out of the base Homebrew formula.
- The wrapper exports `HERMES_BUNDLED_SKILLS`, `HERMES_OPTIONAL_SKILLS`, and `HERMES_MANAGED=homebrew` so packaged installs keep runtime assets and defer upgrades to Homebrew.
Typical update flow:
1. Bump the formula `url`, `version`, and `sha256`.
2. Refresh Python resources with `brew update-python-resources --print-only hermes-agent`.
3. Keep `ignore_packages: %w[certifi cryptography pydantic]`.
4. Verify `brew audit --new --strict hermes-agent` and `brew test hermes-agent`.

View file

@ -1,48 +0,0 @@
class HermesAgent < Formula
include Language::Python::Virtualenv
desc "Self-improving AI agent that creates skills from experience"
homepage "https://hermes-agent.nousresearch.com"
# Stable source should point at the semver-named sdist asset attached by
# scripts/release.py, not the CalVer tag tarball.
url "https://github.com/NousResearch/hermes-agent/releases/download/v2026.3.30/hermes_agent-0.6.0.tar.gz"
sha256 "<replace-with-release-asset-sha256>"
license "MIT"
depends_on "certifi" => :no_linkage
depends_on "cryptography" => :no_linkage
depends_on "libyaml"
depends_on "python@3.14"
pypi_packages ignore_packages: %w[certifi cryptography pydantic]
# Refresh resource stanzas after bumping the source url/version:
# brew update-python-resources --print-only hermes-agent
def install
venv = virtualenv_create(libexec, "python3.14")
venv.pip_install resources
venv.pip_install buildpath
pkgshare.install "skills", "optional-skills"
%w[hermes hermes-agent hermes-acp].each do |exe|
next unless (libexec/"bin"/exe).exist?
(bin/exe).write_env_script(
libexec/"bin"/exe,
HERMES_BUNDLED_SKILLS: pkgshare/"skills",
HERMES_OPTIONAL_SKILLS: pkgshare/"optional-skills",
HERMES_MANAGED: "homebrew"
)
end
end
test do
assert_match "Hermes Agent v#{version}", shell_output("#{bin}/hermes version")
managed = shell_output("#{bin}/hermes update 2>&1")
assert_match "managed by Homebrew", managed
assert_match "brew upgrade hermes-agent", managed
end
end

View file

@ -9472,7 +9472,7 @@ def register(ctx) -> None:
check_fn=check_discord_requirements,
is_connected=_is_connected,
required_env=["DISCORD_BOT_TOKEN"],
install_hint="pip install 'hermes-agent[messaging]'",
install_hint="Run `hermes setup` to install Discord support.",
# Interactive setup wizard — replaces the central
# hermes_cli/setup.py::_setup_discord function. Same shape as Teams.
setup_fn=interactive_setup,

View file

@ -5465,7 +5465,7 @@ async def _standalone_send(
(images, video, voice, documents). Replaces the legacy _send_feishu helper.
"""
if not FEISHU_AVAILABLE:
return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
return {"error": "Feishu dependencies not installed. Run `hermes setup` to install Feishu support."}
media_files = media_files or []
try:
@ -5713,7 +5713,7 @@ def register(ctx) -> None:
is_connected=_is_connected,
validate_config=_is_connected,
required_env=["FEISHU_APP_ID", "FEISHU_APP_SECRET"],
install_hint="pip install 'hermes-agent[feishu]'",
install_hint="Run `hermes setup` to install Feishu support.",
setup_fn=interactive_setup,
apply_yaml_config_fn=_apply_yaml_config,
allowed_users_env="FEISHU_ALLOWED_USERS",

View file

@ -3689,7 +3689,7 @@ def register(ctx) -> None:
required_env=[
"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON",
],
install_hint="pip install 'hermes-agent[google_chat]'",
install_hint="Run `hermes setup` to install Google Chat support.",
setup_fn=interactive_setup,
# Env-driven auto-configuration — the core env-populator hook calls
# this during ``_apply_env_overrides`` and seeds

View file

@ -199,7 +199,7 @@ def load_user_credentials(email: Optional[str] = None) -> Optional[Any]:
except ImportError:
logger.warning(
"[google_chat_user_oauth] google-auth not installed; user-OAuth "
"attachment delivery is disabled. Install hermes-agent[google_chat]."
"attachment delivery is disabled. Run `hermes setup` to install Google Chat support."
)
return None
@ -388,8 +388,7 @@ def install_deps() -> bool:
return True
except Exception as exc:
print(f"ERROR: Failed to install dependencies: {exc}")
print("Or install via the optional extra:")
print(" pip install 'hermes-agent[google_chat]'")
print("Run `hermes setup` to repair the managed installation, then retry.")
return False

View file

@ -5163,7 +5163,7 @@ def register(ctx) -> None:
check_fn=check_slack_requirements,
is_connected=_is_connected,
required_env=["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"],
install_hint="pip install 'hermes-agent[slack]'",
install_hint="Run `hermes setup` to install Slack support.",
# Interactive setup wizard — replaces hermes_cli/setup.py::_setup_slack
# and the static _PLATFORMS["slack"] dict in hermes_cli/gateway.py.
setup_fn=interactive_setup,

View file

@ -9435,7 +9435,7 @@ def register(ctx) -> None:
check_fn=check_telegram_requirements,
is_connected=_is_connected,
required_env=["TELEGRAM_BOT_TOKEN"],
install_hint="pip install 'hermes-agent[telegram]'",
install_hint="Run `hermes setup` to install Telegram support.",
setup_fn=interactive_setup,
apply_yaml_config_fn=_apply_yaml_config,
allowed_users_env="TELEGRAM_ALLOWED_USERS",

View file

@ -1861,7 +1861,7 @@ def register(ctx) -> None:
is_connected=_is_connected,
validate_config=_is_connected,
required_env=["WECOM_BOT_ID", "WECOM_SECRET"],
install_hint="pip install 'hermes-agent[wecom]'",
install_hint="Run `hermes setup` to install WeCom support.",
setup_fn=interactive_setup,
allowed_users_env="WECOM_ALLOWED_USERS",
allow_all_env="WECOM_ALLOW_ALL_USERS",
@ -1881,7 +1881,7 @@ def register(ctx) -> None:
is_connected=_callback_is_connected,
validate_config=_callback_is_connected,
required_env=["WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"],
install_hint="pip install 'hermes-agent[wecom]'",
install_hint="Run `hermes setup` to install WeCom support.",
allowed_users_env="WECOM_CALLBACK_ALLOWED_USERS",
allow_all_env="WECOM_CALLBACK_ALLOW_ALL_USERS",
emoji="💼",

View file

@ -1,9 +1,4 @@
# PEP 639 SPDX license expression (`license = "MIT"` below) requires
# setuptools>=77. Keep this floor in lockstep with the `license` form in
# [project]; an older build backend rejects the string form.
[build-system]
requires = ["setuptools>=77.0,<83"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent"
@ -175,18 +170,11 @@ wecom = ["defusedxml==0.7.1"]
cli = ["simple-term-menu==1.6.6"]
tts-premium = ["elevenlabs==1.59.0"]
voice = [
# Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime),
# so keep it out of the base install for source-build packagers like Homebrew.
# Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime).
"faster-whisper==1.2.1",
"sounddevice==0.5.5",
"numpy==2.4.3",
]
pty = [
# Kept as a no-op back-compat alias — `ptyprocess` and `pywinpty` are now
# in the main `dependencies` list (with the same platform markers), so
# any existing `pip install hermes-agent[pty]` invocations resolve cleanly
# without pulling in extra packages.
]
honcho = ["honcho-ai==2.2.0"]
# Cloud memory providers — opt-in, lazy-installed via tools/lazy_deps.py
# (memory.supermemory / memory.mem0) at first use. Exact pins MUST match the
@ -196,11 +184,16 @@ honcho = ["honcho-ai==2.2.0"]
supermemory = ["supermemory==3.50.0"]
mem0 = ["mem0ai==2.0.10"]
# Image resize recovery for the vision tools. Pillow is now a CORE dependency
# (see the main `dependencies` list) since the byte/pixel shrink paths are on
# (see the main `dependencies` list above) since the byte/pixel shrink paths are on
# the default vision-embed path and the mid-session lazy install deadlocked the
# CLI under prompt_toolkit (#40490). This extra is kept as a no-op back-compat
# alias so existing `pip install hermes-agent[vision]` invocations still resolve.
# alias so existing requests for the `vision` extra resolve.
vision = []
# Kept as a no-op back-compat alias — `ptyprocess` and `pywinpty` are now
# in the main `dependencies` list (with the same platform markers), so
# any existing requests for the `pty` extra resolve cleanly
# without pulling in extra packages.
pty = []
# CVE-2026-48710 (BadHost): Starlette is pulled transitively by mcp's
# sse-starlette / HTTP-SSE stack (and by fastapi in the `web` extra). Before
# 1.0.1, a malformed Host header makes `request.url.path` desync from the path
@ -236,7 +229,6 @@ termux = [
"python-telegram-bot[webhooks]==22.6",
"hermes-agent[cron]",
"hermes-agent[cli]",
"hermes-agent[pty]",
"hermes-agent[mcp]",
"hermes-agent[honcho]",
"hermes-agent[acp]",
@ -251,14 +243,15 @@ termux-all = [
"hermes-agent[homeassistant]",
"hermes-agent[sms]",
"hermes-agent[web]",
"hermes-agent[pty]",
]
dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"]
feishu = ["lark-oapi==1.6.8", "qrcode==7.4.2"]
google = [
# Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts,
# Sheets, Docs). Declared here so packagers (Nix, Homebrew) ship them with
# the [all] extra and users don't hit runtime `pip install` paths that fail
# in environments without pip (e.g. Nix-managed Python).
# Sheets, Docs). Declared here so dev environments (`uv sync --extra google`)
# and packagers ship them without hitting runtime `pip install` paths that
# fail in environments without pip (e.g. Nix-managed Python).
"google-api-python-client==2.194.0",
"google-auth-oauthlib==1.3.1",
"google-auth-httplib2==0.3.1",
@ -278,11 +271,11 @@ all = [
# Policy (2026-05-12): `[all]` includes only extras that genuinely
# CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every
# session can use, things needed before the agent loop is alive
# (terminal/CLI), and skill deps that packagers (Nix, AUR, Homebrew)
# need in the wheel. Anything an opt-in backend (provider, search,
# TTS, image, memory, messaging platform, terminal sandbox) needs
# MUST live exclusively in `LAZY_DEPS` and resolve at first use —
# otherwise one quarantined PyPI release breaks every fresh install.
# (terminal/CLI), and skill deps that dev environments need.
# Anything an opt-in backend (provider, search, TTS, image, memory,
# messaging platform, terminal sandbox) needs MUST live exclusively in
# `LAZY_DEPS` and resolve at first use — otherwise one quarantined PyPI
# release breaks every fresh install.
#
# Removed from [all] on 2026-05-12 (covered by lazy-install):
# anthropic, exa, firecrawl, parallel-web, fal, edge-tts,
@ -308,58 +301,31 @@ all = [
"hermes-agent[youtube]",
]
[build-system]
requires = ["setuptools>=77.0,<83"]
build-backend = "setuptools.build_meta"
[project.scripts]
hermes = "hermes_cli.main:main"
hermes-agent = "run_agent:main"
hermes-acp = "acp_adapter.entry:main"
[tool.setuptools]
# Top-level single-file modules (not packages). Without this, uv2nix's
# sealed venv is missing hermes_constants, run_agent, etc.
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", "mcp_serve"]
[tool.setuptools.data-files]
# i18n catalogs. locales/ is a bare data directory (no __init__.py), so it is
# neither a package (packages.find) nor package-data (which attaches to a
# package). data-files ships it in the wheel; MANIFEST.in `graft locales`
# ships it in the sdist. Without this, sealed installs (pip wheel, Nix store
# venv) drop the catalogs and gateway/CLI commands surface raw i18n keys like
# `gateway.reset.header_default` (#27632, #35374, #23943).
locales = ["locales/*.yaml"]
# Shipped MCP catalog (optional-mcps/<name>/manifest.yaml). Same bare-data-dir
# case as locales: data-files ships it in the wheel, `graft optional-mcps` in
# MANIFEST.in ships it in the sdist. Without this, `hermes mcp catalog` and the
# dashboard catalog screen come up empty on packaged installs even though the
# manifests exist in the repo (hermes_cli/mcp_catalog.py:_catalog_root resolves
# the packaged dir; list_catalog() returns [] when it's missing).
#
# data-files flattens every glob match into its single target dir, so each
# catalog entry needs its OWN target to preserve the per-entry directory the
# catalog iterates over (a shared `optional-mcps/*/*` glob would collapse all
# manifests into one colliding optional-mcps/manifest.yaml). One target per
# entry; tests/test_packaging_metadata.py enforces an entry per optional-mcps/<name>.
"optional-mcps/linear" = ["optional-mcps/linear/manifest.yaml"]
"optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"]
[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
gateway = ["assets/**/*"]
plugins = [
"*/dashboard/manifest.json",
"*/dashboard/dist/*",
"*/dashboard/dist/**/*",
# Plugin discovery (hermes_cli/plugins.py) reads a plugin.yaml/plugin.yml
# manifest from each bundled plugin directory to register it. Wheels only
# carry files declared here, so without this glob the wheel ships every
# plugin's Python code but none of its manifests — the scan finds zero
# plugins and all gateway platforms fail with "No adapter available for
# <platform>" (#34034), web-search providers go missing (#28149), etc.
"**/plugin.yaml",
"**/plugin.yml",
"**/README.md",
]
[tool.setuptools.packages.find]
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "cron.*", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
[tool.setuptools.package-data]
# gateway/assets/ ships status_phrases.yaml and the Telegram BotFather
# screenshot. Without this, sealed venvs (uv2nix) silently lose both —
# status phrases fall back to the tiny hardcoded set and the Telegram
# topic-setup image disappears. Loaded via Path(__file__).parent / "assets"
# in gateway/status_phrases.py and gateway/run.py.
gateway = ["assets/**/*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [

View file

@ -576,7 +576,6 @@ def main(argv: list[str]) -> int:
REPO_ROOT / "plugins",
REPO_ROOT / "scripts",
REPO_ROOT / "acp_adapter",
REPO_ROOT / "acp_registry",
]
roots = [r for r in roots if r.exists()]
elif args.diff:

View file

@ -74,7 +74,7 @@ NO_SKILLS=false
BRANCH="main"
INSTALL_COMMIT=""
ENSURE_DEPS=""
POSTINSTALL_MODE=false
MANIFEST_MODE=false
STAGE_NAME=""
JSON_OUTPUT=false
@ -150,10 +150,7 @@ while [[ $# -gt 0 ]]; do
ENSURE_DEPS="$2"
shift 2
;;
--postinstall)
POSTINSTALL_MODE=true
shift
;;
-h|--help)
echo "Hermes Agent Installer"
echo ""
@ -190,9 +187,7 @@ while [[ $# -gt 0 ]]; do
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
;;
*)
@ -2584,29 +2579,6 @@ ensure_mode() {
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
ensure_browser
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
}
# Clear the cached Electron download + any half-written unpacked output so the
# next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in
@ -3150,8 +3122,6 @@ elif [ -n "$STAGE_NAME" ]; then
run_stage_protocol "$STAGE_NAME"
elif [ -n "$ENSURE_DEPS" ]; then
ensure_mode
elif [ "$POSTINSTALL_MODE" = true ]; then
postinstall_mode
else
main
fi

View file

@ -34,11 +34,6 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py"
PYPROJECT_FILE = REPO_ROOT / "pyproject.toml"
# ACP Registry manifest must stay version-locked with pyproject.toml.
# tests/acp/test_registry_manifest.py enforces this lockstep so the release
# bump touches both files atomically.
ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# ──────────────────────────────────────────────────────────────────────
# Git email → GitHub username mapping
# ──────────────────────────────────────────────────────────────────────
@ -2203,70 +2198,6 @@ def update_version_files(semver: str, calver_date: str):
)
desktop_pkg.write_text(pkg_text, encoding="utf-8")
# Update ACP Registry manifest + npm launcher (must stay version-locked
# with pyproject — enforced by tests/acp/test_registry_manifest.py).
_update_acp_registry_versions(semver)
def _update_acp_registry_versions(semver: str) -> None:
"""Bump the ACP Registry manifest's version + uvx package pin in lockstep
with pyproject.
Skips silently if the manifest is missing older release branches predate
the ACP Registry assets.
"""
if ACP_REGISTRY_MANIFEST.exists():
manifest = json.loads(ACP_REGISTRY_MANIFEST.read_text(encoding="utf-8"))
manifest["version"] = semver
uvx = manifest.get("distribution", {}).get("uvx", {})
if "package" in uvx:
uvx["package"] = f"hermes-agent[acp]=={semver}"
# Preserve trailing newline + 2-space indent the file already uses.
ACP_REGISTRY_MANIFEST.write_text(
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
)
def build_release_artifacts(semver: str) -> list[Path]:
"""Build sdist/wheel artifacts for the current release.
Tries ``uv build`` first (matching the CI workflow), falls back to
``python -m build`` if uv is unavailable.
"""
dist_dir = REPO_ROOT / "dist"
shutil.rmtree(dist_dir, ignore_errors=True)
# Prefer uv build (matches CI workflow), fall back to python -m build.
uv_bin = shutil.which("uv")
if uv_bin:
cmd = [uv_bin, "build", "--sdist", "--wheel"]
else:
cmd = [sys.executable, "-m", "build", "--sdist", "--wheel"]
result = subprocess.run(
cmd,
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
)
if result.returncode != 0:
print(" ⚠ Could not build Python release artifacts.")
stderr = result.stderr.strip()
stdout = result.stdout.strip()
if stderr:
print(f" {stderr.splitlines()[-1]}")
elif stdout:
print(f" {stdout.splitlines()[-1]}")
print(" Install uv or the 'build' package to attach sdist/wheel assets.")
return []
artifacts = sorted(p for p in dist_dir.iterdir() if p.is_file())
matching = [p for p in artifacts if semver in p.name]
if not matching:
print(" ⚠ Built artifacts did not match the expected release version.")
return []
return matching
def resolve_author(name: str, email: str) -> str:
"""Resolve a git author to a GitHub @mention."""
@ -2606,8 +2537,6 @@ def main():
# Commit version bump
add_files = [str(VERSION_FILE), str(PYPROJECT_FILE)]
if ACP_REGISTRY_MANIFEST.exists():
add_files.append(str(ACP_REGISTRY_MANIFEST))
add_result = git_result("add", *add_files)
if add_result.returncode != 0:
print(f" ✗ Failed to stage version files: {add_result.stderr.strip()}")
@ -2640,14 +2569,6 @@ def main():
print(" Continue manually after fixing access:")
print(" git push origin HEAD --tags")
# Build semver-named Python artifacts so downstream packagers
# (e.g. Homebrew) can target them without relying on CalVer tag names.
artifacts = build_release_artifacts(new_version)
if artifacts:
print(" ✓ Built release artifacts:")
for artifact in artifacts:
print(f" - {artifact.relative_to(REPO_ROOT)}")
# Create GitHub release
changelog_file = REPO_ROOT / ".release_notes.md"
changelog_file.write_text(changelog, encoding="utf-8")
@ -2657,7 +2578,6 @@ def main():
"--title", f"Hermes Agent v{new_version} ({calver_date})",
"--notes-file", str(changelog_file),
]
gh_cmd.extend(str(path) for path in artifacts)
gh_bin = shutil.which("gh")
if gh_bin:
@ -2682,9 +2602,9 @@ def main():
print(" Tag was created locally. Create the release manually:")
print(
f" gh release create {tag_name} --title 'Hermes Agent v{new_version} ({calver_date})' "
f"--notes-file .release_notes.md {' '.join(str(path) for path in artifacts)}"
f"--notes-file .release_notes.md"
)
print(f"\n ✓ Release artifacts prepared for manual publish: v{new_version} ({tag_name})")
print(f"\n ✓ Release v{new_version} ({tag_name}) prepared for manual publish.")
else:
print(f"\n{'='*60}")
print(" Dry run complete. To publish, add --publish")

149
setup.py
View file

@ -1,87 +1,74 @@
from __future__ import annotations
"""
setup.py wheel/sdist build guard.
from collections import defaultdict
from pathlib import Path
import tempfile
pip/PyPI and Homebrew are no longer supported distribution methods for
Hermes Agent (see website/docs/getting-started/platform-support.md). The
wheel would ship without bundled assets (locales, skills, optional-mcps,
web_dist, tui_dist, plugin manifests) since those are resolved at runtime
via env-var overrides set by the nix wrapper or the source-checkout layout.
This file overrides the ``bdist_wheel`` and ``sdist`` setuptools commands
to raise an error when run outside a Nix build. The PEP 517
``build_wheel`` / ``build_sdist`` hooks in
``setuptools.build_meta`` call these commands internally, so the guard
fires for ``uv build``, ``pip wheel``, ``python -m build``, and direct
``setup.py`` invocations alike.
The one legitimate consumer of ``build_wheel`` is uv2nix, which calls
``setuptools.build_meta.build_wheel`` ( ``bdist_wheel``) inside a Nix
build sandbox. ``nix/python.nix`` sets ``HERMES_NIX_BUILD=1`` on the
Hermes package derivation, so only that build may create an artifact.
Editable installs (``uv sync``, ``pip install -e .``, ``nix develop``)
use ``build_editable``, which does NOT call ``bdist_wheel`` it calls
``build_ext`` in editable mode. So the guard does not affect development.
"""
import os
from setuptools import setup
from setuptools.command.build import build as _build
from setuptools.command.egg_info import egg_info as _egg_info
from setuptools.command.sdist import sdist
_IN_NIX_BUILD = os.environ.get("HERMES_NIX_BUILD") == "1"
REPO_ROOT = Path(__file__).parent.resolve()
def _source_tree_is_writable() -> bool:
probe = REPO_ROOT / ".setuptools-write-probe"
try:
with probe.open("w", encoding="utf-8") as handle:
handle.write("")
probe.unlink()
except OSError:
try:
probe.unlink(missing_ok=True)
except OSError:
pass
return False
return True
def _temporary_build_dir(kind: str) -> str:
return tempfile.mkdtemp(prefix=f"hermes-agent-{kind}-")
def _would_write_under_source(path_value: str | None) -> bool:
if path_value is None:
return True
path = Path(path_value)
if not path.is_absolute():
path = REPO_ROOT / path
try:
path.resolve().relative_to(REPO_ROOT)
except ValueError:
return False
return True
class ReadOnlySourceBuild(_build):
def finalize_options(self) -> None:
if (
not _source_tree_is_writable()
and _would_write_under_source(self.build_base)
):
self.build_base = _temporary_build_dir("build")
super().finalize_options()
class ReadOnlySourceEggInfo(_egg_info):
def finalize_options(self) -> None:
if (
not _source_tree_is_writable()
and _would_write_under_source(self.egg_base)
):
self.egg_base = _temporary_build_dir("egg-info")
super().finalize_options()
def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]:
root = REPO_ROOT / root_name
grouped: defaultdict[str, list[str]] = defaultdict(list)
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
rel_path = path.relative_to(REPO_ROOT)
grouped[str(rel_path.parent)].append(str(rel_path))
return sorted(grouped.items())
setup(
cmdclass={
"build": ReadOnlySourceBuild,
"egg_info": ReadOnlySourceEggInfo,
},
data_files=[
*_data_file_tree("skills"),
*_data_file_tree("optional-skills"),
]
_BLOCK_MESSAGE = (
"Building wheels or sdists for hermes-agent is not supported.\n"
"Hermes is distributed via the shell installer, Docker image, or Nix.\n"
"See: https://hermes-agent.nousresearch.com/docs/getting-started/installation\n"
"\n"
"If you are developing, use an editable install instead:\n"
" uv sync # or: uv pip install -e .\n"
"\n"
"If you are building with Nix (uv2nix), this error should not fire —\n"
"the Hermes Nix derivation sets HERMES_NIX_BUILD=1. If it does, file a bug."
)
class _GuardedSdist(sdist):
def run(self, *args, **kwargs):
if not _IN_NIX_BUILD:
raise RuntimeError(_BLOCK_MESSAGE)
return super().run(*args, **kwargs)
cmdclass = {"sdist": _GuardedSdist}
# bdist_wheel is only available when the `wheel` package is installed.
# setuptools.build_meta.build_wheel() calls it internally, so the guard
# fires for all PEP 517 wheel build paths. Define the subclass only when
# the import succeeds — otherwise a None base class raises TypeError at
# class-definition time, before the cmdclass guard can run.
try:
from setuptools.command.bdist_wheel import bdist_wheel
class _GuardedBdistWheel(bdist_wheel):
def run(self, *args, **kwargs):
if not _IN_NIX_BUILD:
raise RuntimeError(_BLOCK_MESSAGE)
return super().run(*args, **kwargs)
cmdclass["bdist_wheel"] = _GuardedBdistWheel
except ImportError:
pass
setup(cmdclass=cmdclass)

View file

@ -48,9 +48,6 @@ Good verification targets:
# Install (shell installer — sets up uv, Python, the venv, and the launcher)
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
# Or via PyPI (ships the TUI bundle + shell launcher)
pip install hermes-agent # or: uv pip install hermes-agent
# Interactive chat (default surface; set display.interface: tui to launch the Ink TUI instead)
hermes

View file

@ -141,7 +141,7 @@ def install_deps():
"On environments without pip (e.g. Nix, or the Hermes Docker image's "
"uv-managed venv), install the optional extra instead:"
)
print(" pip install 'hermes-agent[google]'")
print(" hermes setup")
print(f"Or manually: {sys.executable} -m pip install {' '.join(REQUIRED_PACKAGES)}")
return False

View file

@ -1,90 +0,0 @@
"""Tests for ACP Registry metadata shipped with Hermes."""
from __future__ import annotations
import json
import re
import tomllib
from pathlib import Path
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[2]
MANIFEST = ROOT / "acp_registry" / "agent.json"
ICON = ROOT / "acp_registry" / "icon.svg"
FORBIDDEN_MANIFEST_KEYS = {"schema_version", "display_name"}
ALLOWED_DISTRIBUTIONS = {"binary", "npx", "uvx"}
def _manifest() -> dict:
return json.loads(MANIFEST.read_text(encoding="utf-8"))
def _pyproject_version() -> str:
data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
return data["project"]["version"]
def test_agent_json_matches_official_registry_required_fields():
data = _manifest()
assert FORBIDDEN_MANIFEST_KEYS.isdisjoint(data)
assert data["id"] == "hermes-agent"
assert re.fullmatch(r"[a-z][a-z0-9-]*", data["id"])
assert data["name"] == "Hermes Agent"
assert data["description"]
assert data["repository"] == "https://github.com/NousResearch/hermes-agent"
assert data["website"].startswith("https://hermes-agent.nousresearch.com/")
assert data["authors"] == ["Nous Research"]
assert data["license"] == "MIT"
assert set(data["distribution"]) <= ALLOWED_DISTRIBUTIONS
def test_agent_json_uses_uvx_distribution_without_local_command_fields():
data = _manifest()
assert set(data["distribution"]) == {"uvx"}
uvx = data["distribution"]["uvx"]
# Schema allows {package, args, env}; we use {package, args}.
assert set(uvx) <= {"package", "args", "env"}
assert "package" in uvx
assert uvx["package"] == f"hermes-agent[acp]=={data['version']}"
assert uvx["args"] == ["hermes-acp"]
# Old command-shape fields must not leak back in.
assert "type" not in data["distribution"]
assert "command" not in data["distribution"]
def test_agent_json_version_matches_pyproject():
assert _manifest()["version"] == _pyproject_version()
def test_agent_json_pins_uvx_package_to_pyproject_version():
"""The registry CI rejects ``@latest`` and floating pins; the manifest must
always reference the exact PyPI version listed in pyproject.toml."""
assert _manifest()["distribution"]["uvx"]["package"] == (
f"hermes-agent[acp]=={_pyproject_version()}"
)
def test_icon_svg_is_16x16_current_color():
root = ET.fromstring(ICON.read_text(encoding="utf-8"))
assert root.attrib["viewBox"] == "0 0 16 16"
assert root.attrib["width"] == "16"
assert root.attrib["height"] == "16"
def test_icon_svg_has_no_hardcoded_colors_or_gradients():
text = ICON.read_text(encoding="utf-8")
assert "linearGradient" not in text
assert "radialGradient" not in text
assert "url(#" not in text
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", text)
root = ET.fromstring(text)
for element in root.iter():
for attr in ("fill", "stroke"):
value = element.attrib.get(attr)
if value is not None:
assert value in {"currentColor", "none"}

View file

@ -193,34 +193,6 @@ def test_locales_dir_env_override_ignored_when_missing(tmp_path, monkeypatch):
assert result.name == "locales"
def test_locales_dir_falls_back_to_data_scheme(tmp_path, monkeypatch):
"""When neither the env override nor a source-adjacent locales/ exists,
_locales_dir uses sysconfig's data scheme (the pip-wheel layout)."""
import sysconfig
# No env override.
monkeypatch.delenv("HERMES_BUNDLED_LOCALES", raising=False)
# Force the source-adjacent path to a location with no locales/ dir.
fake_pkg = tmp_path / "site-packages" / "agent"
fake_pkg.mkdir(parents=True)
monkeypatch.setattr(i18n, "__file__", str(fake_pkg / "i18n.py"))
# Stand up a fake data scheme containing locales/.
data_root = tmp_path / "data-scheme"
(data_root / "locales").mkdir(parents=True)
real_get_path = sysconfig.get_path
def fake_get_path(name, *args, **kwargs):
if name == "data":
return str(data_root)
return real_get_path(name, *args, **kwargs)
monkeypatch.setattr(i18n.sysconfig, "get_path", fake_get_path)
assert i18n._locales_dir() == data_root / "locales"
def test_t_resolves_real_string_in_source_checkout():
"""Sanity: in the test environment (a source checkout) t() must return a
human string, never the bare key path. Guards against catalog-load

View file

@ -76,13 +76,13 @@ def test_managed_install_refuses_and_does_not_set_pending_relaunch(capsys):
patch("hermes_cli.config.is_managed", return_value=True),
patch(
"hermes_cli.config.format_managed_message",
return_value="Use `brew upgrade hermes-agent` to update.",
return_value="Use `sudo nixos-rebuild switch` to update.",
),
):
result = _call(self_)
out = capsys.readouterr().out
assert "brew upgrade hermes-agent" in out
assert "sudo nixos-rebuild switch" in out
assert self_._pending_relaunch is None
assert not result

View file

@ -45,23 +45,6 @@ def _make_runner():
class TestHandleUpdateCommand:
"""Tests for GatewayRunner._handle_update_command."""
@pytest.mark.asyncio
async def test_managed_install_returns_package_manager_guidance(self, monkeypatch):
runner = _make_runner()
event = _make_event()
monkeypatch.setenv("HERMES_MANAGED", "homebrew")
# Guard: prevent any accidental fall-through from spawning a real
# `hermes update --gateway` against the CI checkout. The managed-install
# guard should return before Popen is ever reached, but mock it as
# belt-and-suspenders so a premature return doesn't corrupt the repo.
with patch("subprocess.Popen") as mock_popen:
result = await runner._handle_update_command(event)
assert "managed by Homebrew" in result
assert "brew upgrade hermes-agent" in result
mock_popen.assert_not_called() # must return before reaching Popen
@pytest.mark.asyncio
async def test_no_git_directory(self, tmp_path):
"""Returns an error when .git does not exist."""

View file

@ -1,35 +0,0 @@
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

@ -255,45 +255,6 @@ class TestCmdUpdateNpmLockfileCache:
assert cache_roots == [shared_root, shared_root]
class TestCmdUpdatePip:
"""Regression tests for pip-install update flows."""
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_exports_virtualenv_from_sys_prefix(
self, mock_run, _mock_which, mock_args, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/tmp/hermes-launcher-venv")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
hm._cmd_update_pip(mock_args)
assert mock_run.call_count == 1
assert mock_run.call_args.args[0] == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/tmp/hermes-launcher-venv"
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_does_not_export_virtualenv_for_system_python(
self, mock_run, _mock_which, mock_args, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/usr")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
hm._cmd_update_pip(mock_args)
assert mock_run.call_count == 1
assert "env" not in mock_run.call_args.kwargs
class TestCmdUpdateTermuxUvBootstrap:
"""Regression tests for Termux-specific uv bootstrap behavior."""
@ -989,21 +950,6 @@ class TestCmdUpdateCheckBranchFlag:
rev_list_cmds = [c for c in commands if "rev-list" in c]
assert any("upstream/main" in c for c in rev_list_cmds), rev_list_cmds
@patch("hermes_cli.config.detect_install_method", return_value="pip")
@patch("hermes_cli.banner.check_via_pypi", return_value=0)
@patch("subprocess.run")
def test_check_branch_warns_on_pypi_install(
self, mock_run, _mock_pypi, _mock_method, capsys
):
"""PyPI install + --branch=<non-main> surfaces a warning instead of silent drop."""
args = SimpleNamespace(check=True, branch="bb/gui")
cmd_update(args)
out = capsys.readouterr().out
assert "--branch is ignored for PyPI installs" in out
assert "bb/gui" in out
class TestCmdUpdateZipBranchRefusal:
"""``hermes update --branch=<non-main>`` must refuse on the ZIP fallback path.

View file

@ -143,19 +143,6 @@ def test_cmd_update_on_git_install_does_not_print_docker_message(
assert "doesn't apply inside the Docker container" not in capsys.readouterr().out
@patch("hermes_cli.config.detect_install_method", return_value="pip")
@patch("hermes_cli.banner.check_via_pypi", return_value=0)
def test_cmd_update_check_on_pip_install_still_uses_pypi(
_mock_pypi, _mock_method, capsys
):
"""PyPI installs route to PyPI check, not the Docker bail-out."""
_cmd_update_check()
out = capsys.readouterr().out
assert "Already up to date" in out
assert "doesn't apply inside the Docker container" not in out
# ---------- format_docker_update_message — content lock ----------

View file

@ -350,7 +350,7 @@ def test_console_registry_covers_non_admin_cli_surface():
"oneshot hello",
"model",
"setup",
"postinstall",
"fallback add",
"moa configure",
"claw migrate",

View file

@ -1,31 +1,11 @@
from types import SimpleNamespace
from unittest.mock import patch
from hermes_cli.config import (
format_managed_message,
get_managed_system,
recommended_update_command,
)
from hermes_cli.config import recommended_update_command
from hermes_cli.main import cmd_update
from tools.skills_hub import OptionalSkillSource
def test_get_managed_system_homebrew(monkeypatch):
monkeypatch.setenv("HERMES_MANAGED", "homebrew")
assert get_managed_system() == "Homebrew"
assert recommended_update_command() == "brew upgrade hermes-agent"
def test_format_managed_message_homebrew(monkeypatch):
monkeypatch.setenv("HERMES_MANAGED", "homebrew")
message = format_managed_message("update Hermes Agent")
assert "managed by Homebrew" in message
assert "brew upgrade hermes-agent" in message
def test_recommended_update_command_defaults_to_hermes_update(monkeypatch):
monkeypatch.delenv("HERMES_MANAGED", raising=False)
@ -39,18 +19,6 @@ def test_recommended_update_command_defaults_to_hermes_update(monkeypatch):
assert recommended_update_command() == "hermes update"
def test_cmd_update_blocks_managed_homebrew(monkeypatch, capsys):
monkeypatch.setenv("HERMES_MANAGED", "homebrew")
with patch("hermes_cli.main.subprocess.run") as mock_run:
cmd_update(SimpleNamespace())
assert not mock_run.called
captured = capsys.readouterr()
assert "managed by Homebrew" in captured.err
assert "brew upgrade hermes-agent" in captured.err
def test_optional_skill_source_honors_env_override(monkeypatch, tmp_path):
optional_dir = tmp_path / "optional-skills"
optional_dir.mkdir()

View file

@ -1,13 +1,15 @@
from unittest.mock import patch
import pytest
def test_pip_install_detected_when_no_git_dir(tmp_path):
"""When PROJECT_ROOT has no .git, detect as pip install."""
def test_unknown_install_detected_when_no_git_dir(tmp_path):
"""When PROJECT_ROOT has no .git, detect as 'unknown' (not 'pip')."""
with patch("hermes_cli.config.get_managed_system", return_value=None), \
patch("hermes_cli.config.get_hermes_home", return_value=tmp_path):
from hermes_cli.config import detect_install_method
method = detect_install_method(project_root=tmp_path)
assert method == "pip"
assert method == "unknown"
def test_git_install_detected_when_git_dir_exists(tmp_path):
@ -30,15 +32,6 @@ def test_managed_install_takes_precedence(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
def test_stamp_file_takes_precedence(tmp_path):
(tmp_path / ".git").mkdir()
(tmp_path / ".install_method").write_text("docker\n")
@ -48,6 +41,30 @@ def test_stamp_file_takes_precedence(tmp_path):
assert detect_install_method(project_root=tmp_path) == "docker"
@pytest.mark.parametrize("retired_method", ["pip", "homebrew"])
def test_code_scoped_retired_stamp_falls_back_to_unknown(tmp_path, retired_method):
"""Removed install methods must not survive in an upgraded code stamp."""
(tmp_path / ".install_method").write_text(retired_method + "\n")
with patch("hermes_cli.config.get_managed_system", return_value=None), \
patch("hermes_cli.config.get_hermes_home", return_value=tmp_path):
from hermes_cli.config import detect_install_method
assert detect_install_method(project_root=tmp_path) == "unknown"
@pytest.mark.parametrize("retired_method", ["pip", "homebrew"])
def test_home_scoped_retired_stamp_falls_back_to_unknown(tmp_path, retired_method):
"""Removed install methods must not survive in an upgraded home stamp."""
code = tmp_path / "code"
home = tmp_path / "home"
code.mkdir()
home.mkdir()
(home / ".install_method").write_text(retired_method + "\n")
with patch("hermes_cli.config.get_managed_system", return_value=None), \
patch("hermes_cli.config.get_hermes_home", return_value=home):
from hermes_cli.config import detect_install_method
assert detect_install_method(project_root=code) == "unknown"
def test_code_scoped_stamp_wins_over_home_stamp(tmp_path):
"""The stamp next to the running code is authoritative over $HERMES_HOME.
@ -111,7 +128,7 @@ def test_home_non_docker_stamp_still_honored_for_backcompat(tmp_path):
"""Legacy non-'docker' home stamps (e.g. 'git') are still respected.
Only the 'docker' value carries the cross-contamination risk, so a host
install that historically stamped 'git'/'pip' into $HERMES_HOME keeps
install that historically stamped 'git' into $HERMES_HOME keeps
resolving from there when no code-scoped stamp exists yet.
"""
code = tmp_path / "code"
@ -134,8 +151,8 @@ def test_stamp_install_method_writes_code_scoped(tmp_path):
home.mkdir()
with patch("hermes_cli.config.get_hermes_home", return_value=home):
from hermes_cli.config import stamp_install_method
stamp_install_method("pip", project_root=code)
assert (code / ".install_method").read_text().strip() == "pip"
stamp_install_method("git", project_root=code)
assert (code / ".install_method").read_text().strip() == "git"
assert not (home / ".install_method").exists()
@ -158,13 +175,13 @@ def test_container_without_stamp_is_not_docker(tmp_path):
assert detect_install_method(project_root=tmp_path) == "git"
def test_container_pip_install_without_stamp_is_pip(tmp_path):
"""Container + no .git + no stamp -> pip, not docker (issue #34397)."""
def test_container_unknown_install_without_stamp_is_unknown(tmp_path):
"""Container + no .git + no stamp -> unknown, not docker (issue #34397)."""
with patch("hermes_cli.config.get_managed_system", return_value=None), \
patch("hermes_cli.config.get_hermes_home", return_value=tmp_path), \
patch("hermes_constants.is_container", return_value=True):
from hermes_cli.config import detect_install_method
assert detect_install_method(project_root=tmp_path) == "pip"
assert detect_install_method(project_root=tmp_path) == "unknown"
def test_recommended_update_command_docker():
@ -172,77 +189,21 @@ def test_recommended_update_command_docker():
assert "docker pull" in recommended_update_command_for_method("docker")
def test_banner_warns_on_pip_install(tmp_path):
"""The welcome banner surfaces a warning when the install method is pip."""
import io
from rich.console import Console
from hermes_cli import banner
def test_nix_store_path_detected_as_nixos(tmp_path, monkeypatch):
"""A code path under /nix/store/ (nix run / nix profile install) is detected
as 'nixos' even without HERMES_MANAGED or a .install_method stamp."""
# detect_install_method checks whether the resolved root is a descendant
# of _NIX_STORE (Path("/nix/store")). We can't create files under the real
# /nix/store, so patch the constant to point at a temp dir and create the
# fake install path under it.
fake_nix_store = tmp_path / "fake-nix-store"
fake_nix_store.mkdir(parents=True)
fake_nix = fake_nix_store / "abc123-hermes-agent-0.19.0"
fake_nix.mkdir(parents=True)
hh = tmp_path / ".hermes"
hh.mkdir()
(hh / ".install_method").write_text("pip\n")
monkeypatch.setattr("hermes_cli.config._NIX_STORE", fake_nix_store)
with patch("hermes_cli.config.get_hermes_home", return_value=hh), \
patch("hermes_constants.get_hermes_home", return_value=hh):
buf = io.StringIO()
# Wide console so the warning isn't wrapped across lines in the panel.
console = Console(file=buf, width=400, force_terminal=False, color_system=None)
banner.build_welcome_banner(
console, model="m", cwd="/tmp",
tools=[{"function": {"name": "terminal"}}],
enabled_toolsets=["terminal"],
)
out = buf.getvalue()
assert "officially" in out
assert "platform-support" in out
def test_banner_warns_on_homebrew_install(tmp_path):
"""The welcome banner surfaces a warning when the install method is homebrew."""
import io
from rich.console import Console
from hermes_cli import banner
hh = tmp_path / ".hermes"
hh.mkdir()
(hh / ".install_method").write_text("homebrew\n")
with patch("hermes_cli.config.get_hermes_home", return_value=hh), \
patch("hermes_constants.get_hermes_home", return_value=hh):
buf = io.StringIO()
console = Console(file=buf, width=400, force_terminal=False, color_system=None)
banner.build_welcome_banner(
console, model="m", cwd="/tmp",
tools=[{"function": {"name": "terminal"}}],
enabled_toolsets=["terminal"],
)
out = buf.getvalue()
assert "officially" in out
assert "Homebrew" in out
assert "platform-support" in out
def test_banner_no_pip_warning_on_git_install(tmp_path):
"""Git installs must not show the pip-install warning."""
import io
from rich.console import Console
from hermes_cli import banner
hh = tmp_path / ".hermes"
hh.mkdir()
(hh / ".install_method").write_text("git\n")
with patch("hermes_cli.config.get_hermes_home", return_value=hh), \
patch("hermes_constants.get_hermes_home", return_value=hh):
buf = io.StringIO()
console = Console(file=buf, width=400, force_terminal=False, color_system=None)
banner.build_welcome_banner(
console, model="m", cwd="/tmp",
tools=[{"function": {"name": "terminal"}}],
enabled_toolsets=["terminal"],
)
out = buf.getvalue()
assert "officially" not in out
with patch("hermes_cli.config.get_managed_system", return_value=None), \
patch("hermes_cli.config.get_hermes_home", return_value=tmp_path):
from hermes_cli.config import detect_install_method
assert detect_install_method(project_root=fake_nix) == "nixos"

View file

@ -27,7 +27,7 @@ from hermes_cli.subcommands.login import build_login_parser
from hermes_cli.subcommands.logout import build_logout_parser
from hermes_cli.subcommands.logs import build_logs_parser
from hermes_cli.subcommands.model import build_model_parser
from hermes_cli.subcommands.postinstall import build_postinstall_parser
from hermes_cli.subcommands.prompt_size import build_prompt_size_parser
from hermes_cli.subcommands.security import build_security_parser
from hermes_cli.subcommands.setup import build_setup_parser
@ -51,7 +51,7 @@ def _h(name):
SINGLE_HANDLER_CASES = [
("model", build_model_parser, "cmd_model", ["model"]),
("setup", build_setup_parser, "cmd_setup", ["setup"]),
("postinstall", build_postinstall_parser, "cmd_postinstall", ["postinstall"]),
("whatsapp", build_whatsapp_parser, "cmd_whatsapp", ["whatsapp"]),
("slack", build_slack_parser, "cmd_slack", ["slack"]),
("login", build_login_parser, "cmd_login", ["login"]),

View file

@ -60,13 +60,12 @@ def test_check_for_updates_invalidates_on_version_change(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_REVISION", raising=False)
with patch("hermes_cli.banner.subprocess.run") as mock_run, \
patch("hermes_cli.banner.check_via_pypi", return_value=0) as mock_pypi:
with patch("hermes_cli.banner.subprocess.run") as mock_run:
result = banner.check_for_updates()
# Stale-version cache rejected -> fresh check ran -> up-to-date result.
assert result == 0
mock_pypi.assert_called_once()
# Stale-version cache rejected -> fresh check ran. No git checkout and no
# embedded rev means we can't determine update status, so result is None.
assert result is None
mock_run.assert_not_called()
# Cache rewritten with the current installed version.
@ -223,7 +222,7 @@ def test_check_via_local_git_full_clone_keeps_exact_count(tmp_path):
def test_check_for_updates_no_git_dir(tmp_path, monkeypatch):
"""Falls back to PyPI check when .git directory doesn't exist anywhere."""
"""Returns None when .git directory doesn't exist anywhere (no source tree)."""
import hermes_cli.banner as banner
# Create a fake banner.py so the fallback path also has no .git
@ -234,9 +233,8 @@ 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:
with patch("hermes_cli.banner.check_via_pypi", return_value=0):
result = banner.check_for_updates()
assert result == 0
result = banner.check_for_updates()
assert result is None
mock_run.assert_not_called()
@ -262,12 +260,9 @@ def test_check_for_updates_docker_returns_none(tmp_path, monkeypatch):
Regression: the published image excludes .git (.dockerignore) and sets no
HERMES_REVISION (nix-only), so without a docker guard check_for_updates()
falls through to check_via_pypi(), whose version-mismatch flag (1) gets
rendered by both the Rich banner and the Ink TUI badge as a phantom
"1 commit behind" despite there being no git repo or commit math in the
container, and `hermes update` correctly refusing to run there. The guard
would fall through and try to probe a non-existent git checkout. The guard
must return None (so the > 0 render guards stay false) AND not reach the
git/pypi probes or write a cache entry.
git probe or write a cache entry.
"""
import hermes_cli.banner as banner
@ -275,44 +270,16 @@ def test_check_for_updates_docker_returns_none(tmp_path, monkeypatch):
cache_file = tmp_path / ".update_check"
with patch("hermes_cli.config.detect_install_method", return_value="docker"), \
patch("hermes_cli.banner.subprocess.run") as mock_run, \
patch("hermes_cli.banner.check_via_pypi") as mock_pypi:
patch("hermes_cli.banner.subprocess.run") as mock_run:
result = banner.check_for_updates()
assert result is None
# Neither the git probe nor the PyPI probe should have run.
# The git probe should not have run.
mock_run.assert_not_called()
mock_pypi.assert_not_called()
# And no phantom "behind" count should be cached for the next 6h.
assert not cache_file.exists()
def test_check_for_updates_non_docker_still_checks(tmp_path, monkeypatch):
"""The docker guard must NOT over-broaden: a pip install still version-checks.
Invariant guarding against the guard firing for non-docker methods pip
installs legitimately reach check_via_pypi() and surface a real update.
"""
import hermes_cli.banner as banner
# No local git checkout -> the PyPI (pip-install) path is exercised.
fake_banner = tmp_path / "hermes_cli" / "banner.py"
fake_banner.parent.mkdir(parents=True, exist_ok=True)
fake_banner.touch()
monkeypatch.setattr(banner, "__file__", str(fake_banner))
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_REVISION", raising=False)
with patch("hermes_cli.config.detect_install_method", return_value="pip"), \
patch("hermes_cli.banner.subprocess.run") as mock_run, \
patch("hermes_cli.banner.check_via_pypi", return_value=1) as mock_pypi:
result = banner.check_for_updates()
assert result == 1
mock_pypi.assert_called_once()
mock_run.assert_not_called()
def test_prefetch_non_blocking():
"""prefetch_update_check() should return immediately without blocking."""
import hermes_cli.banner as banner

View file

@ -1,342 +0,0 @@
"""Tests for uv-tool install detection in the update path (issue #29700).
``uv tool install hermes-agent`` lives outside any venv, so the previous
``uv pip install --upgrade`` update path failed with ``No virtual
environment found``. ``is_uv_tool_install`` should detect this layout and
both the user-facing recommended command and the actual
``_cmd_update_pip`` subprocess invocation should switch to
``uv tool upgrade hermes-agent``.
Detection is restricted to properties of the running interpreter
(``sys.prefix`` / ``sys.executable``) so a pip/venv install on a machine
that also has ``uv tool install hermes-agent`` does not get misclassified.
"""
from __future__ import annotations
import subprocess
from types import SimpleNamespace
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Managed-uv compatibility for tests that patch shutil.which
# ---------------------------------------------------------------------------
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
# instead of ``shutil.which("uv")``. Many tests in this file patch
# ``shutil.which`` to control whether uv is "available" — these autouse
# fixtures make the managed_uv functions delegate to the patched
# ``shutil.which`` so the existing test setup keeps working without
# per-test changes.
@pytest.fixture(autouse=True)
def _patch_managed_uv(request):
"""Make managed_uv helpers follow shutil.which mocking in tests."""
import shutil
# resolve_uv delegates to shutil.which("uv") so that test patches
# on shutil.which flow through naturally.
def _fake_resolve_uv():
return shutil.which("uv")
def _fake_ensure_uv():
return shutil.which("uv")
def _fake_update_managed_uv():
return None # never actually self-update in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv):
yield
# ---------------------------------------------------------------------------
# is_uv_tool_install
# ---------------------------------------------------------------------------
class TestIsUvToolInstall:
def test_returns_true_when_sys_prefix_matches_uv_tool_layout(self):
from hermes_cli import config
with patch.object(config.sys, "prefix", "/home/user/.local/share/uv/tools/hermes-agent"):
assert config.is_uv_tool_install() is True
def test_returns_true_when_sys_executable_matches_uv_tool_layout(self):
"""Some uv-tool layouts surface the marker on ``sys.executable`` (bin/python)."""
from hermes_cli import config
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
patch.object(
config.sys,
"executable",
"/home/user/.local/share/uv/tools/hermes-agent/bin/python",
):
assert config.is_uv_tool_install() is True
def test_returns_false_when_neither_prefix_nor_executable_matches(self):
from hermes_cli import config
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
patch.object(config.sys, "executable", "/usr/bin/python3"):
assert config.is_uv_tool_install() is False
def test_does_not_consult_uv_tool_list(self):
"""Detection must NOT shell out: ``uv tool list`` would false-positive
when the active install is pip/venv but the machine also has
``uv tool install hermes-agent`` somewhere on disk. Copilot review on
PR #29703 flagged this; the fix is to never call ``uv tool list``
from the detection path."""
from hermes_cli import config
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
patch.object(config.sys, "executable", "/usr/bin/python3"), \
patch("subprocess.run") as mock_run:
assert config.is_uv_tool_install() is False
mock_run.assert_not_called()
def test_case_insensitive_match(self):
"""Match must be case-insensitive — Windows paths preserve case
(e.g. ``...AppData\\Local\\UV\\Tools\\hermes-agent``) and a case-sensitive
check would miss them. We exercise the lower-cased compare path here
without monkey-patching ``os.sep``, which would break the whole suite."""
from hermes_cli import config
with patch.object(
config.sys, "prefix", "/HOME/USER/.local/share/UV/Tools/hermes-agent"
):
assert config.is_uv_tool_install() is True
def test_handles_empty_executable(self):
from hermes_cli import config
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
patch.object(config.sys, "executable", ""):
assert config.is_uv_tool_install() is False
# ---------------------------------------------------------------------------
# recommended_update_command_for_method
# ---------------------------------------------------------------------------
class TestRecommendedUpdateCommandForUvTool:
def test_uv_tool_install_recommends_uv_tool_upgrade(self):
from hermes_cli import config
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch.object(config, "is_uv_tool_install", return_value=True):
cmd = config.recommended_update_command_for_method("pip")
assert cmd == "uv tool upgrade hermes-agent"
def test_uv_tool_install_recommends_uv_tool_upgrade_even_without_uv_on_path(self):
"""Recommendation reflects the *install method*, not whether ``uv`` is
currently on PATH the user needs to know the right command to run."""
from hermes_cli import config
with patch("shutil.which", return_value=None), \
patch.object(config, "is_uv_tool_install", return_value=True):
cmd = config.recommended_update_command_for_method("pip")
assert cmd == "uv tool upgrade hermes-agent"
def test_uv_pip_install_keeps_legacy_recommendation(self):
"""Existing behavior: uv is on PATH but Hermes is a regular pip install."""
from hermes_cli import config
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch.object(config, "is_uv_tool_install", return_value=False):
cmd = config.recommended_update_command_for_method("pip")
assert cmd == "uv pip install --upgrade hermes-agent"
def test_no_uv_falls_back_to_plain_pip(self):
from hermes_cli import config
with patch("shutil.which", return_value=None), \
patch.object(config, "is_uv_tool_install", return_value=False):
cmd = config.recommended_update_command_for_method("pip")
assert cmd == "pip install --upgrade hermes-agent"
def test_recommendation_does_not_spawn_subprocess(self):
"""Computing the recommendation string must be cheap — no ``uv tool list``
spawn. Copilot review on PR #29703 flagged the prior subprocess hop
as adding overhead and a multi-second timeout window for what is
purely a display string."""
from hermes_cli import config
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
patch.object(config.sys, "executable", "/usr/bin/python3"), \
patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch("subprocess.run") as mock_run:
cmd = config.recommended_update_command_for_method("pip")
mock_run.assert_not_called()
assert cmd == "uv pip install --upgrade hermes-agent"
# ---------------------------------------------------------------------------
# _cmd_update_pip subprocess command
# ---------------------------------------------------------------------------
class TestCmdUpdatePipUsesUvTool:
@patch("subprocess.run")
def test_runs_uv_tool_upgrade_when_uv_tool_install(self, mock_run):
"""The actual subprocess invocation must switch to ``uv tool upgrade``."""
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="")
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=True):
_cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == ["/usr/local/bin/uv", "tool", "upgrade", "hermes-agent"]
@patch("subprocess.run")
def test_runs_uv_pip_install_when_not_uv_tool(self, mock_run):
"""Existing behavior preserved when uv is present but Hermes isn't a tool install."""
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="")
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
_cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == [
"/usr/local/bin/uv",
"pip",
"install",
"--upgrade",
"hermes-agent",
]
@patch("subprocess.run")
def test_falls_back_to_pip_when_no_uv(self, mock_run):
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["pip"], 0, stdout="", stderr="")
with patch("shutil.which", return_value=None), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
_cmd_update_pip(SimpleNamespace())
cmd = mock_run.call_args[0][0]
assert cmd[1:] == ["-m", "pip", "install", "--upgrade", "hermes-agent"]
@patch("subprocess.run")
def test_exits_nonzero_on_subprocess_failure(self, mock_run):
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["uv"], 1, stdout="", stderr="")
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=True):
with pytest.raises(SystemExit) as exc_info:
_cmd_update_pip(SimpleNamespace())
assert exc_info.value.code == 1
@patch("subprocess.run")
def test_uv_tool_install_without_uv_on_path_exits_with_hint(self, mock_run):
"""If the running interpreter looks like a uv-tool install but ``uv`` is
somehow missing from PATH, surface a clear hint instead of silently
falling back to ``python -m pip``, which would either fail (no venv)
or upgrade the wrong copy."""
from hermes_cli.main import _cmd_update_pip
with patch("shutil.which", return_value=None), \
patch("hermes_cli.config.is_uv_tool_install", return_value=True):
with pytest.raises(SystemExit) as exc_info:
_cmd_update_pip(SimpleNamespace())
assert exc_info.value.code == 1
mock_run.assert_not_called()
# ---------------------------------------------------------------------------
# pipx-managed installs, --system fallback, and VIRTUAL_ENV overlay
# (issue #29700 / #35031 family — consolidated update-path handling)
# ---------------------------------------------------------------------------
class TestCmdUpdatePipInstallLayouts:
"""The uv pip path must adapt to where the running interpreter lives:
- inside a venv (launcher shim) -> export VIRTUAL_ENV, no ``--system``
- bare pip outside any venv -> add ``--system``, no overlay
- pipx-managed -> ``pipx upgrade``
"""
@patch("subprocess.run")
def test_pipx_managed_uses_pipx_upgrade(self, mock_run, monkeypatch):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.setattr(hm.sys, "prefix", "/home/u/.local/pipx/venvs/hermes-agent")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
def _which(name):
return {"uv": "/usr/bin/uv", "pipx": "/usr/bin/pipx"}.get(name)
with patch("shutil.which", side_effect=_which), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
hm._cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == ["/usr/bin/pipx", "upgrade", "hermes-agent"]
# pipx upgrade ignores VIRTUAL_ENV; we must not set it.
assert "env" not in mock_run.call_args.kwargs
@patch("subprocess.run")
def test_pipx_layout_without_pipx_binary_treated_as_venv(
self, mock_run, monkeypatch
):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.setattr(hm.sys, "prefix", "/home/u/.local/pipx/venvs/hermes-agent")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
# pipx layout detected via prefix, but pipx binary missing on PATH.
def _which(name):
return "/usr/bin/uv" if name == "uv" else None
with patch("shutil.which", side_effect=_which), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
hm._cmd_update_pip(SimpleNamespace())
# prefix != base_prefix, so this is treated as a venv -> overlay, no --system.
assert mock_run.call_args[0][0] == [
"/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent",
]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"].endswith("hermes-agent")
@patch("subprocess.run")
def test_bare_pip_outside_venv_adds_system(self, mock_run, monkeypatch):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
# No venv: prefix == base_prefix.
monkeypatch.setattr(hm.sys, "prefix", "/usr")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
with patch("shutil.which", return_value="/usr/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
hm._cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == [
"/usr/bin/uv", "pip", "install", "--system", "--upgrade", "hermes-agent",
]
assert "env" not in mock_run.call_args.kwargs
@patch("subprocess.run")
def test_venv_exports_virtualenv_and_omits_system(self, mock_run, monkeypatch):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/home/u/.hermes/hermes-agent/venv")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
with patch("shutil.which", return_value="/usr/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
hm._cmd_update_pip(SimpleNamespace())
cmd = mock_run.call_args[0][0]
assert "--system" not in cmd
assert cmd == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/home/u/.hermes/hermes-agent/venv"

View file

@ -1,113 +0,0 @@
"""Tests for the ACP Registry version-lockstep bump in scripts/release.py.
The official ACP Registry manifest must match ``pyproject.toml`` exactly
``tests/acp/test_registry_manifest.py`` enforces this at lint time, and the
upstream registry CI rejects ``@latest`` / floating pins. The release script
is the single place that bumps the manifest in lockstep with pyproject; if
that bump ever silently breaks, weekly releases fail the manifest test
until someone hand-edits the JSON.
"""
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
def _load_release_module(monkeypatch, tmp_root: Path):
"""Import scripts/release.py with REPO_ROOT pinned to a temp tree."""
spec = importlib.util.spec_from_file_location(
"_release_under_test",
Path(__file__).resolve().parents[2] / "scripts" / "release.py",
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
monkeypatch.setattr(module, "REPO_ROOT", tmp_root)
monkeypatch.setattr(
module, "ACP_REGISTRY_MANIFEST", tmp_root / "acp_registry" / "agent.json"
)
return module
def _write_manifest(root: Path, version: str) -> None:
manifest_dir = root / "acp_registry"
manifest_dir.mkdir(parents=True)
(manifest_dir / "agent.json").write_text(
json.dumps(
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": version,
"description": "test",
"distribution": {
"uvx": {
"package": f"hermes-agent[acp]=={version}",
"args": ["hermes-acp"],
}
},
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
def test_update_acp_registry_versions_bumps_manifest_and_pin(monkeypatch, tmp_path):
_write_manifest(tmp_path, "0.13.0")
module = _load_release_module(monkeypatch, tmp_path)
module._update_acp_registry_versions("0.14.0")
manifest = json.loads(
(tmp_path / "acp_registry" / "agent.json").read_text(encoding="utf-8")
)
assert manifest["version"] == "0.14.0"
assert manifest["distribution"]["uvx"]["package"] == "hermes-agent[acp]==0.14.0"
# args stay untouched so we don't accidentally rewrite them.
assert manifest["distribution"]["uvx"]["args"] == ["hermes-acp"]
def test_update_acp_registry_versions_is_silent_when_manifest_missing(
monkeypatch, tmp_path
):
"""Older release branches predate the ACP Registry asset — must no-op."""
module = _load_release_module(monkeypatch, tmp_path)
# No fixture written; function should not raise.
module._update_acp_registry_versions("0.14.0")
def test_update_version_files_bumps_manifest_alongside_pyproject(
monkeypatch, tmp_path
):
"""End-to-end: update_version_files() is the function release.py actually
calls, so it must drive the manifest bump too."""
_write_manifest(tmp_path, "0.13.0")
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "hermes-agent"\nversion = "0.13.0"\n', encoding="utf-8"
)
version_dir = tmp_path / "hermes_cli"
version_dir.mkdir()
(version_dir / "__init__.py").write_text(
'__version__ = "0.13.0"\n__release_date__ = "2026-05-14"\n',
encoding="utf-8",
)
module = _load_release_module(monkeypatch, tmp_path)
monkeypatch.setattr(module, "VERSION_FILE", version_dir / "__init__.py")
monkeypatch.setattr(module, "PYPROJECT_FILE", tmp_path / "pyproject.toml")
module.update_version_files("0.14.0", "2026-05-21")
pyproject_text = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
assert 'version = "0.14.0"' in pyproject_text
manifest = json.loads(
(tmp_path / "acp_registry" / "agent.json").read_text(encoding="utf-8")
)
assert manifest["version"] == "0.14.0"
assert manifest["distribution"]["uvx"]["package"] == "hermes-agent[acp]==0.14.0"

View file

@ -0,0 +1,46 @@
"""Behavioral regression coverage for the wheel/sdist distribution guard."""
import os
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def _build_sdist(tmp_path, *, nix_build: bool) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
# nix develop exports this too, so it must not grant permission to build
# a distributable artifact.
env["NIX_BUILD_TOP"] = "/build/devshell"
if nix_build:
env["HERMES_NIX_BUILD"] = "1"
else:
env.pop("HERMES_NIX_BUILD", None)
return subprocess.run(
[
sys.executable,
"-c",
"from setuptools.build_meta import build_sdist; build_sdist(r'{}')".format(tmp_path),
],
cwd=PROJECT_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
def test_sdist_rejects_nix_development_shell_environment(tmp_path):
result = _build_sdist(tmp_path, nix_build=False)
assert result.returncode != 0
assert "Building wheels or sdists for hermes-agent is not supported" in result.stderr
def test_sdist_allows_explicit_nix_package_build_marker(tmp_path):
result = _build_sdist(tmp_path, nix_build=True)
assert result.returncode == 0, result.stderr
assert list(tmp_path.glob("hermes_agent-*.tar.gz"))

View file

@ -5,13 +5,6 @@ from pathlib import Path
import pytest
# setuptools is declared in the [dev] extra and is the build backend, but
# guard the import so a runner without it skips these packaging checks
# instead of erroring out collection for the whole shard (it used to be
# picked up ambiently from the CI image; newer ubuntu-latest images don't
# ship it in the test venv).
find_packages = pytest.importorskip("setuptools", exc_type=ImportError).find_packages
REPO_ROOT = Path(__file__).resolve().parents[1]
@ -32,53 +25,6 @@ def _distribution_name(requirement: str) -> str:
return spec.strip().lower()
def _packages_find_include():
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
return data["tool"]["setuptools"]["packages"]["find"]["include"]
def test_every_on_disk_subpackage_is_covered_by_packages_find():
"""Regression test for #34701 (and the bug class behind #34034 / #28149).
``[tool.setuptools.packages.find]`` ``include`` is hand-maintained. Every
top-level package is listed twice bare (``hermes_cli``) for the package
itself and ``hermes_cli.*`` for its subpackages EXCEPT when someone
forgets the wildcard. v0.15.x listed ``hermes_cli`` without ``hermes_cli.*``,
so the wheel shipped ``hermes_cli/*.py`` but dropped the ``dashboard_auth``
and ``proxy`` subpackages. The dashboard then died on every install with
``ModuleNotFoundError: No module named 'hermes_cli.dashboard_auth'``.
This drives setuptools' own discovery against the live tree: every package
that exists on disk and would be found by a permissive ``<name>.*`` scan
must also be found by the actual ``include`` list. A subpackage added under
any listed package without the matching wildcard fails here instead of in a
user's container.
"""
include = _packages_find_include()
# What the real include list actually selects.
selected = set(find_packages(where=str(REPO_ROOT), include=include))
# Top-level packages we ship (bare names in the include list, no wildcard).
top_level = sorted({name for name in include if "." not in name})
# For each shipped top-level package, every on-disk subpackage must be
# covered by the include list.
expected = set(
find_packages(
where=str(REPO_ROOT),
include=[pattern for name in top_level for pattern in (name, f"{name}.*")],
)
)
missing = sorted(expected - selected)
assert not missing, (
"These packages exist on disk but are dropped from the wheel because "
"[tool.setuptools.packages.find] include is missing a wildcard. Add the "
f"matching '<name>.*' entry in pyproject.toml: {missing}"
)
def test_packaging_declared_as_core_dependency():
"""Regression for #40503.
@ -110,52 +56,6 @@ def test_faster_whisper_is_not_a_base_dependency():
assert any(dep.startswith("faster-whisper") for dep in voice_extra)
def test_manifest_includes_bundled_skills():
manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8")
assert "graft skills" in manifest
assert "graft optional-skills" in manifest
def test_bundled_plugin_manifests_ship_in_both_wheel_and_sdist():
"""Regression test for #34034 / #28149.
Plugin discovery (hermes_cli/plugins.py) registers each bundled plugin by
reading its ``plugin.yaml`` / ``plugin.yml`` manifest. Those manifests are
data files, not Python modules, so they only reach installed packages when
declared explicitly:
- wheel -> ``[tool.setuptools.package-data]`` ``plugins`` glob
- sdist -> ``MANIFEST.in`` (Homebrew and other downstream packagers build
from the sdist)
v0.15.0 declared neither, so the wheel shipped every adapter's Python code
but none of its manifests, and *every* gateway platform failed with
"No adapter available for <platform>". Both channels must cover manifests.
"""
# There must actually be manifests on disk for the globs to match.
on_disk = list((REPO_ROOT / "plugins").rglob("plugin.yaml")) + list(
(REPO_ROOT / "plugins").rglob("plugin.yml")
)
assert on_disk, "expected bundled plugin manifests under plugins/"
# Wheel channel: package-data must declare a glob that matches plugin
# manifests anywhere under the plugins package.
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
plugins_pkg_data = data["tool"]["setuptools"]["package-data"].get("plugins", [])
assert any(
g.endswith("plugin.yaml") or g.endswith("plugin.yml")
for g in plugins_pkg_data
), "pyproject package-data 'plugins' must ship plugin.yaml/plugin.yml (wheel)"
# Sdist channel: MANIFEST.in must recursively include the manifests so
# downstream packagers building from the sdist also get them.
manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8")
assert "recursive-include plugins" in manifest and "plugin.yaml" in manifest, (
"MANIFEST.in must recursive-include plugins plugin.yaml/plugin.yml (sdist)"
)
# Minimum non-vulnerable Starlette: CVE-2026-48710 ("BadHost") was fixed in
# 1.0.1. Anything below that lets a malformed Host header desync
# ``request.url.path`` from the dispatched ASGI path, bypassing path-based
@ -241,32 +141,6 @@ def test_locked_starlette_is_not_vulnerable_to_cve_2026_48710():
)
def test_locale_catalogs_ship_in_both_wheel_and_sdist():
"""Regression test for #27632 / #35374 / #23943.
locales/ is a bare data directory (no __init__.py), so it is invisible to
packages.find and to package-data (which attaches to a package). It must be
declared as setuptools data-files (wheel) AND grafted in MANIFEST.in
(sdist). Without both, sealed installs drop the catalogs and gateway/CLI
commands surface raw i18n keys like `gateway.reset.header_default`.
"""
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
data_files = data["tool"]["setuptools"].get("data-files", {})
assert data_files.get("locales") == ["locales/*.yaml"], (
"pyproject [tool.setuptools.data-files] must declare "
'locales = ["locales/*.yaml"] so the wheel ships i18n catalogs'
)
manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8")
assert "graft locales" in manifest, (
"MANIFEST.in must `graft locales` so the sdist ships i18n catalogs"
)
# Every on-disk catalog has the .yaml extension the globs above match.
on_disk = list((REPO_ROOT / "locales").glob("*.yaml"))
assert on_disk, "expected locales/*.yaml catalogs on disk"
# ---------------------------------------------------------------------------
# Dependency-pin consistency: pyproject extras <-> tools/lazy_deps.py
#

View file

@ -11,13 +11,6 @@ def _load_optional_dependencies():
return project["optional-dependencies"]
def _load_package_data():
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
with pyproject_path.open("rb") as handle:
tool = tomllib.load(handle)["tool"]
return tool["setuptools"]["package-data"]
def test_matrix_extra_not_in_all():
"""The [matrix] extra pulls `mautrix[encryption]` -> `python-olm`,
which has Linux-only wheels and no native build path on Windows or
@ -31,7 +24,7 @@ def test_matrix_extra_not_in_all():
"""
optional_dependencies = _load_optional_dependencies()
assert "matrix" in optional_dependencies, "[matrix] extra must still exist for explicit `pip install hermes-agent[matrix]`"
assert "matrix" in optional_dependencies, "[matrix] extra must still exist for `uv sync --extra matrix`"
# Must NOT appear in [all] in any form — neither unconditional nor
# platform-gated. Lazy-install handles it.
matrix_in_all = [
@ -230,25 +223,3 @@ def test_nemo_relay_extra_uses_supported_official_distribution_range():
spec == "hermes-agent[nemo-relay]"
for spec in optional_dependencies["all"]
)
def test_dashboard_plugin_manifests_and_assets_are_packaged():
"""Bundled dashboard plugins need their manifests and built assets in
wheel installs so /api/dashboard/plugins can discover them outside a
source checkout."""
package_data = _load_package_data()
plugin_data = package_data["plugins"]
assert "*/dashboard/manifest.json" in plugin_data
assert "*/dashboard/dist/*" in plugin_data
assert "*/dashboard/dist/**/*" in plugin_data
def test_nested_bundled_plugin_metadata_is_packaged():
"""Nested opt-in plugins need manifests and READMEs in wheel installs."""
package_data = _load_package_data()
plugin_data = package_data["plugins"]
assert "**/plugin.yaml" in plugin_data
assert "**/plugin.yml" in plugin_data
assert "**/README.md" in plugin_data

View file

@ -1,76 +0,0 @@
"""Test that setup.py uses temporary output directories when the source
tree is read-only (as it is inside the Docker WebUI install surface).
"""
from __future__ import annotations
from pathlib import Path
import runpy
from setuptools import Distribution
import setuptools
REPO_ROOT = Path(__file__).resolve().parent.parent
def _is_under(path: str, root: Path) -> bool:
try:
Path(path).resolve().relative_to(root.resolve())
except ValueError:
return False
return True
def test_setup_uses_temporary_outputs_when_source_tree_is_read_only(
monkeypatch,
) -> None:
"""WebUI installs from read-only /opt/hermes must not write build metadata."""
captured: dict[str, object] = {}
def capture_setup(**kwargs: object) -> None:
captured.update(kwargs)
monkeypatch.setattr(setuptools, "setup", capture_setup)
namespace = runpy.run_path(str(REPO_ROOT / "setup.py"))
cmdclass = captured["cmdclass"]
monkeypatch.setitem(
cmdclass["build"].finalize_options.__globals__,
"_source_tree_is_writable",
lambda: False,
)
monkeypatch.setitem(
cmdclass["egg_info"].finalize_options.__globals__,
"_source_tree_is_writable",
lambda: False,
)
build_cmd = cmdclass["build"](Distribution())
build_cmd.initialize_options()
build_cmd.finalize_options()
assert not _is_under(build_cmd.build_base, REPO_ROOT)
assert Path(build_cmd.build_base).name.startswith("hermes-agent-build")
source_relative_build = cmdclass["build"](Distribution())
source_relative_build.initialize_options()
source_relative_build.build_base = "nested/build"
source_relative_build.finalize_options()
assert not _is_under(source_relative_build.build_base, REPO_ROOT)
assert Path(source_relative_build.build_base).name.startswith("hermes-agent-build")
egg_info_cmd = cmdclass["egg_info"](Distribution())
egg_info_cmd.initialize_options()
egg_info_cmd.finalize_options()
assert egg_info_cmd.egg_base is not None
assert not _is_under(egg_info_cmd.egg_base, REPO_ROOT)
assert Path(egg_info_cmd.egg_base).name.startswith("hermes-agent-egg-info")
source_relative_egg_info = cmdclass["egg_info"](Distribution())
source_relative_egg_info.initialize_options()
source_relative_egg_info.egg_base = "."
source_relative_egg_info.finalize_options()
assert source_relative_egg_info.egg_base is not None
assert not _is_under(source_relative_egg_info.egg_base, REPO_ROOT)
assert Path(source_relative_egg_info.egg_base).name.startswith(
"hermes-agent-egg-info"
)

View file

@ -6482,25 +6482,6 @@ def test_session_info_includes_session_title(monkeypatch):
assert info["title"] == "Dashboard title"
def test_session_info_includes_install_warning_for_pip(monkeypatch):
"""pip installs surface install_warning; git installs don't (issue: pip/brew deprecation)."""
monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda: "pip")
info = server._session_info(types.SimpleNamespace(tools=[], model="", provider=""))
assert "install_warning" in info
assert "pip" in info["install_warning"]
assert "platform-support" in info["install_warning"]
def test_session_info_omits_install_warning_for_git(monkeypatch):
monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda: "git")
info = server._session_info(types.SimpleNamespace(tools=[], model="", provider=""))
assert "install_warning" not in info
# ---------------------------------------------------------------------------
# History-mutating commands must reject while session.running is True.
# Without these guards, prompt.submit's post-run history write either

View file

@ -1,195 +0,0 @@
"""End-to-end: a built wheel, installed without a source tree, must resolve
i18n catalogs and render human strings not raw key paths.
This is the test that would have caught #27632 / #35374 / #23943. Metadata
unit tests (test_packaging_metadata.py) prove the glob is declared; this proves
the runtime actually finds the catalogs after a real pip install.
This lives in tests/ (NOT tests/e2e/) so it is collected by the dedicated CI
step in Task 9, not by the existing `python -m pytest tests/e2e/` runner.
Assumption: `from agent import i18n` must import with only stdlib + pyyaml
available (the test installs the wheel --no-deps + pyyaml). agent/__init__.py's
jiter preload swallows ImportError, and i18n.py imports yaml lazily inside
_load_catalog, so this holds today. If i18n.py ever gains a top-level non-stdlib
import, add it to the pip install line below.
Marked `integration` because it shells out to `uv build` + `venv` + `pip` and
takes ~15-30s. Run with: pytest -m integration tests/test_wheel_locales_e2e.py
"""
from __future__ import annotations
import glob
import os
import subprocess
import sys
import tarfile
import venv
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.integration
@pytest.mark.timeout(300) # overrides the global --timeout=30; cold-CI wheel build + venv + pip can exceed it
def test_installed_wheel_renders_i18n_strings(tmp_path):
# 1. Build the wheel from the current tree.
wheel_dir = tmp_path / "wheel"
build = subprocess.run(
["uv", "build", "--wheel", "--out-dir", str(wheel_dir), "."],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=600,
)
assert build.returncode == 0, f"uv build failed:\n{build.stderr}"
wheels = glob.glob(str(wheel_dir / "*.whl"))
assert wheels, "no wheel produced"
wheel = wheels[0]
# 2. Fresh venv, install the wheel WITHOUT deps (we only exercise i18n,
# which needs pyyaml). --force-reinstall guards against pip's
# same-version no-op.
venv_dir = tmp_path / "venv"
venv.create(venv_dir, with_pip=True)
if sys.platform == "win32":
vpy = venv_dir / "Scripts" / "python.exe"
else:
vpy = venv_dir / "bin" / "python"
subprocess.run([str(vpy), "-m", "pip", "install", "-q", "pyyaml"], check=True, timeout=300)
subprocess.run(
[str(vpy), "-m", "pip", "install", "-q", "--no-deps", "--force-reinstall", wheel],
check=True,
timeout=300,
)
# 3. Run from a directory that is NOT the source tree, with a clean env
# (no PYTHONPATH leaking the repo, no HERMES_BUNDLED_LOCALES).
probe = (
"from agent import i18n;"
"import sys;"
"r = i18n.t('gateway.reset.header_default', lang='en');"
"s = i18n.t('gateway.status.header', lang='en');"
"print(repr(r)); print(repr(s));"
"sys.exit(0 if (r != 'gateway.reset.header_default' "
"and s != 'gateway.status.header') else 1)"
)
env = {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "HERMES_BUNDLED_LOCALES")}
env["PYTHONIOENCODING"] = "utf-8"
if sys.platform == "win32":
env["PATH"] = f"{venv_dir / 'Scripts'}{os.pathsep}{env['PATH']}"
else:
env["PATH"] = f"{venv_dir / 'bin'}{os.pathsep}{env['PATH']}"
env["VIRTUAL_ENV"] = str(venv_dir)
run = subprocess.run(
[str(vpy), "-c", probe],
cwd=str(tmp_path), # NOT the repo root
capture_output=True,
text=True,
env=env,
timeout=120,
)
assert run.returncode == 0, (
"installed wheel returned raw i18n keys instead of human strings:\n"
f"stdout: {run.stdout}\nstderr: {run.stderr}"
)
@pytest.mark.integration
@pytest.mark.timeout(300) # overrides the global --timeout=30; cold-CI sdist build can exceed it
def test_built_sdist_ships_locale_catalogs(tmp_path):
"""The sdist must carry locales/ too.
The wheel is covered above; the sdist is a separately shipped artifact
(PyPI, and the form distro/Homebrew packagers build from). MANIFEST.in
`graft locales` is what puts the catalogs in the tarball a stale graft or
a setuptools change would pass the metadata unit test (which only inspects
the declaration) while the actual artifact regresses. This inspects the
real tarball so that path can't rot silently. Closes the sdist half of
#27632 / #35374 / #23943.
"""
sdist_dir = tmp_path / "sdist"
build = subprocess.run(
["uv", "build", "--sdist", "--out-dir", str(sdist_dir), "."],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=600,
)
assert build.returncode == 0, f"uv build --sdist failed:\n{build.stderr}"
tarballs = glob.glob(str(sdist_dir / "*.tar.gz"))
assert tarballs, "no sdist produced"
with tarfile.open(tarballs[0]) as tf:
# Members are prefixed with the sdist root dir, e.g.
# hermes_agent-0.15.1/locales/en.yaml — match on the suffix.
catalogs = [m for m in tf.getnames() if "/locales/" in m and m.endswith(".yaml")]
# Compare against the canonical language list rather than a hardcoded floor
# so adding/removing a catalog updates the guard automatically and a dropped
# catalog (not just a fully-empty graft) trips it.
from agent.i18n import SUPPORTED_LANGUAGES
expected = len(SUPPORTED_LANGUAGES)
assert len(catalogs) == expected, (
f"sdist shipped {len(catalogs)} locale catalogs, expected {expected} "
f"({len(SUPPORTED_LANGUAGES)} supported languages) — check `graft "
"locales` in MANIFEST.in"
)
assert any(m.endswith("/locales/en.yaml") for m in catalogs), (
f"sdist missing locales/en.yaml; shipped: {catalogs[:5]}"
)
@pytest.mark.integration
@pytest.mark.timeout(300)
def test_built_sdist_ships_web_dist(tmp_path):
"""The sdist must carry hermes_cli/web_dist/ too.
MANIFEST.in `graft hermes_cli/web_dist` is what puts the frontend assets
in the source distribution tarball. This test builds the sdist and asserts
that index.html exists inside it.
"""
# Create a dummy index.html in hermes_cli/web_dist if it doesn't exist
# so that the sdist build actually has files to bundle.
web_dist_dir = REPO_ROOT / "hermes_cli" / "web_dist"
dummy_index = web_dist_dir / "index.html"
created_dummy = False
if not dummy_index.exists():
web_dist_dir.mkdir(parents=True, exist_ok=True)
with open(dummy_index, "w", encoding="utf-8") as f:
f.write("<html><body>Dummy Dashboard</body></html>")
created_dummy = True
try:
sdist_dir = tmp_path / "sdist"
build = subprocess.run(
["uv", "build", "--sdist", "--out-dir", str(sdist_dir), "."],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=600,
)
assert build.returncode == 0, f"uv build --sdist failed:\n{build.stderr}"
tarballs = glob.glob(str(sdist_dir / "*.tar.gz"))
assert tarballs, "no sdist produced"
with tarfile.open(tarballs[0]) as tf:
names = tf.getnames()
web_assets = [m for m in names if "hermes_cli/web_dist/" in m]
assert any(m.endswith("/hermes_cli/web_dist/index.html") for m in web_assets), (
f"sdist missing hermes_cli/web_dist/index.html; shipped files in web_dist: {web_assets}"
)
finally:
if created_dummy and dummy_index.exists():
try:
dummy_index.unlink()
# Clean up directory if it's empty
if not any(web_dist_dir.iterdir()):
web_dist_dir.rmdir()
except Exception:
pass

View file

@ -2343,10 +2343,8 @@ class MCPServerTask:
if not _MCP_AVAILABLE:
raise ImportError(
f"MCP server '{self.name}' requires the 'mcp' Python SDK, but "
"it is not installed. Install with:\n"
" pip install 'hermes-agent[mcp]'\n"
"or (full install):\n"
" pip install 'hermes-agent[all]'"
"it is not installed. Run `hermes setup` to install MCP support, "
"then retry."
)
command = config.get("command")

View file

@ -54,8 +54,7 @@ def _get_bundled_dir() -> Path:
"""Locate the bundled skills/ directory.
Checks HERMES_BUNDLED_SKILLS env var first (set by Nix wrapper),
then a wheel-installed data dir, then falls back to the relative
path from this source file.
then falls back to the relative path from this source file.
"""
return get_bundled_skills_dir(Path(__file__).parent.parent / "skills")

View file

@ -2451,7 +2451,7 @@ def text_to_speech_tool(
return json.dumps({
"success": False,
"error": "Mistral provider selected but 'mistralai' package not installed. "
"Run: pip install 'hermes-agent[mistral]'"
"Run `hermes setup` to install Mistral support."
}, ensure_ascii=False)
logger.info("Generating speech with Mistral Voxtral TTS...")
_generate_mistral_tts(text, file_str, tts_config)

View file

@ -6,7 +6,7 @@ sounddevice or system audio players.
Dependencies (optional):
pip install sounddevice numpy
or: pip install hermes-agent[voice]
or: uv sync --extra voice
"""
import logging

View file

@ -3833,18 +3833,6 @@ def _session_info(agent, session: dict | None = None) -> dict:
"usage": _session_usage_snapshot(session),
"profile_name": _current_profile_name(),
}
try:
from hermes_cli.config import (
detect_install_method,
format_unsupported_install_warning,
is_unsupported_install_method,
)
_install_method = detect_install_method()
if is_unsupported_install_method(_install_method):
info["install_warning"] = format_unsupported_install_warning(_install_method)
except Exception:
pass
try:
from hermes_cli import __version__, __release_date__

4
uv.lock generated
View file

@ -1784,7 +1784,7 @@ requires-dist = [
{ name = "hermes-agent", extras = ["mcp"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["mcp"], marker = "extra == 'termux'" },
{ name = "hermes-agent", extras = ["pty"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" },
{ name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux-all'" },
{ name = "hermes-agent", extras = ["sms"], marker = "extra == 'all'" },
{ name = "hermes-agent", extras = ["sms"], marker = "extra == 'termux-all'" },
{ name = "hermes-agent", extras = ["termux"], marker = "extra == 'termux-all'" },
@ -1855,7 +1855,7 @@ requires-dist = [
{ name = "websockets", specifier = "==15.0.1" },
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" },
]
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "honcho", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
[[package]]
name = "hf-xet"

View file

@ -17,7 +17,6 @@ Key implementation files:
- `acp_adapter/permissions.py`
- `acp_adapter/tools.py`
- `acp_adapter/auth.py`
- `acp_registry/agent.json`
## Boot flow
@ -31,8 +30,6 @@ hermes acp / hermes-acp / python -m acp_adapter
-> acp.run_agent(agent, use_unstable_protocol=True)
```
The Zed ACP Registry path launches the same adapter through `uvx --from 'hermes-agent[acp]==<version>' hermes-acp`, pointed at the `hermes-agent` PyPI release.
Stdout is reserved for ACP JSON-RPC transport. Human-readable logs go to stderr.
## Major components
@ -149,7 +146,7 @@ Instead it reuses Hermes' runtime resolver:
- `acp_adapter/auth.py`
- `hermes_cli/runtime_provider.py`
So ACP advertises and uses the currently configured Hermes provider/credentials. It also always advertises a terminal setup auth method (`hermes-setup`, args `--setup`) so first-run registry clients can open Hermes' interactive model/provider configuration before starting a normal ACP session.
So ACP advertises and uses the currently configured Hermes provider/credentials. It also always advertises a terminal setup auth method (`hermes-setup`, args `--setup`) so first-run ACP clients can open Hermes' interactive model/provider configuration before starting a normal ACP session.
## Working directory binding

View file

@ -159,4 +159,4 @@ For more diagnostics, run `hermes doctor` — it will tell you exactly what's mi
## Install method auto-detection
Hermes auto-detects whether it was installed via `pip`, the git installer, Homebrew, or NixOS, and `hermes update` prints the matching update command for that path. There's no env var to set — the detection is based on the install layout (Python site-packages, `~/.hermes/hermes-agent/`, Homebrew prefix, or Nix store path). `hermes doctor` also surfaces the detected method under its environment summary.
Hermes auto-detects whether it was installed via the git installer, Docker, or NixOS, and `hermes update` prints the matching update command for that path. There's no env var to set — the detection is based on the install layout (`~/.hermes/hermes-agent/` checkout, Docker image stamp, or Nix store path). `hermes doctor` also surfaces the detected method under its environment summary.

View file

@ -44,7 +44,7 @@ PRs to fix them will _not_ be accepted, and any code that keeps compatibility wi
- installs via the AUR (we might upstream patches if it helps out &lt;3)
- macOS on x86 (Intel) processors
- installs via `pypi` (e.g. `uv tool install hermes-agent`, `pip install hermse-agent`, etc.)
- installs via `pypi` (e.g. `uv tool install hermes-agent`, `pip install hermes-agent`, etc.)
- installs via `brew` (`brew install hermes-agent`)
If you are using an unsupported distribution method, please read the [the installation guide](./installation.md) to learn how to switch to a supported one.

View file

@ -18,7 +18,7 @@ Vertex has **no static API key** for the standard endpoint. Every request needs
- **Credentials**, one of:
- a **service-account JSON** key file with the `roles/aiplatform.user` role, or
- **Application Default Credentials** via `gcloud auth application-default login` (or the metadata server when running on a GCP VM).
- **`google-auth`** — installed automatically the first time you select Vertex (lazy install), or explicitly with `pip install 'hermes-agent[vertex]'`.
- **`google-auth`** — installed automatically the first time you select Vertex (lazy install). Run `hermes setup` to repair a managed install if that fails.
## Quick Start
@ -128,7 +128,7 @@ Hermes found neither a service-account JSON nor working ADC. Either set `VERTEX_
### `google-auth` not installed
Install the extra: `pip install 'hermes-agent[vertex]'`. Hermes also lazy-installs it the first time you select the Vertex provider.
Hermes lazy-installs it the first time you select the Vertex provider. If that fails, run `hermes setup` to repair the managed install.
### 404 on Gemini 3.x models

View file

@ -12,23 +12,15 @@ Hermes isn't just a CLI tool. You can import `AIAgent` directly and use it progr
## Installation
Install Hermes directly from the repository:
Clone Hermes and create its supported editable development environment:
```bash
pip install git+https://github.com/NousResearch/hermes-agent.git
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
uv sync
```
Or with [uv](https://docs.astral.sh/uv/):
```bash
uv pip install git+https://github.com/NousResearch/hermes-agent.git
```
You can also pin it in your `requirements.txt`:
```text
hermes-agent @ git+https://github.com/NousResearch/hermes-agent.git
```
Run your application with `uv run python your_app.py` from that checkout. Hermes does not publish a supported wheel or source distribution for `requirements.txt` installs.
:::tip
The same environment variables used by the CLI are required when using Hermes as a library. At minimum, set `OPENROUTER_API_KEY` (or `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` if using direct provider access).

View file

@ -444,7 +444,7 @@ By default, the bot needs an `@mention` in Discord server text channels unless c
If you want the shortest path to success:
1. get text Hermes working
2. install `hermes-agent[voice]`
2. run `hermes setup voice` to enable voice support
3. use CLI voice mode with local STT + Edge TTS
4. then enable `/voice on` in Telegram or Discord
5. only after that, try Discord VC mode

View file

@ -403,7 +403,7 @@ vertex:
region: "global" # required for the Gemini 3.x previews
```
`VERTEX_PROJECT_ID` / `VERTEX_REGION` env vars override the `config.yaml` values. Install with `pip install 'hermes-agent[vertex]'` (or let Hermes lazy-install `google-auth` on first use). See the [Google Vertex AI guide](/guides/google-vertex) for the full walkthrough, and the [Google Gemini guide](/guides/google-gemini) for the static-API-key AI Studio path instead.
`VERTEX_PROJECT_ID` / `VERTEX_REGION` env vars override the `config.yaml` values. Hermes lazy-installs `google-auth` on first use; run `hermes setup` if the managed install needs repair. See the [Google Vertex AI guide](/guides/google-vertex) for the full walkthrough, and the [Google Gemini guide](/guides/google-gemini) for the static-API-key AI Studio path instead.
### Qwen Portal (OAuth)

View file

@ -1582,7 +1582,7 @@ Additional behavior:
|---------|-------------|
| `hermes version` | Print version information. |
| `hermes update` | Pull latest changes and reinstall dependencies. |
| `hermes postinstall` | Internal bootstrap. Runs once after the install script provisions Hermes (or after `hermes update`) to install non-Python dependencies that pip cannot provide — Node.js runtime, headless browser, ripgrep, ffmpeg — and then trigger `hermes setup` if the profile has not been configured yet. Safe to re-run idempotently. |
| `hermes uninstall [--full] [--gui] [--yes]` | Remove Hermes, optionally deleting all config/data. `--gui` removes only the desktop Chat GUI, leaving the agent intact; `--full` also deletes config/data; `--yes` skips prompts. |
## See also

View file

@ -1767,7 +1767,7 @@ quick_commands:
command: df -h /
update:
type: exec
command: cd ~/.hermes/hermes-agent && git pull && pip install -e .
command: cd ~/.hermes/hermes-agent && git pull && uv pip install -e .
gpu:
type: exec
command: nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total --format=csv,noheader

View file

@ -33,10 +33,10 @@ It intentionally excludes things that do not fit typical editor UX, such as mess
## Installation
Install Hermes normally, then add the ACP extra:
Install Hermes normally, then add the ACP extra from the install checkout:
```bash
pip install -e '.[acp]'
cd ~/.hermes/hermes-agent && uv pip install -e '.[acp]'
```
This installs the `agent-client-protocol` dependency and enables:
@ -45,14 +45,6 @@ This installs the `agent-client-protocol` dependency and enables:
- `hermes-acp`
- `python -m acp_adapter`
For Zed registry installs, Zed launches Hermes through the official ACP Registry entry. That entry uses a `uvx` distribution that runs:
```bash
uvx --from 'hermes-agent[acp]==<version>' hermes-acp
```
Make sure `uv` is available on `PATH` before using the registry install path.
## Launching the ACP server
Any of the following starts Hermes in ACP mode:
@ -89,7 +81,7 @@ hermes acp --setup-browser # interactive (prompts before ~400 MB downl
hermes acp --setup-browser --yes # accept the download non-interactively
```
This is the standalone command. The Zed registry's terminal-auth flow (`hermes acp --setup`) also offers the browser bootstrap as a follow-up question after model selection, so most users never need to run `--setup-browser` directly.
This is the standalone command. The terminal-auth flow (`hermes acp --setup`) also offers the browser bootstrap as a follow-up question after model selection, so most users never need to run `--setup-browser` directly.
What it does:
@ -126,19 +118,10 @@ If you want to define Hermes manually, add it through VS Code settings under `ac
### Zed
Zed v0.221.x and newer installs external agents through the official ACP Registry.
Configure Hermes as a custom agent server in Zed settings:
1. Open the Agent Panel.
2. Click **Add Agent**, or run the `zed: acp registry` command.
3. Search for **Hermes Agent**.
4. Install it and start a new Hermes external-agent thread.
Prerequisites:
- Configure Hermes provider credentials first with `hermes model`, or set them in `~/.hermes/.env` / `~/.hermes/config.yaml`.
- Install `uv` so the registry launcher can run `uvx --from 'hermes-agent[acp]==<version>' hermes-acp`.
For local development before the registry entry is available, use a custom agent server in Zed settings:
2. Add a custom agent server with the following configuration:
```json
{
@ -152,32 +135,15 @@ For local development before the registry entry is available, use a custom agent
}
```
3. Start a new Hermes external-agent thread.
Prerequisites:
- Configure Hermes provider credentials first with `hermes model`, or set them in `~/.hermes/.env` / `~/.hermes/config.yaml`.
### JetBrains
Use an ACP-compatible plugin and point it at:
```text
/path/to/hermes-agent/acp_registry
```
## Registry manifest
The source copy of Hermes' official ACP Registry metadata lives at:
```text
acp_registry/agent.json
acp_registry/icon.svg
```
The upstream registry PR copies those files into the top-level `hermes-agent/` directory in `agentclientprotocol/registry`.
The registry entry uses a `uvx` distribution that points directly at the `hermes-agent` PyPI release:
```text
uvx --from 'hermes-agent[acp]==<version>' hermes-acp
```
The registry CI verifies that the pinned version exists on PyPI, so the manifest's `version` and uvx `package` pin must always match `pyproject.toml`. `scripts/release.py` keeps them in lockstep automatically.
Use an ACP-compatible plugin and point it at `hermes acp` or `hermes-acp`.
## Configuration and credentials
@ -188,7 +154,7 @@ ACP mode uses the same Hermes configuration as the CLI:
- `~/.hermes/skills/`
- `~/.hermes/state.db`
Provider resolution uses Hermes' normal runtime resolver, so ACP inherits the currently configured provider and credentials. Hermes also advertises a terminal auth method (`--setup`) for first-run registry clients; this opens Hermes' interactive model/provider setup.
Provider resolution uses Hermes' normal runtime resolver, so ACP inherits the currently configured provider and credentials. Hermes also advertises a terminal auth method (`--setup`) for first-run ACP clients; this opens Hermes' interactive model/provider setup.
## Session behavior
@ -239,11 +205,9 @@ The ACP bridge maps these options onto Hermes' internal approval semantics — `
Check:
- In Zed, open the ACP Registry with `zed: acp registry` and search for **Hermes Agent**.
- For manual/local development, verify the custom `agent_servers` command points to `hermes acp`.
- Hermes is installed and on your PATH.
- The ACP extra is installed (`pip install -e '.[acp]'`).
- `uv` is installed if launching from the official Zed registry entry.
- The ACP extra is installed (`cd ~/.hermes/hermes-agent && uv pip install -e '.[acp]'`).
### ACP starts but immediately errors
@ -264,11 +228,7 @@ ACP mode uses Hermes' existing provider setup. Configure credentials with:
hermes model
```
or by editing `~/.hermes/.env`. Registry clients can also trigger Hermes' terminal auth flow, which runs the same interactive provider/model setup.
### Zed registry launcher cannot find uv
Install `uv` from the official uv installation docs, then retry the Hermes Agent thread from Zed.
or by editing `~/.hermes/.env`. The terminal auth flow (`hermes acp --setup`) can also trigger the interactive provider/model setup.
## See also

View file

@ -361,7 +361,7 @@ the API server and webhook endpoints) with its live connection status.
A consolidated administration panel for installation-wide operations:
- **Host** — live system stats: OS / kernel, architecture, hostname, Python and Hermes versions, CPU core count + utilization, memory, disk usage of the Hermes home, uptime, and load average. (CPU/memory/disk come from `psutil` when installed; identity fields are always shown.) The Hermes version shows an **update-status badge** (up to date / N commits behind) and a **Check for updates** button. When an update is available on a git or pip install, an **Update now** button opens a confirmation dialog — showing how many commits you'll pull — before running `hermes update` in the background. On Docker/Nix/Homebrew installs the dashboard can't apply the update in place, so it shows the correct out-of-band command instead.
- **Host** — live system stats: OS / kernel, architecture, hostname, Python and Hermes versions, CPU core count + utilization, memory, disk usage of the Hermes home, uptime, and load average. (CPU/memory/disk come from `psutil` when installed; identity fields are always shown.) The Hermes version shows an **update-status badge** (up to date / N commits behind) and a **Check for updates** button. When an update is available on a git install, an **Update now** button opens a confirmation dialog — showing how many commits you'll pull — before running `hermes update` in the background. On Docker/Nix installs the dashboard can't apply the update in place, so it shows the correct out-of-band command instead.
- **Nous Portal** — login status, the active inference provider, and the Tool Gateway routing table (which tools run via the Portal vs. locally), with a link to manage your subscription. Read-only mirror of `hermes portal`.
- **Skill curator** — the background skill-maintenance status (active / paused, interval, last run) with pause/resume and a run-now button. Mirrors `hermes curator`.
- **Gateway** — start, stop, and restart the messaging gateway, with live status (running/stopped, PID, state)
@ -542,7 +542,7 @@ same auth gate as the rest of `/api/`.
| `GET /api/ops/checkpoints` · `POST .../prune` | Inspect / prune the `/rollback` store |
| `POST /api/ops/hooks` · `DELETE /api/ops/hooks` | Create / remove a shell hook (consent-gated) |
| `GET /api/system/stats` | Host stats — OS, CPU, memory, disk, uptime |
| `GET /api/hermes/update/check` | Report update availability (commits behind, install method) without applying. For git/pip installs that are behind, also returns a `commits` list (`sha`, `summary`, `author`, `at`) of what's changed. `?force=1` busts the 6h cache |
| `GET /api/hermes/update/check` | Report update availability (commits behind, install method) without applying. For git installs that are behind, also returns a `commits` list (`sha`, `summary`, `author`, `at`) of what's changed. `?force=1` busts the 6h cache |
| `GET /api/curator` · `PUT .../paused` · `POST .../run` | Skill-curator status + pause/resume + run |
| `GET /api/portal` | Nous Portal auth + Tool Gateway routing (read-only) |
| `POST /api/ops/prompt-size` · `/dump` · `/config-migrate` | Diagnostics (backgrounded) |

View file

@ -17,7 +17,6 @@ ACP 适配器将 Hermes 的同步 `AIAgent` 封装为异步 JSON-RPC stdio 服
- `acp_adapter/permissions.py`
- `acp_adapter/tools.py`
- `acp_adapter/auth.py`
- `acp_registry/agent.json`
## 启动流程
@ -31,8 +30,6 @@ hermes acp / hermes-acp / python -m acp_adapter
-> acp.run_agent(agent, use_unstable_protocol=True)
```
Zed ACP Registry 路径通过 `uvx --from 'hermes-agent[acp]==<version>' hermes-acp` 启动同一适配器,指向 `hermes-agent` PyPI 发布包。
stdout 保留用于 ACP JSON-RPC 传输。人类可读的日志输出至 stderr。
## 主要组件
@ -149,7 +146,7 @@ ACP 不实现自己的认证存储。
- `acp_adapter/auth.py`
- `hermes_cli/runtime_provider.py`
因此 ACP 通告并使用当前配置的 Hermes provider/凭据。它还始终通告一个终端 setup 认证方法(`hermes-setup`,参数 `--setup`),以便首次运行的 registry 客户端在启动正常 ACP 会话前可以打开 Hermes 的交互式模型/provider 配置。
因此 ACP 通告并使用当前配置的 Hermes provider/凭据。它还始终通告一个终端 setup 认证方法(`hermes-setup`,参数 `--setup`),以便首次运行的 ACP 客户端在启动正常 ACP 会话前可以打开 Hermes 的交互式模型/provider 配置。
## 工作目录绑定

View file

@ -204,4 +204,4 @@ hermes setup --portal
## 安装方式自动检测
Hermes 会自动检测安装方式(`pip`、git 安装程序、Homebrew 或 NixOS`hermes update` 会打印对应路径的更新命令。无需设置任何环境变量——检测基于安装目录结构(Python site-packages、`~/.hermes/hermes-agent/`、Homebrew 前缀或 Nix store 路径)。`hermes doctor` 也会在其环境摘要中显示检测到的安装方式。
Hermes 会自动检测安装方式(git 安装程序、Docker 或 NixOS`hermes update` 会打印对应路径的更新命令。无需设置任何环境变量——检测基于安装目录结构(`~/.hermes/hermes-agent/` 检出、Docker 镜像标记或 Nix store 路径)。`hermes doctor` 也会在其环境摘要中显示检测到的安装方式。

View file

@ -12,23 +12,15 @@ Hermes 不仅仅是一个 CLI 工具。你可以直接导入 `AIAgent`,在自
## 安装
直接从仓库安装 Hermes
克隆 Hermes 并创建受支持的可编辑开发环境
```bash
pip install git+https://github.com/NousResearch/hermes-agent.git
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
uv sync
```
或使用 [uv](https://docs.astral.sh/uv/)
```bash
uv pip install git+https://github.com/NousResearch/hermes-agent.git
```
也可以在 `requirements.txt` 中固定版本:
```text
hermes-agent @ git+https://github.com/NousResearch/hermes-agent.git
```
在该检出目录中使用 `uv run python your_app.py` 运行应用。Hermes 不发布用于 `requirements.txt` 安装的受支持 wheel 或源代码发行版。
:::tip
将 Hermes 作为库使用时CLI 所需的环境变量同样必须设置。至少需要设置 `OPENROUTER_API_KEY`(若直接访问提供商,则设置 `OPENAI_API_KEY``ANTHROPIC_API_KEY`)。

View file

@ -440,7 +440,7 @@ Hermes 加入 Discord 语音频道VC监听用户语音转录后运
如果你想走最短的成功路径:
1. 让文本 Hermes 正常工作
2. 安装 `hermes-agent[voice]`
2. 运行 `hermes setup voice` 以启用语音支持
3. 使用本地 STT + Edge TTS 的 CLI 语音模式
4. 然后在 Telegram 或 Discord 中启用 `/voice on`
5. 只有在此之后,再尝试 Discord 语音频道模式

View file

@ -1389,7 +1389,7 @@ quick_commands:
command: df -h /
update:
type: exec
command: cd ~/.hermes/hermes-agent && git pull && pip install -e .
command: cd ~/.hermes/hermes-agent && git pull && uv pip install -e .
gpu:
type: exec
command: nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total --format=csv,noheader

View file

@ -33,10 +33,10 @@ Hermes 使用专为编辑器工作流设计的精选 `hermes-acp` 工具集运
## 安装
正常安装 Hermes 后,添加 ACP 扩展:
正常安装 Hermes 后,从安装检出目录添加 ACP 扩展:
```bash
pip install -e '.[acp]'
cd ~/.hermes/hermes-agent && uv pip install -e '.[acp]'
```
这将安装 `agent-client-protocol` 依赖并启用:
@ -45,14 +45,6 @@ pip install -e '.[acp]'
- `hermes-acp`
- `python -m acp_adapter`
对于 Zed registry 安装Zed 通过官方 ACP Registry 条目启动 Hermes。该条目使用 `uvx` 发行版运行:
```bash
uvx --from 'hermes-agent[acp]==<version>' hermes-acp
```
使用 registry 安装路径前,请确保 `uv` 已在 `PATH` 中可用。
## 启动 ACP 服务器
以下任意命令均可以 ACP 模式启动 Hermes
@ -87,7 +79,7 @@ hermes acp --setup-browser # 交互式(下载约 400 MB 前会提示
hermes acp --setup-browser --yes # 非交互式接受下载
```
这是独立命令。Zed registry 的终端认证流程(`hermes acp --setup`)在模型选择后也会将浏览器引导作为后续问题提供,因此大多数用户无需直接运行 `--setup-browser`
这是独立命令。终端认证流程(`hermes acp --setup`)在模型选择后也会将浏览器引导作为后续问题提供,因此大多数用户无需直接运行 `--setup-browser`
具体操作:
@ -124,19 +116,10 @@ hermes acp --setup-browser --yes # 非交互式接受下载
### Zed
Zed v0.221.x 及更新版本通过官方 ACP Registry 安装外部 agent。
在 Zed 设置中将 Hermes 配置为自定义 agent 服务器:
1. 打开 Agent 面板。
2. 点击 **Add Agent**,或运行 `zed: acp registry` 命令。
3. 搜索 **Hermes Agent**
4. 安装后启动新的 Hermes 外部 agent 线程。
前提条件:
- 先通过 `hermes model` 配置 Hermes provider 凭据,或在 `~/.hermes/.env` / `~/.hermes/config.yaml` 中设置。
- 安装 `uv`,以便 registry 启动器可以运行 `uvx --from 'hermes-agent[acp]==<version>' hermes-acp`
在 registry 条目可用之前进行本地开发时,在 Zed 设置中使用自定义 agent 服务器:
2. 使用以下配置添加自定义 agent 服务器:
```json
{
@ -150,32 +133,15 @@ Zed v0.221.x 及更新版本通过官方 ACP Registry 安装外部 agent。
}
```
3. 启动新的 Hermes 外部 agent 线程。
前提条件:
- 先通过 `hermes model` 配置 Hermes provider 凭据,或在 `~/.hermes/.env` / `~/.hermes/config.yaml` 中设置。
### JetBrains
使用兼容 ACP 的插件并将其指向:
```text
/path/to/hermes-agent/acp_registry
```
## Registry 清单
Hermes 官方 ACP Registry 元数据的源文件位于:
```text
acp_registry/agent.json
acp_registry/icon.svg
```
上游 registry PR 将这些文件复制到 `agentclientprotocol/registry` 中的顶层 `hermes-agent/` 目录。
Registry 条目使用直接指向 `hermes-agent` PyPI 发行版的 `uvx` 发行版:
```text
uvx --from 'hermes-agent[acp]==<version>' hermes-acp
```
Registry CI 会验证固定版本是否存在于 PyPI因此清单的 `version` 和 uvx `package` 固定版本必须始终与 `pyproject.toml` 匹配。`scripts/release.py` 会自动保持它们同步。
使用兼容 ACP 的插件并将其指向 `hermes acp``hermes-acp`
## 配置与凭据
@ -186,7 +152,7 @@ ACP 模式使用与 CLI 相同的 Hermes 配置:
- `~/.hermes/skills/`
- `~/.hermes/state.db`
Provider 解析使用 Hermes 的正常运行时解析器,因此 ACP 继承当前配置的 provider 和凭据。Hermes 还为首次运行的 registry 客户端提供终端认证方法(`--setup`);这将打开 Hermes 的交互式模型/provider 设置。
Provider 解析使用 Hermes 的正常运行时解析器,因此 ACP 继承当前配置的 provider 和凭据。Hermes 还为首次运行的 ACP 客户端提供终端认证方法(`--setup`);这将打开 Hermes 的交互式模型/provider 设置。
## 会话行为
@ -237,11 +203,9 @@ ACP 桥接将这些选项映射到 Hermes 的内部审批语义——`allow_alwa
检查:
- 在 Zed 中,使用 `zed: acp registry` 打开 ACP Registry 并搜索 **Hermes Agent**
- 对于手动/本地开发,验证自定义 `agent_servers` 命令是否指向 `hermes acp`
- Hermes 已安装且在 PATH 中。
- ACP 扩展已安装(`pip install -e '.[acp]'`)。
- 如果从官方 Zed registry 条目启动,`uv` 已安装。
- ACP 扩展已安装(`cd ~/.hermes/hermes-agent && uv pip install -e '.[acp]'`)。
### ACP 启动后立即报错
@ -262,11 +226,7 @@ ACP 模式使用 Hermes 现有的 provider 设置。通过以下方式配置凭
hermes model
```
或编辑 `~/.hermes/.env`。Registry 客户端也可以触发 Hermes 的终端认证流程,该流程运行相同的交互式 provider/模型设置。
### Zed registry 启动器找不到 uv
从官方 uv 安装文档安装 `uv`,然后从 Zed 重试 Hermes Agent 线程。
或编辑 `~/.hermes/.env`。终端认证流程(`hermes acp --setup`)也可以触发交互式 provider/模型设置。
## 另请参阅