diff --git a/.dockerignore b/.dockerignore
index ec3d52f81413..cfd0616efb84 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -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
diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml
index 47114b306546..7ee20e029a7c 100644
--- a/.github/workflows/supply-chain-audit.yml
+++ b/.github/workflows/supply-chain-audit.yml
@@ -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.
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 1f29b25008e4..cdae2e037a59 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -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
diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml
deleted file mode 100644
index e95ef194fa71..000000000000
--- a/.github/workflows/upload_to_pypi.yml
+++ /dev/null
@@ -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
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index c09d58c71283..000000000000
--- a/MANIFEST.in
+++ /dev/null
@@ -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]
diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py
index 5048b7025982..55773536122a 100644
--- a/acp_adapter/entry.py
+++ b/acp_adapter/entry.py
@@ -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.
"""
diff --git a/acp_registry/agent.json b/acp_registry/agent.json
deleted file mode 100644
index 1d3752bf15a1..000000000000
--- a/acp_registry/agent.json
+++ /dev/null
@@ -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"]
- }
- }
-}
diff --git a/acp_registry/icon.svg b/acp_registry/icon.svg
deleted file mode 100644
index f42c0daea458..000000000000
--- a/acp_registry/icon.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
diff --git a/agent/i18n.py b/agent/i18n.py
index ef9fd4b06c28..b55b8128c92d 100644
--- a/agent/i18n.py
+++ b/agent/i18n.py
@@ -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. ``/locales`` -- source checkouts and ``pip install -e .``,
+ 2. ``/locales`` -- source checkouts and editable installs,
where the working tree sits next to ``agent/``.
- 3. ``/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
diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
index 1fde9070d4a3..0d74014b50b3 100644
--- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
+++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts
@@ -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]
diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py
index 7b0beef7bb35..e487475f62b1 100644
--- a/hermes_cli/banner.py
+++ b/hermes_cli/banner.py
@@ -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)
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 5ef6ae381352..33076a7f238c 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -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_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."
diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py
index 3806ee509dd0..24b258e84e29 100644
--- a/hermes_cli/console_engine.py
+++ b/hermes_cli/console_engine.py
@@ -1180,7 +1180,7 @@ class HermesConsoleEngine:
"model",
"moa",
"oneshot",
- "postinstall",
+
"proxy",
"serve",
"setup",
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index c233594fcf81..61b921b6c4f1 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -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/?" 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//...
- 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)
diff --git a/hermes_cli/proxy/cli.py b/hermes_cli/proxy/cli.py
index 7c7b86caf085..5fc184509c43 100644
--- a/hermes_cli/proxy/cli.py
+++ b/hermes_cli/proxy/cli.py
@@ -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,
)
diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py
index 66749ba664aa..91d7fb338a22 100644
--- a/hermes_cli/proxy/server.py
+++ b/hermes_cli/proxy/server.py
@@ -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)
diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py
index 6fee9b013a93..aad776f86a85 100644
--- a/hermes_cli/runtime_provider.py
+++ b/hermes_cli/runtime_provider.py
@@ -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",
diff --git a/hermes_cli/subcommands/postinstall.py b/hermes_cli/subcommands/postinstall.py
deleted file mode 100644
index 207040ada2ff..000000000000
--- a/hermes_cli/subcommands/postinstall.py
+++ /dev/null
@@ -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)
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index bf5ca3d357a4..c98951098535 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -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
diff --git a/hermes_constants.py b/hermes_constants.py
index 639d6d48f000..7cb52e16ca2a 100644
--- a/hermes_constants.py
+++ b/hermes_constants.py
@@ -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 ``/skills`` (pip install path)
- 3. Caller-supplied ``default`` (typically the source-checkout path)
- 4. ``/skills`` last-resort
+ 2. Caller-supplied ``default`` (typically the source-checkout path)
+ 3. ``/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"
diff --git a/nix/checks.nix b/nix/checks.nix
index 7f625ca4fe4b..9e69d4c0f832 100644
--- a/nix/checks.nix
+++ b/nix/checks.nix
@@ -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
diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix
index f1a23b1029d5..90cbdbeb69d9 100644
--- a/nix/hermes-agent.nix
+++ b/nix/hermes-agent.nix
@@ -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//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 \
diff --git a/nix/lib.nix b/nix/lib.nix
index a705331c12b3..f18d3f0b23d7 100644
--- a/nix/lib.nix
+++ b/nix/lib.nix
@@ -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
diff --git a/nix/python.nix b/nix/python.nix
index 602c08a3fd04..316b7687a663 100644
--- a/nix/python.nix
+++ b/nix/python.nix
@@ -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";
+ });
+ })
]
);
diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md
deleted file mode 100644
index e53d3fd0b3e1..000000000000
--- a/packaging/homebrew/README.md
+++ /dev/null
@@ -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`.
diff --git a/packaging/homebrew/hermes-agent.rb b/packaging/homebrew/hermes-agent.rb
deleted file mode 100644
index 7c00fc6acf8f..000000000000
--- a/packaging/homebrew/hermes-agent.rb
+++ /dev/null
@@ -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 ""
- 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
diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py
index f843565cc771..a97d351a406f 100644
--- a/plugins/platforms/discord/adapter.py
+++ b/plugins/platforms/discord/adapter.py
@@ -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,
diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py
index 519c9afa413c..882b695b90c5 100644
--- a/plugins/platforms/feishu/adapter.py
+++ b/plugins/platforms/feishu/adapter.py
@@ -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",
diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py
index 2be49ff1742e..540f9bf84566 100644
--- a/plugins/platforms/google_chat/adapter.py
+++ b/plugins/platforms/google_chat/adapter.py
@@ -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
diff --git a/plugins/platforms/google_chat/oauth.py b/plugins/platforms/google_chat/oauth.py
index 277b2396f0c5..eb92528bbee4 100644
--- a/plugins/platforms/google_chat/oauth.py
+++ b/plugins/platforms/google_chat/oauth.py
@@ -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
diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py
index bde89f4ad1af..420d0af27996 100644
--- a/plugins/platforms/slack/adapter.py
+++ b/plugins/platforms/slack/adapter.py
@@ -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,
diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py
index 09f97598e264..a33f41361de5 100644
--- a/plugins/platforms/telegram/adapter.py
+++ b/plugins/platforms/telegram/adapter.py
@@ -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",
diff --git a/plugins/platforms/wecom/adapter.py b/plugins/platforms/wecom/adapter.py
index 80ca2ca1439f..4fc743aa6e23 100644
--- a/plugins/platforms/wecom/adapter.py
+++ b/plugins/platforms/wecom/adapter.py
@@ -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="💼",
diff --git a/pyproject.toml b/pyproject.toml
index ca3648279575..a2a1d6c0ee24 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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//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/.
-"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
- # " (#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 = [
diff --git a/scripts/check-windows-footguns.py b/scripts/check-windows-footguns.py
index 7ae7ca50c4e7..c0ca6622ec33 100644
--- a/scripts/check-windows-footguns.py
+++ b/scripts/check-windows-footguns.py
@@ -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:
diff --git a/scripts/install.sh b/scripts/install.sh
index ef95fc7aecf2..43755a59a762 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -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
diff --git a/scripts/release.py b/scripts/release.py
index 00da845f8f20..9e90850928e4 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -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")
diff --git a/setup.py b/setup.py
index 6e3e8c4272e8..fac7fe88161e 100644
--- a/setup.py
+++ b/setup.py
@@ -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)
diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md
index 5ba4ee874b32..ac4c84dead9a 100644
--- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md
+++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md
@@ -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
diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py
index f5300e9e6a9d..26f50c52c704 100644
--- a/skills/productivity/google-workspace/scripts/setup.py
+++ b/skills/productivity/google-workspace/scripts/setup.py
@@ -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
diff --git a/tests/acp/test_registry_manifest.py b/tests/acp/test_registry_manifest.py
deleted file mode 100644
index 633b4a8494c0..000000000000
--- a/tests/acp/test_registry_manifest.py
+++ /dev/null
@@ -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"}
diff --git a/tests/agent/test_i18n.py b/tests/agent/test_i18n.py
index 56a7568620d3..9c6e58bbb866 100644
--- a/tests/agent/test_i18n.py
+++ b/tests/agent/test_i18n.py
@@ -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
diff --git a/tests/cli/test_update_command.py b/tests/cli/test_update_command.py
index 392c11d1b265..6dd74ed0afdd 100644
--- a/tests/cli/test_update_command.py
+++ b/tests/cli/test_update_command.py
@@ -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
diff --git a/tests/gateway/test_update_command.py b/tests/gateway/test_update_command.py
index 5cc7f206e667..b25e79eba444 100644
--- a/tests/gateway/test_update_command.py
+++ b/tests/gateway/test_update_command.py
@@ -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."""
diff --git a/tests/hermes_cli/test_banner_pip_update.py b/tests/hermes_cli/test_banner_pip_update.py
deleted file mode 100644
index 205c97488a90..000000000000
--- a/tests/hermes_cli/test_banner_pip_update.py
+++ /dev/null
@@ -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")
diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py
index b8b0244531a0..dc01dbae0efc 100644
--- a/tests/hermes_cli/test_cmd_update.py
+++ b/tests/hermes_cli/test_cmd_update.py
@@ -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= 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=`` must refuse on the ZIP fallback path.
diff --git a/tests/hermes_cli/test_cmd_update_docker.py b/tests/hermes_cli/test_cmd_update_docker.py
index 827b41ec4583..fb26c689d676 100644
--- a/tests/hermes_cli/test_cmd_update_docker.py
+++ b/tests/hermes_cli/test_cmd_update_docker.py
@@ -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 ----------
diff --git a/tests/hermes_cli/test_console_engine.py b/tests/hermes_cli/test_console_engine.py
index 0c8274bf0f15..40153ae8c7a7 100644
--- a/tests/hermes_cli/test_console_engine.py
+++ b/tests/hermes_cli/test_console_engine.py
@@ -350,7 +350,7 @@ def test_console_registry_covers_non_admin_cli_surface():
"oneshot hello",
"model",
"setup",
- "postinstall",
+
"fallback add",
"moa configure",
"claw migrate",
diff --git a/tests/hermes_cli/test_managed_installs.py b/tests/hermes_cli/test_managed_installs.py
index 9dda45f4ffea..f942ffbc163a 100644
--- a/tests/hermes_cli/test_managed_installs.py
+++ b/tests/hermes_cli/test_managed_installs.py
@@ -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()
diff --git a/tests/hermes_cli/test_pip_install_detection.py b/tests/hermes_cli/test_pip_install_detection.py
index c852b57052ae..e8c5a9fff939 100644
--- a/tests/hermes_cli/test_pip_install_detection.py
+++ b/tests/hermes_cli/test_pip_install_detection.py
@@ -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"
diff --git a/tests/hermes_cli/test_subcommands_batch.py b/tests/hermes_cli/test_subcommands_batch.py
index d4ec37b6f3c7..712ca9f6b66c 100644
--- a/tests/hermes_cli/test_subcommands_batch.py
+++ b/tests/hermes_cli/test_subcommands_batch.py
@@ -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"]),
diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py
index 84b9e3a6c991..48a8da3045cf 100644
--- a/tests/hermes_cli/test_update_check.py
+++ b/tests/hermes_cli/test_update_check.py
@@ -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
diff --git a/tests/hermes_cli/test_uv_tool_update.py b/tests/hermes_cli/test_uv_tool_update.py
deleted file mode 100644
index b8f294de373e..000000000000
--- a/tests/hermes_cli/test_uv_tool_update.py
+++ /dev/null
@@ -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"
diff --git a/tests/scripts/test_release_acp_registry.py b/tests/scripts/test_release_acp_registry.py
deleted file mode 100644
index 4d20cda25bde..000000000000
--- a/tests/scripts/test_release_acp_registry.py
+++ /dev/null
@@ -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"
diff --git a/tests/test_packaging_build_guard.py b/tests/test_packaging_build_guard.py
new file mode 100644
index 000000000000..0234e3dd340c
--- /dev/null
+++ b/tests/test_packaging_build_guard.py
@@ -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"))
\ No newline at end of file
diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py
index f1ccee4773b0..c4a707065082 100644
--- a/tests/test_packaging_metadata.py
+++ b/tests/test_packaging_metadata.py
@@ -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 ``.*`` 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 '.*' 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 ". 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
#
diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py
index 8c0836e9059e..1ee9777e99f7 100644
--- a/tests/test_project_metadata.py
+++ b/tests/test_project_metadata.py
@@ -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
diff --git a/tests/test_setup_temporary_outputs.py b/tests/test_setup_temporary_outputs.py
deleted file mode 100644
index 042f0e233af8..000000000000
--- a/tests/test_setup_temporary_outputs.py
+++ /dev/null
@@ -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"
- )
diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py
index da815f9979c1..86615fee8015 100644
--- a/tests/test_tui_gateway_server.py
+++ b/tests/test_tui_gateway_server.py
@@ -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
diff --git a/tests/test_wheel_locales_e2e.py b/tests/test_wheel_locales_e2e.py
deleted file mode 100644
index 40c7a5b59415..000000000000
--- a/tests/test_wheel_locales_e2e.py
+++ /dev/null
@@ -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("Dummy Dashboard")
- 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
diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py
index a4303bdab5c8..06cee62109ef 100644
--- a/tools/mcp_tool.py
+++ b/tools/mcp_tool.py
@@ -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")
diff --git a/tools/skills_sync.py b/tools/skills_sync.py
index 2c0f41c47a62..09b8337540b0 100644
--- a/tools/skills_sync.py
+++ b/tools/skills_sync.py
@@ -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")
diff --git a/tools/tts_tool.py b/tools/tts_tool.py
index 545d72bb6907..da08ec6103f0 100644
--- a/tools/tts_tool.py
+++ b/tools/tts_tool.py
@@ -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)
diff --git a/tools/voice_mode.py b/tools/voice_mode.py
index d000e29d59d9..9beaa3e7cb5d 100644
--- a/tools/voice_mode.py
+++ b/tools/voice_mode.py
@@ -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
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index d841cdc39f8a..1aacd9c9c129 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -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__
diff --git a/uv.lock b/uv.lock
index 13a9912aa785..3b035b7e277a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -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"
diff --git a/website/docs/developer-guide/acp-internals.md b/website/docs/developer-guide/acp-internals.md
index 89ae398b6af5..e739d8087118 100644
--- a/website/docs/developer-guide/acp-internals.md
+++ b/website/docs/developer-guide/acp-internals.md
@@ -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]==' 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
diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md
index 86aea0fc0158..39237bc6f4b6 100644
--- a/website/docs/getting-started/installation.md
+++ b/website/docs/getting-started/installation.md
@@ -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.
diff --git a/website/docs/getting-started/platform-support.md b/website/docs/getting-started/platform-support.md
index d0df354e5924..1791716717c9 100644
--- a/website/docs/getting-started/platform-support.md
+++ b/website/docs/getting-started/platform-support.md
@@ -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 <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.
diff --git a/website/docs/guides/google-vertex.md b/website/docs/guides/google-vertex.md
index 851a391691c8..54923db967d5 100644
--- a/website/docs/guides/google-vertex.md
+++ b/website/docs/guides/google-vertex.md
@@ -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
diff --git a/website/docs/guides/python-library.md b/website/docs/guides/python-library.md
index 89fa122759b1..24d1aecc95e4 100644
--- a/website/docs/guides/python-library.md
+++ b/website/docs/guides/python-library.md
@@ -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).
diff --git a/website/docs/guides/use-voice-mode-with-hermes.md b/website/docs/guides/use-voice-mode-with-hermes.md
index b99b19211a6b..7007e20efc5e 100644
--- a/website/docs/guides/use-voice-mode-with-hermes.md
+++ b/website/docs/guides/use-voice-mode-with-hermes.md
@@ -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
diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md
index ed00d5b2a850..9ef33b90ef1b 100644
--- a/website/docs/integrations/providers.md
+++ b/website/docs/integrations/providers.md
@@ -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)
diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md
index b222039715fd..3e7782724b88 100644
--- a/website/docs/reference/cli-commands.md
+++ b/website/docs/reference/cli-commands.md
@@ -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
diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md
index e04e1eb30e26..2500f3a7752a 100644
--- a/website/docs/user-guide/configuration.md
+++ b/website/docs/user-guide/configuration.md
@@ -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
diff --git a/website/docs/user-guide/features/acp.md b/website/docs/user-guide/features/acp.md
index 4dce234ef524..ccdaabc5832d 100644
--- a/website/docs/user-guide/features/acp.md
+++ b/website/docs/user-guide/features/acp.md
@@ -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]==' 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]==' 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]==' 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
diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md
index bcd43951f372..2f81a54a557c 100644
--- a/website/docs/user-guide/features/web-dashboard.md
+++ b/website/docs/user-guide/features/web-dashboard.md
@@ -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) |
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/acp-internals.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/acp-internals.md
index 8230d5534c17..830cce38550e 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/acp-internals.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/acp-internals.md
@@ -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]==' 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 配置。
## 工作目录绑定
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md
index 6e91995e7445..c2bf14764a2a 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md
@@ -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` 也会在其环境摘要中显示检测到的安装方式。
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/python-library.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/python-library.md
index e094cd1af104..59493c66fce6 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/python-library.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/python-library.md
@@ -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`)。
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/use-voice-mode-with-hermes.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/use-voice-mode-with-hermes.md
index 853e69310c71..5df1eea252f9 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/use-voice-mode-with-hermes.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/use-voice-mode-with-hermes.md
@@ -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 语音频道模式
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md
index 2d290c846526..6d4c7c14f6d1 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md
@@ -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
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/acp.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/acp.md
index 629430438cba..717381203e77 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/acp.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/acp.md
@@ -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]==' 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]==' 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]==' 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/模型设置。
## 另请参阅