mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
refactor: remove pip and brew installation paths
This commit is contained in:
parent
8fd5b25898
commit
76c2f0aae2
83 changed files with 433 additions and 2509 deletions
|
|
@ -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
|
||||
|
|
|
|||
10
.github/workflows/supply-chain-audit.yml
vendored
10
.github/workflows/supply-chain-audit.yml
vendored
|
|
@ -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.
|
||||
|
|
|
|||
5
.github/workflows/tests.yml
vendored
5
.github/workflows/tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
188
.github/workflows/upload_to_pypi.yml
vendored
188
.github/workflows/upload_to_pypi.yml
vendored
|
|
@ -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
|
||||
14
MANIFEST.in
14
MANIFEST.in
|
|
@ -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]
|
||||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none">
|
||||
<path d="M8 1.5v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M8 3.25c-2.35-1.4-4.7-.95-6.25.35 1.85-.2 3.8.2 5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 3.25c2.35-1.4 4.7-.95 6.25.35-1.85-.2-3.8.2-5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c-2.3-1-3.05-2.65-1.35-4.15-2 .8-2.35 2.95-.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c2.3-1 3.05-2.65 1.35-4.15 2 .8 2.35 2.95.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="8" cy="1.8" r="1.1" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 882 B |
|
|
@ -32,7 +32,6 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import os
|
||||
import sysconfig
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
|
@ -92,12 +91,8 @@ def _locales_dir() -> Path:
|
|||
|
||||
1. ``HERMES_BUNDLED_LOCALES`` env var -- set by the Nix wrapper (or any
|
||||
sealed-packaging system) to point at the installed catalog directory.
|
||||
2. ``<repo-root>/locales`` -- source checkouts and ``pip install -e .``,
|
||||
2. ``<repo-root>/locales`` -- source checkouts and editable installs,
|
||||
where the working tree sits next to ``agent/``.
|
||||
3. ``<sysconfig data|purelib|platlib>/locales`` -- pip wheel installs.
|
||||
setuptools ``data-files`` extracts ``locales/*.yaml`` under the
|
||||
interpreter's ``data`` scheme; the other schemes are checked as a
|
||||
safety net for nonstandard layouts.
|
||||
|
||||
Falling through to the source-style path (even when missing) keeps
|
||||
``_load_catalog`` error messages informative -- it logs the path it
|
||||
|
|
@ -116,25 +111,6 @@ def _locales_dir() -> Path:
|
|||
|
||||
# agent/i18n.py -> agent/ -> repo root (source checkout, editable install)
|
||||
source_dir = Path(__file__).resolve().parent.parent / "locales"
|
||||
if source_dir.is_dir():
|
||||
return source_dir
|
||||
|
||||
# pip wheel install: data-files lands under the interpreter data scheme.
|
||||
# ``data`` (== sys.prefix in a venv) is where setuptools data-files extract
|
||||
# and is checked first. ``purelib``/``platlib`` (site-packages) are a safety
|
||||
# net for nonstandard layouts. NOTE: this does NOT cover ``pip install
|
||||
# --user`` (user scheme, ~/.local/locales) or ``pip install --target`` --
|
||||
# both are out of scope; see the plan header.
|
||||
for scheme in ("data", "purelib", "platlib"):
|
||||
raw = sysconfig.get_path(scheme)
|
||||
if not raw:
|
||||
continue
|
||||
candidate = Path(raw) / "locales"
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
|
||||
# Last resort: return the source-style path so _load_catalog's catalog-missing
|
||||
# log (logger.debug "i18n catalog missing for %s at %s") stays informative.
|
||||
return source_dir
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -326,11 +326,19 @@ from hermes_cli.default_soul import DEFAULT_SOUL_MD, is_legacy_template_soul
|
|||
|
||||
_MANAGED_TRUE_VALUES = ("true", "1", "yes")
|
||||
_MANAGED_SYSTEM_NAMES = {
|
||||
"brew": "Homebrew",
|
||||
"homebrew": "Homebrew",
|
||||
"nix": "NixOS",
|
||||
"nixos": "NixOS",
|
||||
}
|
||||
# The Nix store root. Used by detect_install_method to identify installs
|
||||
# from `nix run` / `nix profile install` (which don't set HERMES_MANAGED).
|
||||
# A module-level constant so tests can patch it without creating files
|
||||
# under the real /nix/store.
|
||||
_NIX_STORE = Path("/nix/store")
|
||||
# Values that used to signal a Homebrew-managed install. Homebrew is no
|
||||
# longer a supported distribution method, so these are explicitly ignored
|
||||
# rather than treated as a managed system — they fall through to git/unknown
|
||||
# detection instead of blocking config writes.
|
||||
_IGNORED_MANAGED_VALUES = frozenset({"brew", "homebrew"})
|
||||
|
||||
|
||||
def get_managed_system() -> Optional[str]:
|
||||
|
|
@ -338,6 +346,8 @@ def get_managed_system() -> Optional[str]:
|
|||
raw = os.getenv("HERMES_MANAGED", "").strip()
|
||||
if raw:
|
||||
normalized = raw.lower()
|
||||
if normalized in _IGNORED_MANAGED_VALUES:
|
||||
return None
|
||||
if normalized in _MANAGED_TRUE_VALUES:
|
||||
return "NixOS"
|
||||
return _MANAGED_SYSTEM_NAMES.get(normalized, raw)
|
||||
|
|
@ -364,8 +374,6 @@ _NIX_UPDATE_MSG = "Update your Nix flake input and rebuild (e.g. nix flake updat
|
|||
def get_managed_update_command() -> Optional[str]:
|
||||
"""Return the preferred upgrade command for a managed install."""
|
||||
managed_system = get_managed_system()
|
||||
if managed_system == "Homebrew":
|
||||
return "brew upgrade hermes-agent"
|
||||
if managed_system == "NixOS":
|
||||
return _NIX_UPDATE_MSG
|
||||
return None
|
||||
|
|
@ -375,10 +383,10 @@ def _install_method_project_root(project_root: Optional[Path] = None) -> Path:
|
|||
"""Resolve the directory that holds the *running code* (the install tree).
|
||||
|
||||
This is the parent of ``hermes_cli/`` — i.e. the git checkout for source
|
||||
installs, ``/opt/hermes`` inside the published image, the venv's
|
||||
site-packages root for pip installs. It is a property of the running
|
||||
interpreter, NOT of ``$HERMES_HOME``, which is why a code-scoped stamp
|
||||
here is immune to two installs sharing one data directory.
|
||||
installs, ``/opt/hermes`` inside the published image. It is a property of
|
||||
the running interpreter, NOT of ``$HERMES_HOME``, which is why a
|
||||
code-scoped stamp here is immune to two installs sharing one data
|
||||
directory.
|
||||
"""
|
||||
if project_root is not None:
|
||||
return project_root
|
||||
|
|
@ -386,7 +394,7 @@ def _install_method_project_root(project_root: Optional[Path] = None) -> Path:
|
|||
|
||||
|
||||
def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||||
"""Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'.
|
||||
"""Detect how Hermes was installed: 'docker', 'nixos', 'git', or 'unknown'.
|
||||
|
||||
Resolution order:
|
||||
1. Code-scoped stamp ``<install tree>/.install_method`` (next to the
|
||||
|
|
@ -394,9 +402,10 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
|||
2. Legacy home-scoped stamp ``$HERMES_HOME/.install_method`` — read for
|
||||
backward compatibility, but a ``docker`` value is IGNORED when we are
|
||||
not actually running inside a container (see below).
|
||||
3. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
|
||||
4. .git directory presence -> 'git'
|
||||
5. Fallback -> 'pip'
|
||||
3. HERMES_MANAGED env / .managed marker (NixOS managed mode)
|
||||
4. /nix/store/ path detection -> 'nixos' (nix run / nix profile install)
|
||||
5. .git directory presence -> 'git'
|
||||
6. Fallback -> 'unknown'
|
||||
|
||||
Why the stamp is code-scoped, not home-scoped (issue: shared ``~/.hermes``)
|
||||
--------------------------------------------------------------------------
|
||||
|
|
@ -415,7 +424,7 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
|||
Self-healing for already-poisoned homes: a legacy ``docker`` value in the
|
||||
home-scoped stamp is only honoured when we are genuinely in a container.
|
||||
On a host install that read a contaminating ``docker`` stamp, we fall
|
||||
through to managed/.git/pip detection instead — so existing shared-home
|
||||
through to managed/.git detection instead — so existing shared-home
|
||||
setups recover without the user touching anything.
|
||||
|
||||
Note: running inside a container is NOT treated as "docker" on its own.
|
||||
|
|
@ -425,15 +434,16 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
|||
- the published ``nousresearch/hermes-agent`` image bakes a ``docker``
|
||||
stamp into ``/opt/hermes`` at build time.
|
||||
An unsupported manual install dropped into a container (no stamp) falls
|
||||
through to the ``.git``/pip checks and behaves like any off-path install.
|
||||
through to the ``.git`` checks and behaves like any off-path install.
|
||||
See issue #34397.
|
||||
"""
|
||||
root = _install_method_project_root(project_root)
|
||||
supported_methods = {"docker", "nixos", "git", "unknown"}
|
||||
|
||||
# 1. Code-scoped stamp — authoritative, immune to shared $HERMES_HOME.
|
||||
try:
|
||||
method = (root / ".install_method").read_text(encoding="utf-8").strip().lower()
|
||||
if method:
|
||||
if method in supported_methods:
|
||||
return method
|
||||
except OSError:
|
||||
pass
|
||||
|
|
@ -449,7 +459,7 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
|||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if method and not (method == "docker" and not _running_in_container()):
|
||||
if method in supported_methods and not (method == "docker" and not _running_in_container()):
|
||||
return method
|
||||
except OSError:
|
||||
pass
|
||||
|
|
@ -457,7 +467,18 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
|||
managed = get_managed_system()
|
||||
if managed:
|
||||
return managed.lower().replace(" ", "-")
|
||||
|
||||
|
||||
# detect Nix installs that don't set HERMES_MANAGED (e.g. ``nix run``,
|
||||
# ``nix profile install``). The code lives under /nix/store/ which is the
|
||||
# hallmark of a nix-built install — no other supported install path puts
|
||||
# code there.
|
||||
try:
|
||||
resolved = root.resolve()
|
||||
if resolved != _NIX_STORE and _NIX_STORE in resolved.parents:
|
||||
return "nixos"
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# detect git repo installs (normal installer, development env)
|
||||
git_path = root / ".git"
|
||||
if git_path.is_dir():
|
||||
|
|
@ -471,7 +492,7 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
|||
return "git"
|
||||
except OSError:
|
||||
pass
|
||||
return "pip"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _running_in_container() -> bool:
|
||||
|
|
@ -505,49 +526,12 @@ def stamp_install_method(method: str, project_root: Optional[Path] = None) -> No
|
|||
pass
|
||||
|
||||
|
||||
def is_uv_tool_install() -> bool:
|
||||
"""Return True when the *running* Hermes lives in a ``uv tool`` layout.
|
||||
|
||||
``uv tool install hermes-agent`` places the install at
|
||||
``.../uv/tools/hermes-agent/...`` (default ``~/.local/share/uv/tools``,
|
||||
or ``$UV_TOOL_DIR/...``). Such installs live outside any virtualenv, so
|
||||
``uv pip install`` fails with ``No virtual environment found`` and the
|
||||
update path must use ``uv tool upgrade`` instead.
|
||||
|
||||
Detection is intentionally restricted to properties of the running
|
||||
interpreter (``sys.prefix`` / ``sys.executable``). We deliberately do
|
||||
NOT consult ``uv tool list``: it would also return True when
|
||||
``hermes-agent`` happens to be uv-tool-installed on the machine while
|
||||
the *active* Hermes is a regular pip/venv install, causing
|
||||
``hermes update`` to upgrade the wrong copy. It would also block on a
|
||||
subprocess call (~seconds) just to compute a recommendation string.
|
||||
"""
|
||||
def _has_uv_tool_marker(path: str) -> bool:
|
||||
norm = os.path.normpath(path).replace(os.sep, "/").lower()
|
||||
return "/uv/tools/hermes-agent/" in norm + "/"
|
||||
|
||||
if _has_uv_tool_marker(sys.prefix):
|
||||
return True
|
||||
if _has_uv_tool_marker(sys.executable or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def recommended_update_command_for_method(method: str) -> str:
|
||||
"""Return the update command or guidance for a given install method."""
|
||||
if method == "nixos":
|
||||
return _NIX_UPDATE_MSG
|
||||
if method == "homebrew":
|
||||
return "brew upgrade hermes-agent"
|
||||
if method == "docker":
|
||||
return "docker pull nousresearch/hermes-agent:latest"
|
||||
if method == "pip":
|
||||
if is_uv_tool_install():
|
||||
return "uv tool upgrade hermes-agent"
|
||||
import shutil
|
||||
if shutil.which("uv"):
|
||||
return "uv pip install --upgrade hermes-agent"
|
||||
return "pip install --upgrade hermes-agent"
|
||||
return "hermes update"
|
||||
|
||||
|
||||
|
|
@ -560,50 +544,6 @@ def recommended_update_command() -> str:
|
|||
return recommended_update_command_for_method(method)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Unsupported install methods (pip, Homebrew) — deprecation notice
|
||||
# =============================================================================
|
||||
#
|
||||
# pip/PyPI and Homebrew are NOT an officially supported distribution method
|
||||
# (see website/docs/getting-started/platform-support.md, "Unsupported"
|
||||
# section). pip exists on PyPI for internal/CI reasons, not end-user installs;
|
||||
# Homebrew is a legacy packaging path. Unlike NixOS/Homebrew "managed mode"
|
||||
# (which hard-blocks config writes), this is a warn-don't-block deprecation
|
||||
# notice surfaced everywhere the user might see install-method state: the CLI
|
||||
# banner, the TUI/desktop session info panel, and ``hermes update``. NixOS
|
||||
# stays fully supported (Tier 2) and must never hit this path.
|
||||
|
||||
PLATFORM_SUPPORT_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/getting-started/platform-support"
|
||||
|
||||
_UNSUPPORTED_INSTALL_METHODS = frozenset({"pip", "homebrew"})
|
||||
|
||||
|
||||
def is_unsupported_install_method(method: str) -> bool:
|
||||
"""Whether ``method`` (from ``detect_install_method()``) is deprecated."""
|
||||
return method in _UNSUPPORTED_INSTALL_METHODS
|
||||
|
||||
|
||||
def unsupported_install_method_label(method: str) -> str:
|
||||
"""Human-readable name for an unsupported install method."""
|
||||
return "pip" if method == "pip" else "Homebrew"
|
||||
|
||||
|
||||
def format_unsupported_install_warning(method: str) -> str:
|
||||
"""Plain-text (no markup) deprecation notice for pip/Homebrew installs.
|
||||
|
||||
Shared verbatim across the CLI banner, TUI/desktop ``session.info``, and
|
||||
``hermes update`` / ``hermes update --check`` so the wording — and the
|
||||
docs link — stays consistent across every surface instead of drifting
|
||||
into three slightly different warnings.
|
||||
"""
|
||||
label = unsupported_install_method_label(method)
|
||||
return (
|
||||
f"{label} installs are no longer an officially supported platform and "
|
||||
f"will not receive further updates. See {PLATFORM_SUPPORT_DOCS_URL} "
|
||||
"for supported install methods."
|
||||
)
|
||||
|
||||
|
||||
# Long-form text for ``hermes update`` / ``--check`` when running inside the
|
||||
# Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in
|
||||
# hermes_cli/main.py; lives here so the wording stays consistent and we
|
||||
|
|
@ -670,15 +610,6 @@ def format_managed_message(action: str = "modify this Hermes installation") -> s
|
|||
" sudo nixos-rebuild switch"
|
||||
)
|
||||
|
||||
if managed_system == "Homebrew":
|
||||
env_hint = raw or "homebrew"
|
||||
return (
|
||||
f"Cannot {action}: this Hermes installation is managed by Homebrew "
|
||||
f"(HERMES_MANAGED={env_hint}).\n"
|
||||
"Use:\n"
|
||||
" brew upgrade hermes-agent"
|
||||
)
|
||||
|
||||
return (
|
||||
f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n"
|
||||
"Use your package manager to upgrade or reinstall Hermes."
|
||||
|
|
|
|||
|
|
@ -1180,7 +1180,7 @@ class HermesConsoleEngine:
|
|||
"model",
|
||||
"moa",
|
||||
"oneshot",
|
||||
"postinstall",
|
||||
|
||||
"proxy",
|
||||
"serve",
|
||||
"setup",
|
||||
|
|
|
|||
|
|
@ -406,7 +406,7 @@ from hermes_cli.subcommands.gateway import build_gateway_parser
|
|||
from hermes_cli.subcommands.profile import build_profile_parser
|
||||
from hermes_cli.subcommands.model import build_model_parser
|
||||
from hermes_cli.subcommands.setup import build_setup_parser
|
||||
from hermes_cli.subcommands.postinstall import build_postinstall_parser
|
||||
|
||||
from hermes_cli.subcommands.whatsapp import build_whatsapp_parser
|
||||
from hermes_cli.subcommands.slack import build_slack_parser
|
||||
from hermes_cli.subcommands.login import build_login_parser
|
||||
|
|
@ -1886,14 +1886,15 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
|||
)
|
||||
sys.exit(1)
|
||||
|
||||
# 1. Prebuilt bundle (nix / packaged release): just run it.
|
||||
# 1. Prebuilt bundle (nix / packaged release / Docker image): just run it.
|
||||
#
|
||||
# This must run BEFORE _ensure_tui_workspace() below. A pip/pipx install
|
||||
# ships hermes_cli/tui_dist/entry.js in the wheel but never ships ui-tui/
|
||||
# at all (that directory only exists in a git checkout) — so requiring
|
||||
# the workspace to exist first made every pip/pipx dashboard Chat tab
|
||||
# connection hard-exit before it ever got a chance to try the bundled
|
||||
# entry.js it already has. See #56665.
|
||||
# This must run BEFORE _ensure_tui_workspace() below. A prebuilt install
|
||||
# (Docker image, Nix build, or prior `npm run build`) ships
|
||||
# hermes_cli/tui_dist/entry.js but never ships ui-tui/ at all (that
|
||||
# directory only exists in a git checkout) — so requiring the workspace
|
||||
# to exist first made every prebuilt dashboard Chat tab connection
|
||||
# hard-exit before it ever got a chance to try the bundled entry.js it
|
||||
# already has. See #56665.
|
||||
if not tui_dev:
|
||||
if ext_dir:
|
||||
p = Path(ext_dir)
|
||||
|
|
@ -1901,7 +1902,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
|||
node = _node_bin("node")
|
||||
return [node, "--expose-gc", str(p / "dist" / "entry.js")], p
|
||||
|
||||
# 1b. Bundled in wheel (pip install)
|
||||
# 1b. Bundled prebuilt TUI (Docker image, Nix build, or prior npm build)
|
||||
bundled = _find_bundled_tui()
|
||||
if bundled is not None:
|
||||
node = _node_bin("node")
|
||||
|
|
@ -2902,27 +2903,6 @@ def cmd_setup(args):
|
|||
run_setup_wizard(args)
|
||||
|
||||
|
||||
def cmd_postinstall(args):
|
||||
"""One-shot bootstrap for pip users: install non-Python deps + run setup."""
|
||||
from hermes_cli.config import stamp_install_method
|
||||
from hermes_cli.dep_ensure import ensure_dependency
|
||||
|
||||
stamp_install_method("pip")
|
||||
|
||||
print("⚕ Hermes post-install bootstrap")
|
||||
print()
|
||||
|
||||
for dep in ("node", "browser", "ripgrep", "ffmpeg"):
|
||||
ensure_dependency(dep)
|
||||
|
||||
if not _has_any_provider_configured():
|
||||
print()
|
||||
cmd_setup(args)
|
||||
else:
|
||||
print()
|
||||
print("✓ Post-install complete.")
|
||||
|
||||
|
||||
def cmd_model(args):
|
||||
"""Select default model — starts with provider selection, then model picker."""
|
||||
_require_tty("model")
|
||||
|
|
@ -8962,18 +8942,11 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
|
|||
on origin/<branch>?" without performing the update.
|
||||
|
||||
``branch_explicit`` is True iff the caller passed --branch on the CLI.
|
||||
PyPI installs can't honor non-default branches, so when this is True
|
||||
on a PyPI install we surface a one-line notice instead of silently
|
||||
dropping the flag.
|
||||
Installs that can't honor non-default branches (e.g. Docker) surface a
|
||||
one-line notice instead of silently dropping the flag.
|
||||
"""
|
||||
from hermes_cli.config import (
|
||||
detect_install_method,
|
||||
format_unsupported_install_warning,
|
||||
is_unsupported_install_method,
|
||||
)
|
||||
from hermes_cli.config import detect_install_method
|
||||
method = detect_install_method(PROJECT_ROOT)
|
||||
if is_unsupported_install_method(method):
|
||||
print(f"⚠ {format_unsupported_install_warning(method)}")
|
||||
if method == "docker":
|
||||
# Docker can't ``git fetch`` from within the container. Surface the
|
||||
# same long-form ``docker pull`` guidance ``hermes update`` (apply
|
||||
|
|
@ -8982,21 +8955,6 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
|
|||
from hermes_cli.config import format_docker_update_message
|
||||
print(format_docker_update_message())
|
||||
sys.exit(1)
|
||||
if method == "pip":
|
||||
from hermes_cli.config import recommended_update_command
|
||||
from hermes_cli.banner import check_via_pypi
|
||||
if branch_explicit and branch != "main":
|
||||
print(f"⚠ --branch is ignored for PyPI installs (would have checked '{branch}').")
|
||||
result = check_via_pypi()
|
||||
if result is None:
|
||||
print("✗ Could not reach PyPI to check for updates.")
|
||||
sys.exit(1)
|
||||
elif result == 0:
|
||||
print("✓ Already up to date.")
|
||||
else:
|
||||
print("⚕ Update available on PyPI.")
|
||||
print(f" Run '{recommended_update_command()}' to install.")
|
||||
return
|
||||
|
||||
git_dir = PROJECT_ROOT / ".git"
|
||||
if not git_dir.exists():
|
||||
|
|
@ -9927,21 +9885,10 @@ def cmd_update(args):
|
|||
from hermes_cli.config import (
|
||||
detect_install_method,
|
||||
format_docker_update_message,
|
||||
format_unsupported_install_warning,
|
||||
is_managed,
|
||||
is_unsupported_install_method,
|
||||
managed_error,
|
||||
)
|
||||
|
||||
# Deprecation notice for pip/Homebrew installs — printed before the
|
||||
# managed-mode early-return below so Homebrew users (who are blocked from
|
||||
# applying the update here) still see it. Warn, don't block: the update
|
||||
# itself still proceeds (except Homebrew, which is managed-mode blocked
|
||||
# for an unrelated reason — brew owns its own upgrade path).
|
||||
_install_method_for_warning = detect_install_method(PROJECT_ROOT)
|
||||
if is_unsupported_install_method(_install_method_for_warning):
|
||||
print(f"⚠ {format_unsupported_install_warning(_install_method_for_warning)}")
|
||||
|
||||
if is_managed():
|
||||
managed_error("update Hermes Agent")
|
||||
return
|
||||
|
|
@ -9978,67 +9925,6 @@ def cmd_update(args):
|
|||
_finalize_update_output(_update_io_state)
|
||||
|
||||
|
||||
def _cmd_update_pip(args):
|
||||
"""Update Hermes via pip (for PyPI installs)."""
|
||||
from hermes_cli import __version__
|
||||
from hermes_cli.config import is_uv_tool_install
|
||||
|
||||
print(f"→ Current version: {__version__}")
|
||||
print("→ Checking PyPI for updates...")
|
||||
|
||||
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
|
||||
|
||||
# Keep managed uv current before using it.
|
||||
update_managed_uv()
|
||||
|
||||
uv = ensure_uv()
|
||||
in_venv = sys.prefix != sys.base_prefix
|
||||
# pipx-managed installs live under .../pipx/venvs/<name>/...
|
||||
pipx_managed = "pipx" in sys.prefix.split(os.sep)
|
||||
pipx = shutil.which("pipx") if pipx_managed else None
|
||||
|
||||
# Only the ``uv pip install`` path inside a venv needs VIRTUAL_ENV
|
||||
# exported (uv refuses to install without it when the launcher shim
|
||||
# didn't activate the venv). ``uv tool upgrade`` / ``pipx upgrade``
|
||||
# operate on a named environment and ignore VIRTUAL_ENV, so we don't
|
||||
# set it for them.
|
||||
export_virtualenv = False
|
||||
|
||||
if is_uv_tool_install():
|
||||
if not uv:
|
||||
print("✗ Detected a uv-tool install but managed uv install failed.")
|
||||
print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/")
|
||||
sys.exit(1)
|
||||
cmd = [uv, "tool", "upgrade", "hermes-agent"]
|
||||
elif pipx_managed and pipx:
|
||||
# pipx owns its own venv; ``pipx upgrade`` is the only correct path.
|
||||
# Matches scripts/auto-update.sh, which already uses pipx upgrade.
|
||||
cmd = [pipx, "upgrade", "hermes-agent"]
|
||||
elif uv:
|
||||
cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"]
|
||||
if in_venv:
|
||||
# Launcher shim runs the venv interpreter but doesn't export
|
||||
# VIRTUAL_ENV; without it uv errors "No virtual environment found".
|
||||
export_virtualenv = True
|
||||
else:
|
||||
# Outside any venv, ``--system`` lets uv target the active
|
||||
# interpreter, matching pip's default behaviour.
|
||||
cmd.insert(3, "--system")
|
||||
else:
|
||||
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"]
|
||||
|
||||
print(f"→ Running: {' '.join(cmd)}")
|
||||
run_kwargs = {}
|
||||
if export_virtualenv:
|
||||
run_kwargs["env"] = {**os.environ, "VIRTUAL_ENV": sys.prefix}
|
||||
result = subprocess.run(cmd, **run_kwargs)
|
||||
if result.returncode != 0:
|
||||
print("✗ Update failed")
|
||||
sys.exit(1)
|
||||
|
||||
print("✓ Update complete! Restart hermes to use the new version.")
|
||||
|
||||
|
||||
def _cmd_update_impl(args, gateway_mode: bool):
|
||||
"""Body of ``cmd_update`` — kept separate so the wrapper can always
|
||||
restore stdio even on ``sys.exit``."""
|
||||
|
|
@ -10130,11 +10016,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
if sys.platform == "win32":
|
||||
use_zip_update = True
|
||||
else:
|
||||
from hermes_cli.config import detect_install_method
|
||||
method = detect_install_method(PROJECT_ROOT)
|
||||
if method == "pip":
|
||||
_cmd_update_pip(args)
|
||||
return
|
||||
print("✗ Not a git repository. Please reinstall:")
|
||||
print(
|
||||
" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
|
||||
|
|
@ -13089,7 +12970,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
|
|||
"dump", "fallback", "gateway", "hooks", "import", "insights",
|
||||
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa",
|
||||
"journey", "memory-graph", "learning",
|
||||
"model", "pairing", "pets", "plugins", "portal", "postinstall", "profile",
|
||||
"model", "pairing", "pets", "plugins", "portal", "profile",
|
||||
"project", "proxy",
|
||||
"prompt-size",
|
||||
"send", "sessions", "setup",
|
||||
|
|
@ -13790,10 +13671,6 @@ def main():
|
|||
# =========================================================================
|
||||
build_setup_parser(subparsers, cmd_setup=cmd_setup)
|
||||
|
||||
# =========================================================================
|
||||
# postinstall command (parser built in hermes_cli/subcommands/postinstall.py)
|
||||
# =========================================================================
|
||||
build_postinstall_parser(subparsers, cmd_postinstall=cmd_postinstall)
|
||||
|
||||
# =========================================================================
|
||||
# whatsapp command (parser built in hermes_cli/subcommands/whatsapp.py)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import os
|
|||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import sysconfig
|
||||
from contextvars import ContextVar, Token
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -191,23 +190,6 @@ def get_default_hermes_root() -> Path:
|
|||
return env_path
|
||||
|
||||
|
||||
def _get_packaged_data_dir(name: str) -> Path | None:
|
||||
"""Return an installed data-files directory if one exists.
|
||||
|
||||
Used to discover bundled skills/optional-skills when Hermes is installed
|
||||
from a wheel that emitted them via setuptools data_files.
|
||||
"""
|
||||
candidates = []
|
||||
for scheme in ("data", "purelib", "platlib"):
|
||||
raw = sysconfig.get_path(scheme)
|
||||
if raw:
|
||||
candidates.append(Path(raw) / name)
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def get_optional_skills_dir(default: Path | None = None) -> Path:
|
||||
"""Return the optional-skills directory, honoring package-manager wrappers.
|
||||
|
||||
|
|
@ -217,9 +199,6 @@ def get_optional_skills_dir(default: Path | None = None) -> Path:
|
|||
override = os.getenv("HERMES_OPTIONAL_SKILLS", "").strip()
|
||||
if override:
|
||||
return Path(override)
|
||||
packaged = _get_packaged_data_dir("optional-skills")
|
||||
if packaged is not None:
|
||||
return packaged
|
||||
if default is not None:
|
||||
return default
|
||||
return get_hermes_home() / "optional-skills"
|
||||
|
|
@ -236,9 +215,6 @@ def get_optional_mcps_dir(default: Path | None = None) -> Path:
|
|||
override = os.getenv("HERMES_OPTIONAL_MCPS", "").strip()
|
||||
if override:
|
||||
return Path(override)
|
||||
packaged = _get_packaged_data_dir("optional-mcps")
|
||||
if packaged is not None:
|
||||
return packaged
|
||||
if default is not None:
|
||||
return default
|
||||
return get_hermes_home() / "optional-mcps"
|
||||
|
|
@ -249,16 +225,12 @@ def get_bundled_skills_dir(default: Path | None = None) -> Path:
|
|||
|
||||
Resolution order:
|
||||
1. ``HERMES_BUNDLED_SKILLS`` env var (Nix wrapper / explicit override)
|
||||
2. Wheel-installed ``<sysconfig data>/skills`` (pip install path)
|
||||
3. Caller-supplied ``default`` (typically the source-checkout path)
|
||||
4. ``<HERMES_HOME>/skills`` last-resort
|
||||
2. Caller-supplied ``default`` (typically the source-checkout path)
|
||||
3. ``<HERMES_HOME>/skills`` last-resort
|
||||
"""
|
||||
override = os.getenv("HERMES_BUNDLED_SKILLS", "").strip()
|
||||
if override:
|
||||
return Path(override)
|
||||
packaged = _get_packaged_data_dir("skills")
|
||||
if packaged is not None:
|
||||
return packaged
|
||||
if default is not None:
|
||||
return default
|
||||
return get_hermes_home() / "skills"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -86,18 +86,16 @@ let
|
|||
# i18n locale catalogs (locales/*.yaml). Shipped into the store and pointed
|
||||
# at by HERMES_BUNDLED_LOCALES so the wrapped binary always resolves human
|
||||
# strings instead of raw i18n keys (#23943 / #27632 / #35374).
|
||||
#
|
||||
# Defense-in-depth, not load-bearing: the wheel already declares locales/ as
|
||||
# setuptools data-files, so uv2nix materializes them into the venv's data
|
||||
# scheme and agent/i18n.py resolves them with no env var. The wrapper override
|
||||
# pins the store path so a future uv2nix change that drops data-files can't
|
||||
# silently ship raw keys via `nix build` (checks don't run on a plain build).
|
||||
# The bundled-locales flake check verifies BOTH paths independently.
|
||||
#
|
||||
# Plain cleanSource (no __pycache__ filter): locales/ is bare *.yaml, never
|
||||
# compiled, so it never carries a __pycache__ dir to exclude.
|
||||
bundledLocales = lib.cleanSource ../locales;
|
||||
|
||||
# Shipped MCP catalog (optional-mcps/<name>/manifest.yaml). Same bare-data-dir
|
||||
# case as locales: not a Python package, so it's symlinked into the store and
|
||||
# exposed via HERMES_OPTIONAL_MCPS.
|
||||
bundledOptionalMcps = lib.cleanSourceWith {
|
||||
src = ../optional-mcps;
|
||||
filter = path: _type: !(lib.hasInfix "/__pycache__/" path);
|
||||
};
|
||||
|
||||
runtimeDeps = [
|
||||
nodejs
|
||||
ripgrep
|
||||
|
|
@ -181,6 +179,7 @@ stdenv.mkDerivation (finalAttrs: {
|
|||
ln -s ${bundledOptionalSkills} $out/share/hermes-agent/optional-skills
|
||||
ln -s ${bundledPlugins} $out/share/hermes-agent/plugins
|
||||
ln -s ${bundledLocales} $out/share/hermes-agent/locales
|
||||
ln -s ${bundledOptionalMcps} $out/share/hermes-agent/optional-mcps
|
||||
ln -s ${hermesWeb} $out/share/hermes-agent/web_dist
|
||||
ln -s ${hermesTui}/lib/hermes-tui $out/ui-tui
|
||||
|
||||
|
|
@ -192,6 +191,7 @@ stdenv.mkDerivation (finalAttrs: {
|
|||
--set HERMES_OPTIONAL_SKILLS $out/share/hermes-agent/optional-skills \
|
||||
--set HERMES_BUNDLED_PLUGINS $out/share/hermes-agent/plugins \
|
||||
--set HERMES_BUNDLED_LOCALES $out/share/hermes-agent/locales \
|
||||
--set HERMES_OPTIONAL_MCPS $out/share/hermes-agent/optional-mcps \
|
||||
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
|
||||
--set HERMES_TUI_DIR $out/ui-tui \
|
||||
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
});
|
||||
})
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
class HermesAgent < Formula
|
||||
include Language::Python::Virtualenv
|
||||
|
||||
desc "Self-improving AI agent that creates skills from experience"
|
||||
homepage "https://hermes-agent.nousresearch.com"
|
||||
# Stable source should point at the semver-named sdist asset attached by
|
||||
# scripts/release.py, not the CalVer tag tarball.
|
||||
url "https://github.com/NousResearch/hermes-agent/releases/download/v2026.3.30/hermes_agent-0.6.0.tar.gz"
|
||||
sha256 "<replace-with-release-asset-sha256>"
|
||||
license "MIT"
|
||||
|
||||
depends_on "certifi" => :no_linkage
|
||||
depends_on "cryptography" => :no_linkage
|
||||
depends_on "libyaml"
|
||||
depends_on "python@3.14"
|
||||
|
||||
pypi_packages ignore_packages: %w[certifi cryptography pydantic]
|
||||
|
||||
# Refresh resource stanzas after bumping the source url/version:
|
||||
# brew update-python-resources --print-only hermes-agent
|
||||
|
||||
def install
|
||||
venv = virtualenv_create(libexec, "python3.14")
|
||||
venv.pip_install resources
|
||||
venv.pip_install buildpath
|
||||
|
||||
pkgshare.install "skills", "optional-skills"
|
||||
|
||||
%w[hermes hermes-agent hermes-acp].each do |exe|
|
||||
next unless (libexec/"bin"/exe).exist?
|
||||
|
||||
(bin/exe).write_env_script(
|
||||
libexec/"bin"/exe,
|
||||
HERMES_BUNDLED_SKILLS: pkgshare/"skills",
|
||||
HERMES_OPTIONAL_SKILLS: pkgshare/"optional-skills",
|
||||
HERMES_MANAGED: "homebrew"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match "Hermes Agent v#{version}", shell_output("#{bin}/hermes version")
|
||||
|
||||
managed = shell_output("#{bin}/hermes update 2>&1")
|
||||
assert_match "managed by Homebrew", managed
|
||||
assert_match "brew upgrade hermes-agent", managed
|
||||
end
|
||||
end
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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="💼",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
# PEP 639 SPDX license expression (`license = "MIT"` below) requires
|
||||
# setuptools>=77. Keep this floor in lockstep with the `license` form in
|
||||
# [project]; an older build backend rejects the string form.
|
||||
[build-system]
|
||||
requires = ["setuptools>=77.0,<83"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
|
||||
[project]
|
||||
name = "hermes-agent"
|
||||
|
|
@ -175,18 +170,11 @@ wecom = ["defusedxml==0.7.1"]
|
|||
cli = ["simple-term-menu==1.6.6"]
|
||||
tts-premium = ["elevenlabs==1.59.0"]
|
||||
voice = [
|
||||
# Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime),
|
||||
# so keep it out of the base install for source-build packagers like Homebrew.
|
||||
# Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime).
|
||||
"faster-whisper==1.2.1",
|
||||
"sounddevice==0.5.5",
|
||||
"numpy==2.4.3",
|
||||
]
|
||||
pty = [
|
||||
# Kept as a no-op back-compat alias — `ptyprocess` and `pywinpty` are now
|
||||
# in the main `dependencies` list (with the same platform markers), so
|
||||
# any existing `pip install hermes-agent[pty]` invocations resolve cleanly
|
||||
# without pulling in extra packages.
|
||||
]
|
||||
honcho = ["honcho-ai==2.2.0"]
|
||||
# Cloud memory providers — opt-in, lazy-installed via tools/lazy_deps.py
|
||||
# (memory.supermemory / memory.mem0) at first use. Exact pins MUST match the
|
||||
|
|
@ -196,11 +184,16 @@ honcho = ["honcho-ai==2.2.0"]
|
|||
supermemory = ["supermemory==3.50.0"]
|
||||
mem0 = ["mem0ai==2.0.10"]
|
||||
# Image resize recovery for the vision tools. Pillow is now a CORE dependency
|
||||
# (see the main `dependencies` list) since the byte/pixel shrink paths are on
|
||||
# (see the main `dependencies` list above) since the byte/pixel shrink paths are on
|
||||
# the default vision-embed path and the mid-session lazy install deadlocked the
|
||||
# CLI under prompt_toolkit (#40490). This extra is kept as a no-op back-compat
|
||||
# alias so existing `pip install hermes-agent[vision]` invocations still resolve.
|
||||
# alias so existing requests for the `vision` extra resolve.
|
||||
vision = []
|
||||
# Kept as a no-op back-compat alias — `ptyprocess` and `pywinpty` are now
|
||||
# in the main `dependencies` list (with the same platform markers), so
|
||||
# any existing requests for the `pty` extra resolve cleanly
|
||||
# without pulling in extra packages.
|
||||
pty = []
|
||||
# CVE-2026-48710 (BadHost): Starlette is pulled transitively by mcp's
|
||||
# sse-starlette / HTTP-SSE stack (and by fastapi in the `web` extra). Before
|
||||
# 1.0.1, a malformed Host header makes `request.url.path` desync from the path
|
||||
|
|
@ -236,7 +229,6 @@ termux = [
|
|||
"python-telegram-bot[webhooks]==22.6",
|
||||
"hermes-agent[cron]",
|
||||
"hermes-agent[cli]",
|
||||
"hermes-agent[pty]",
|
||||
"hermes-agent[mcp]",
|
||||
"hermes-agent[honcho]",
|
||||
"hermes-agent[acp]",
|
||||
|
|
@ -251,14 +243,15 @@ termux-all = [
|
|||
"hermes-agent[homeassistant]",
|
||||
"hermes-agent[sms]",
|
||||
"hermes-agent[web]",
|
||||
"hermes-agent[pty]",
|
||||
]
|
||||
dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"]
|
||||
feishu = ["lark-oapi==1.6.8", "qrcode==7.4.2"]
|
||||
google = [
|
||||
# Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts,
|
||||
# Sheets, Docs). Declared here so packagers (Nix, Homebrew) ship them with
|
||||
# the [all] extra and users don't hit runtime `pip install` paths that fail
|
||||
# in environments without pip (e.g. Nix-managed Python).
|
||||
# Sheets, Docs). Declared here so dev environments (`uv sync --extra google`)
|
||||
# and packagers ship them without hitting runtime `pip install` paths that
|
||||
# fail in environments without pip (e.g. Nix-managed Python).
|
||||
"google-api-python-client==2.194.0",
|
||||
"google-auth-oauthlib==1.3.1",
|
||||
"google-auth-httplib2==0.3.1",
|
||||
|
|
@ -278,11 +271,11 @@ all = [
|
|||
# Policy (2026-05-12): `[all]` includes only extras that genuinely
|
||||
# CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every
|
||||
# session can use, things needed before the agent loop is alive
|
||||
# (terminal/CLI), and skill deps that packagers (Nix, AUR, Homebrew)
|
||||
# need in the wheel. Anything an opt-in backend (provider, search,
|
||||
# TTS, image, memory, messaging platform, terminal sandbox) needs
|
||||
# MUST live exclusively in `LAZY_DEPS` and resolve at first use —
|
||||
# otherwise one quarantined PyPI release breaks every fresh install.
|
||||
# (terminal/CLI), and skill deps that dev environments need.
|
||||
# Anything an opt-in backend (provider, search, TTS, image, memory,
|
||||
# messaging platform, terminal sandbox) needs MUST live exclusively in
|
||||
# `LAZY_DEPS` and resolve at first use — otherwise one quarantined PyPI
|
||||
# release breaks every fresh install.
|
||||
#
|
||||
# Removed from [all] on 2026-05-12 (covered by lazy-install):
|
||||
# anthropic, exa, firecrawl, parallel-web, fal, edge-tts,
|
||||
|
|
@ -308,58 +301,31 @@ all = [
|
|||
"hermes-agent[youtube]",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=77.0,<83"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project.scripts]
|
||||
hermes = "hermes_cli.main:main"
|
||||
hermes-agent = "run_agent:main"
|
||||
hermes-acp = "acp_adapter.entry:main"
|
||||
|
||||
[tool.setuptools]
|
||||
# Top-level single-file modules (not packages). Without this, uv2nix's
|
||||
# sealed venv is missing hermes_constants, run_agent, etc.
|
||||
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils", "mcp_serve"]
|
||||
|
||||
[tool.setuptools.data-files]
|
||||
# i18n catalogs. locales/ is a bare data directory (no __init__.py), so it is
|
||||
# neither a package (packages.find) nor package-data (which attaches to a
|
||||
# package). data-files ships it in the wheel; MANIFEST.in `graft locales`
|
||||
# ships it in the sdist. Without this, sealed installs (pip wheel, Nix store
|
||||
# venv) drop the catalogs and gateway/CLI commands surface raw i18n keys like
|
||||
# `gateway.reset.header_default` (#27632, #35374, #23943).
|
||||
locales = ["locales/*.yaml"]
|
||||
# Shipped MCP catalog (optional-mcps/<name>/manifest.yaml). Same bare-data-dir
|
||||
# case as locales: data-files ships it in the wheel, `graft optional-mcps` in
|
||||
# MANIFEST.in ships it in the sdist. Without this, `hermes mcp catalog` and the
|
||||
# dashboard catalog screen come up empty on packaged installs even though the
|
||||
# manifests exist in the repo (hermes_cli/mcp_catalog.py:_catalog_root resolves
|
||||
# the packaged dir; list_catalog() returns [] when it's missing).
|
||||
#
|
||||
# data-files flattens every glob match into its single target dir, so each
|
||||
# catalog entry needs its OWN target to preserve the per-entry directory the
|
||||
# catalog iterates over (a shared `optional-mcps/*/*` glob would collapse all
|
||||
# manifests into one colliding optional-mcps/manifest.yaml). One target per
|
||||
# entry; tests/test_packaging_metadata.py enforces an entry per optional-mcps/<name>.
|
||||
"optional-mcps/linear" = ["optional-mcps/linear/manifest.yaml"]
|
||||
"optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
|
||||
gateway = ["assets/**/*"]
|
||||
plugins = [
|
||||
"*/dashboard/manifest.json",
|
||||
"*/dashboard/dist/*",
|
||||
"*/dashboard/dist/**/*",
|
||||
# Plugin discovery (hermes_cli/plugins.py) reads a plugin.yaml/plugin.yml
|
||||
# manifest from each bundled plugin directory to register it. Wheels only
|
||||
# carry files declared here, so without this glob the wheel ships every
|
||||
# plugin's Python code but none of its manifests — the scan finds zero
|
||||
# plugins and all gateway platforms fail with "No adapter available for
|
||||
# <platform>" (#34034), web-search providers go missing (#28149), etc.
|
||||
"**/plugin.yaml",
|
||||
"**/plugin.yml",
|
||||
"**/README.md",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "cron.*", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
# gateway/assets/ ships status_phrases.yaml and the Telegram BotFather
|
||||
# screenshot. Without this, sealed venvs (uv2nix) silently lose both —
|
||||
# status phrases fall back to the tiny hardcoded set and the Telegram
|
||||
# topic-setup image disappears. Loaded via Path(__file__).parent / "assets"
|
||||
# in gateway/status_phrases.py and gateway/run.py.
|
||||
gateway = ["assets/**/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -34,11 +34,6 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
|
|||
VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py"
|
||||
PYPROJECT_FILE = REPO_ROOT / "pyproject.toml"
|
||||
|
||||
# ACP Registry manifest must stay version-locked with pyproject.toml.
|
||||
# tests/acp/test_registry_manifest.py enforces this lockstep so the release
|
||||
# bump touches both files atomically.
|
||||
ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Git email → GitHub username mapping
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -2203,70 +2198,6 @@ def update_version_files(semver: str, calver_date: str):
|
|||
)
|
||||
desktop_pkg.write_text(pkg_text, encoding="utf-8")
|
||||
|
||||
# Update ACP Registry manifest + npm launcher (must stay version-locked
|
||||
# with pyproject — enforced by tests/acp/test_registry_manifest.py).
|
||||
_update_acp_registry_versions(semver)
|
||||
|
||||
|
||||
def _update_acp_registry_versions(semver: str) -> None:
|
||||
"""Bump the ACP Registry manifest's version + uvx package pin in lockstep
|
||||
with pyproject.
|
||||
|
||||
Skips silently if the manifest is missing — older release branches predate
|
||||
the ACP Registry assets.
|
||||
"""
|
||||
if ACP_REGISTRY_MANIFEST.exists():
|
||||
manifest = json.loads(ACP_REGISTRY_MANIFEST.read_text(encoding="utf-8"))
|
||||
manifest["version"] = semver
|
||||
uvx = manifest.get("distribution", {}).get("uvx", {})
|
||||
if "package" in uvx:
|
||||
uvx["package"] = f"hermes-agent[acp]=={semver}"
|
||||
# Preserve trailing newline + 2-space indent the file already uses.
|
||||
ACP_REGISTRY_MANIFEST.write_text(
|
||||
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def build_release_artifacts(semver: str) -> list[Path]:
|
||||
"""Build sdist/wheel artifacts for the current release.
|
||||
|
||||
Tries ``uv build`` first (matching the CI workflow), falls back to
|
||||
``python -m build`` if uv is unavailable.
|
||||
"""
|
||||
dist_dir = REPO_ROOT / "dist"
|
||||
shutil.rmtree(dist_dir, ignore_errors=True)
|
||||
|
||||
# Prefer uv build (matches CI workflow), fall back to python -m build.
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
cmd = [uv_bin, "build", "--sdist", "--wheel"]
|
||||
else:
|
||||
cmd = [sys.executable, "-m", "build", "--sdist", "--wheel"]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(" ⚠ Could not build Python release artifacts.")
|
||||
stderr = result.stderr.strip()
|
||||
stdout = result.stdout.strip()
|
||||
if stderr:
|
||||
print(f" {stderr.splitlines()[-1]}")
|
||||
elif stdout:
|
||||
print(f" {stdout.splitlines()[-1]}")
|
||||
print(" Install uv or the 'build' package to attach sdist/wheel assets.")
|
||||
return []
|
||||
|
||||
artifacts = sorted(p for p in dist_dir.iterdir() if p.is_file())
|
||||
matching = [p for p in artifacts if semver in p.name]
|
||||
if not matching:
|
||||
print(" ⚠ Built artifacts did not match the expected release version.")
|
||||
return []
|
||||
return matching
|
||||
|
||||
|
||||
def resolve_author(name: str, email: str) -> str:
|
||||
"""Resolve a git author to a GitHub @mention."""
|
||||
|
|
@ -2606,8 +2537,6 @@ def main():
|
|||
|
||||
# Commit version bump
|
||||
add_files = [str(VERSION_FILE), str(PYPROJECT_FILE)]
|
||||
if ACP_REGISTRY_MANIFEST.exists():
|
||||
add_files.append(str(ACP_REGISTRY_MANIFEST))
|
||||
add_result = git_result("add", *add_files)
|
||||
if add_result.returncode != 0:
|
||||
print(f" ✗ Failed to stage version files: {add_result.stderr.strip()}")
|
||||
|
|
@ -2640,14 +2569,6 @@ def main():
|
|||
print(" Continue manually after fixing access:")
|
||||
print(" git push origin HEAD --tags")
|
||||
|
||||
# Build semver-named Python artifacts so downstream packagers
|
||||
# (e.g. Homebrew) can target them without relying on CalVer tag names.
|
||||
artifacts = build_release_artifacts(new_version)
|
||||
if artifacts:
|
||||
print(" ✓ Built release artifacts:")
|
||||
for artifact in artifacts:
|
||||
print(f" - {artifact.relative_to(REPO_ROOT)}")
|
||||
|
||||
# Create GitHub release
|
||||
changelog_file = REPO_ROOT / ".release_notes.md"
|
||||
changelog_file.write_text(changelog, encoding="utf-8")
|
||||
|
|
@ -2657,7 +2578,6 @@ def main():
|
|||
"--title", f"Hermes Agent v{new_version} ({calver_date})",
|
||||
"--notes-file", str(changelog_file),
|
||||
]
|
||||
gh_cmd.extend(str(path) for path in artifacts)
|
||||
|
||||
gh_bin = shutil.which("gh")
|
||||
if gh_bin:
|
||||
|
|
@ -2682,9 +2602,9 @@ def main():
|
|||
print(" Tag was created locally. Create the release manually:")
|
||||
print(
|
||||
f" gh release create {tag_name} --title 'Hermes Agent v{new_version} ({calver_date})' "
|
||||
f"--notes-file .release_notes.md {' '.join(str(path) for path in artifacts)}"
|
||||
f"--notes-file .release_notes.md"
|
||||
)
|
||||
print(f"\n ✓ Release artifacts prepared for manual publish: v{new_version} ({tag_name})")
|
||||
print(f"\n ✓ Release v{new_version} ({tag_name}) prepared for manual publish.")
|
||||
else:
|
||||
print(f"\n{'='*60}")
|
||||
print(" Dry run complete. To publish, add --publish")
|
||||
|
|
|
|||
149
setup.py
149
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -255,45 +255,6 @@ class TestCmdUpdateNpmLockfileCache:
|
|||
assert cache_roots == [shared_root, shared_root]
|
||||
|
||||
|
||||
class TestCmdUpdatePip:
|
||||
"""Regression tests for pip-install update flows."""
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/uv")
|
||||
@patch("subprocess.run")
|
||||
def test_update_pip_exports_virtualenv_from_sys_prefix(
|
||||
self, mock_run, _mock_which, mock_args, monkeypatch
|
||||
):
|
||||
from hermes_cli import main as hm
|
||||
|
||||
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
||||
monkeypatch.setattr(hm.sys, "prefix", "/tmp/hermes-launcher-venv")
|
||||
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
|
||||
|
||||
hm._cmd_update_pip(mock_args)
|
||||
|
||||
assert mock_run.call_count == 1
|
||||
assert mock_run.call_args.args[0] == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
|
||||
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/tmp/hermes-launcher-venv"
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/uv")
|
||||
@patch("subprocess.run")
|
||||
def test_update_pip_does_not_export_virtualenv_for_system_python(
|
||||
self, mock_run, _mock_which, mock_args, monkeypatch
|
||||
):
|
||||
from hermes_cli import main as hm
|
||||
|
||||
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
||||
monkeypatch.setattr(hm.sys, "prefix", "/usr")
|
||||
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
|
||||
|
||||
hm._cmd_update_pip(mock_args)
|
||||
|
||||
assert mock_run.call_count == 1
|
||||
assert "env" not in mock_run.call_args.kwargs
|
||||
|
||||
|
||||
class TestCmdUpdateTermuxUvBootstrap:
|
||||
"""Regression tests for Termux-specific uv bootstrap behavior."""
|
||||
|
||||
|
|
@ -989,21 +950,6 @@ class TestCmdUpdateCheckBranchFlag:
|
|||
rev_list_cmds = [c for c in commands if "rev-list" in c]
|
||||
assert any("upstream/main" in c for c in rev_list_cmds), rev_list_cmds
|
||||
|
||||
@patch("hermes_cli.config.detect_install_method", return_value="pip")
|
||||
@patch("hermes_cli.banner.check_via_pypi", return_value=0)
|
||||
@patch("subprocess.run")
|
||||
def test_check_branch_warns_on_pypi_install(
|
||||
self, mock_run, _mock_pypi, _mock_method, capsys
|
||||
):
|
||||
"""PyPI install + --branch=<non-main> surfaces a warning instead of silent drop."""
|
||||
args = SimpleNamespace(check=True, branch="bb/gui")
|
||||
|
||||
cmd_update(args)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "--branch is ignored for PyPI installs" in out
|
||||
assert "bb/gui" in out
|
||||
|
||||
|
||||
class TestCmdUpdateZipBranchRefusal:
|
||||
"""``hermes update --branch=<non-main>`` must refuse on the ZIP fallback path.
|
||||
|
|
|
|||
|
|
@ -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 ----------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -350,7 +350,7 @@ def test_console_registry_covers_non_admin_cli_surface():
|
|||
"oneshot hello",
|
||||
"model",
|
||||
"setup",
|
||||
"postinstall",
|
||||
|
||||
"fallback add",
|
||||
"moa configure",
|
||||
"claw migrate",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
46
tests/test_packaging_build_guard.py
Normal file
46
tests/test_packaging_build_guard.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""Behavioral regression coverage for the wheel/sdist distribution guard."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _build_sdist(tmp_path, *, nix_build: bool) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
# nix develop exports this too, so it must not grant permission to build
|
||||
# a distributable artifact.
|
||||
env["NIX_BUILD_TOP"] = "/build/devshell"
|
||||
if nix_build:
|
||||
env["HERMES_NIX_BUILD"] = "1"
|
||||
else:
|
||||
env.pop("HERMES_NIX_BUILD", None)
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from setuptools.build_meta import build_sdist; build_sdist(r'{}')".format(tmp_path),
|
||||
],
|
||||
cwd=PROJECT_ROOT,
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_sdist_rejects_nix_development_shell_environment(tmp_path):
|
||||
result = _build_sdist(tmp_path, nix_build=False)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Building wheels or sdists for hermes-agent is not supported" in result.stderr
|
||||
|
||||
|
||||
def test_sdist_allows_explicit_nix_package_build_marker(tmp_path):
|
||||
result = _build_sdist(tmp_path, nix_build=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert list(tmp_path.glob("hermes_agent-*.tar.gz"))
|
||||
|
|
@ -5,13 +5,6 @@ from pathlib import Path
|
|||
|
||||
import pytest
|
||||
|
||||
# setuptools is declared in the [dev] extra and is the build backend, but
|
||||
# guard the import so a runner without it skips these packaging checks
|
||||
# instead of erroring out collection for the whole shard (it used to be
|
||||
# picked up ambiently from the CI image; newer ubuntu-latest images don't
|
||||
# ship it in the test venv).
|
||||
find_packages = pytest.importorskip("setuptools", exc_type=ImportError).find_packages
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
|
@ -32,53 +25,6 @@ def _distribution_name(requirement: str) -> str:
|
|||
return spec.strip().lower()
|
||||
|
||||
|
||||
def _packages_find_include():
|
||||
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
return data["tool"]["setuptools"]["packages"]["find"]["include"]
|
||||
|
||||
|
||||
def test_every_on_disk_subpackage_is_covered_by_packages_find():
|
||||
"""Regression test for #34701 (and the bug class behind #34034 / #28149).
|
||||
|
||||
``[tool.setuptools.packages.find]`` ``include`` is hand-maintained. Every
|
||||
top-level package is listed twice — bare (``hermes_cli``) for the package
|
||||
itself and ``hermes_cli.*`` for its subpackages — EXCEPT when someone
|
||||
forgets the wildcard. v0.15.x listed ``hermes_cli`` without ``hermes_cli.*``,
|
||||
so the wheel shipped ``hermes_cli/*.py`` but dropped the ``dashboard_auth``
|
||||
and ``proxy`` subpackages. The dashboard then died on every install with
|
||||
``ModuleNotFoundError: No module named 'hermes_cli.dashboard_auth'``.
|
||||
|
||||
This drives setuptools' own discovery against the live tree: every package
|
||||
that exists on disk and would be found by a permissive ``<name>.*`` scan
|
||||
must also be found by the actual ``include`` list. A subpackage added under
|
||||
any listed package without the matching wildcard fails here instead of in a
|
||||
user's container.
|
||||
"""
|
||||
include = _packages_find_include()
|
||||
|
||||
# What the real include list actually selects.
|
||||
selected = set(find_packages(where=str(REPO_ROOT), include=include))
|
||||
|
||||
# Top-level packages we ship (bare names in the include list, no wildcard).
|
||||
top_level = sorted({name for name in include if "." not in name})
|
||||
|
||||
# For each shipped top-level package, every on-disk subpackage must be
|
||||
# covered by the include list.
|
||||
expected = set(
|
||||
find_packages(
|
||||
where=str(REPO_ROOT),
|
||||
include=[pattern for name in top_level for pattern in (name, f"{name}.*")],
|
||||
)
|
||||
)
|
||||
|
||||
missing = sorted(expected - selected)
|
||||
assert not missing, (
|
||||
"These packages exist on disk but are dropped from the wheel because "
|
||||
"[tool.setuptools.packages.find] include is missing a wildcard. Add the "
|
||||
f"matching '<name>.*' entry in pyproject.toml: {missing}"
|
||||
)
|
||||
|
||||
|
||||
def test_packaging_declared_as_core_dependency():
|
||||
"""Regression for #40503.
|
||||
|
||||
|
|
@ -110,52 +56,6 @@ def test_faster_whisper_is_not_a_base_dependency():
|
|||
assert any(dep.startswith("faster-whisper") for dep in voice_extra)
|
||||
|
||||
|
||||
def test_manifest_includes_bundled_skills():
|
||||
manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8")
|
||||
|
||||
assert "graft skills" in manifest
|
||||
assert "graft optional-skills" in manifest
|
||||
|
||||
|
||||
def test_bundled_plugin_manifests_ship_in_both_wheel_and_sdist():
|
||||
"""Regression test for #34034 / #28149.
|
||||
|
||||
Plugin discovery (hermes_cli/plugins.py) registers each bundled plugin by
|
||||
reading its ``plugin.yaml`` / ``plugin.yml`` manifest. Those manifests are
|
||||
data files, not Python modules, so they only reach installed packages when
|
||||
declared explicitly:
|
||||
|
||||
- wheel -> ``[tool.setuptools.package-data]`` ``plugins`` glob
|
||||
- sdist -> ``MANIFEST.in`` (Homebrew and other downstream packagers build
|
||||
from the sdist)
|
||||
|
||||
v0.15.0 declared neither, so the wheel shipped every adapter's Python code
|
||||
but none of its manifests, and *every* gateway platform failed with
|
||||
"No adapter available for <platform>". Both channels must cover manifests.
|
||||
"""
|
||||
# There must actually be manifests on disk for the globs to match.
|
||||
on_disk = list((REPO_ROOT / "plugins").rglob("plugin.yaml")) + list(
|
||||
(REPO_ROOT / "plugins").rglob("plugin.yml")
|
||||
)
|
||||
assert on_disk, "expected bundled plugin manifests under plugins/"
|
||||
|
||||
# Wheel channel: package-data must declare a glob that matches plugin
|
||||
# manifests anywhere under the plugins package.
|
||||
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
plugins_pkg_data = data["tool"]["setuptools"]["package-data"].get("plugins", [])
|
||||
assert any(
|
||||
g.endswith("plugin.yaml") or g.endswith("plugin.yml")
|
||||
for g in plugins_pkg_data
|
||||
), "pyproject package-data 'plugins' must ship plugin.yaml/plugin.yml (wheel)"
|
||||
|
||||
# Sdist channel: MANIFEST.in must recursively include the manifests so
|
||||
# downstream packagers building from the sdist also get them.
|
||||
manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8")
|
||||
assert "recursive-include plugins" in manifest and "plugin.yaml" in manifest, (
|
||||
"MANIFEST.in must recursive-include plugins plugin.yaml/plugin.yml (sdist)"
|
||||
)
|
||||
|
||||
|
||||
# Minimum non-vulnerable Starlette: CVE-2026-48710 ("BadHost") was fixed in
|
||||
# 1.0.1. Anything below that lets a malformed Host header desync
|
||||
# ``request.url.path`` from the dispatched ASGI path, bypassing path-based
|
||||
|
|
@ -241,32 +141,6 @@ def test_locked_starlette_is_not_vulnerable_to_cve_2026_48710():
|
|||
)
|
||||
|
||||
|
||||
def test_locale_catalogs_ship_in_both_wheel_and_sdist():
|
||||
"""Regression test for #27632 / #35374 / #23943.
|
||||
|
||||
locales/ is a bare data directory (no __init__.py), so it is invisible to
|
||||
packages.find and to package-data (which attaches to a package). It must be
|
||||
declared as setuptools data-files (wheel) AND grafted in MANIFEST.in
|
||||
(sdist). Without both, sealed installs drop the catalogs and gateway/CLI
|
||||
commands surface raw i18n keys like `gateway.reset.header_default`.
|
||||
"""
|
||||
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
data_files = data["tool"]["setuptools"].get("data-files", {})
|
||||
assert data_files.get("locales") == ["locales/*.yaml"], (
|
||||
"pyproject [tool.setuptools.data-files] must declare "
|
||||
'locales = ["locales/*.yaml"] so the wheel ships i18n catalogs'
|
||||
)
|
||||
|
||||
manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8")
|
||||
assert "graft locales" in manifest, (
|
||||
"MANIFEST.in must `graft locales` so the sdist ships i18n catalogs"
|
||||
)
|
||||
|
||||
# Every on-disk catalog has the .yaml extension the globs above match.
|
||||
on_disk = list((REPO_ROOT / "locales").glob("*.yaml"))
|
||||
assert on_disk, "expected locales/*.yaml catalogs on disk"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency-pin consistency: pyproject extras <-> tools/lazy_deps.py
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
"""End-to-end: a built wheel, installed without a source tree, must resolve
|
||||
i18n catalogs and render human strings — not raw key paths.
|
||||
|
||||
This is the test that would have caught #27632 / #35374 / #23943. Metadata
|
||||
unit tests (test_packaging_metadata.py) prove the glob is declared; this proves
|
||||
the runtime actually finds the catalogs after a real pip install.
|
||||
|
||||
This lives in tests/ (NOT tests/e2e/) so it is collected by the dedicated CI
|
||||
step in Task 9, not by the existing `python -m pytest tests/e2e/` runner.
|
||||
|
||||
Assumption: `from agent import i18n` must import with only stdlib + pyyaml
|
||||
available (the test installs the wheel --no-deps + pyyaml). agent/__init__.py's
|
||||
jiter preload swallows ImportError, and i18n.py imports yaml lazily inside
|
||||
_load_catalog, so this holds today. If i18n.py ever gains a top-level non-stdlib
|
||||
import, add it to the pip install line below.
|
||||
|
||||
Marked `integration` because it shells out to `uv build` + `venv` + `pip` and
|
||||
takes ~15-30s. Run with: pytest -m integration tests/test_wheel_locales_e2e.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import venv
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.timeout(300) # overrides the global --timeout=30; cold-CI wheel build + venv + pip can exceed it
|
||||
def test_installed_wheel_renders_i18n_strings(tmp_path):
|
||||
# 1. Build the wheel from the current tree.
|
||||
wheel_dir = tmp_path / "wheel"
|
||||
build = subprocess.run(
|
||||
["uv", "build", "--wheel", "--out-dir", str(wheel_dir), "."],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
assert build.returncode == 0, f"uv build failed:\n{build.stderr}"
|
||||
wheels = glob.glob(str(wheel_dir / "*.whl"))
|
||||
assert wheels, "no wheel produced"
|
||||
wheel = wheels[0]
|
||||
|
||||
# 2. Fresh venv, install the wheel WITHOUT deps (we only exercise i18n,
|
||||
# which needs pyyaml). --force-reinstall guards against pip's
|
||||
# same-version no-op.
|
||||
venv_dir = tmp_path / "venv"
|
||||
venv.create(venv_dir, with_pip=True)
|
||||
if sys.platform == "win32":
|
||||
vpy = venv_dir / "Scripts" / "python.exe"
|
||||
else:
|
||||
vpy = venv_dir / "bin" / "python"
|
||||
subprocess.run([str(vpy), "-m", "pip", "install", "-q", "pyyaml"], check=True, timeout=300)
|
||||
subprocess.run(
|
||||
[str(vpy), "-m", "pip", "install", "-q", "--no-deps", "--force-reinstall", wheel],
|
||||
check=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
# 3. Run from a directory that is NOT the source tree, with a clean env
|
||||
# (no PYTHONPATH leaking the repo, no HERMES_BUNDLED_LOCALES).
|
||||
probe = (
|
||||
"from agent import i18n;"
|
||||
"import sys;"
|
||||
"r = i18n.t('gateway.reset.header_default', lang='en');"
|
||||
"s = i18n.t('gateway.status.header', lang='en');"
|
||||
"print(repr(r)); print(repr(s));"
|
||||
"sys.exit(0 if (r != 'gateway.reset.header_default' "
|
||||
"and s != 'gateway.status.header') else 1)"
|
||||
)
|
||||
env = {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "HERMES_BUNDLED_LOCALES")}
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
if sys.platform == "win32":
|
||||
env["PATH"] = f"{venv_dir / 'Scripts'}{os.pathsep}{env['PATH']}"
|
||||
else:
|
||||
env["PATH"] = f"{venv_dir / 'bin'}{os.pathsep}{env['PATH']}"
|
||||
env["VIRTUAL_ENV"] = str(venv_dir)
|
||||
run = subprocess.run(
|
||||
[str(vpy), "-c", probe],
|
||||
cwd=str(tmp_path), # NOT the repo root
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=120,
|
||||
)
|
||||
assert run.returncode == 0, (
|
||||
"installed wheel returned raw i18n keys instead of human strings:\n"
|
||||
f"stdout: {run.stdout}\nstderr: {run.stderr}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.timeout(300) # overrides the global --timeout=30; cold-CI sdist build can exceed it
|
||||
def test_built_sdist_ships_locale_catalogs(tmp_path):
|
||||
"""The sdist must carry locales/ too.
|
||||
|
||||
The wheel is covered above; the sdist is a separately shipped artifact
|
||||
(PyPI, and the form distro/Homebrew packagers build from). MANIFEST.in
|
||||
`graft locales` is what puts the catalogs in the tarball — a stale graft or
|
||||
a setuptools change would pass the metadata unit test (which only inspects
|
||||
the declaration) while the actual artifact regresses. This inspects the
|
||||
real tarball so that path can't rot silently. Closes the sdist half of
|
||||
#27632 / #35374 / #23943.
|
||||
"""
|
||||
sdist_dir = tmp_path / "sdist"
|
||||
build = subprocess.run(
|
||||
["uv", "build", "--sdist", "--out-dir", str(sdist_dir), "."],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
assert build.returncode == 0, f"uv build --sdist failed:\n{build.stderr}"
|
||||
tarballs = glob.glob(str(sdist_dir / "*.tar.gz"))
|
||||
assert tarballs, "no sdist produced"
|
||||
|
||||
with tarfile.open(tarballs[0]) as tf:
|
||||
# Members are prefixed with the sdist root dir, e.g.
|
||||
# hermes_agent-0.15.1/locales/en.yaml — match on the suffix.
|
||||
catalogs = [m for m in tf.getnames() if "/locales/" in m and m.endswith(".yaml")]
|
||||
|
||||
# Compare against the canonical language list rather than a hardcoded floor
|
||||
# so adding/removing a catalog updates the guard automatically and a dropped
|
||||
# catalog (not just a fully-empty graft) trips it.
|
||||
from agent.i18n import SUPPORTED_LANGUAGES
|
||||
|
||||
expected = len(SUPPORTED_LANGUAGES)
|
||||
assert len(catalogs) == expected, (
|
||||
f"sdist shipped {len(catalogs)} locale catalogs, expected {expected} "
|
||||
f"({len(SUPPORTED_LANGUAGES)} supported languages) — check `graft "
|
||||
"locales` in MANIFEST.in"
|
||||
)
|
||||
assert any(m.endswith("/locales/en.yaml") for m in catalogs), (
|
||||
f"sdist missing locales/en.yaml; shipped: {catalogs[:5]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.timeout(300)
|
||||
def test_built_sdist_ships_web_dist(tmp_path):
|
||||
"""The sdist must carry hermes_cli/web_dist/ too.
|
||||
|
||||
MANIFEST.in `graft hermes_cli/web_dist` is what puts the frontend assets
|
||||
in the source distribution tarball. This test builds the sdist and asserts
|
||||
that index.html exists inside it.
|
||||
"""
|
||||
# Create a dummy index.html in hermes_cli/web_dist if it doesn't exist
|
||||
# so that the sdist build actually has files to bundle.
|
||||
web_dist_dir = REPO_ROOT / "hermes_cli" / "web_dist"
|
||||
dummy_index = web_dist_dir / "index.html"
|
||||
created_dummy = False
|
||||
if not dummy_index.exists():
|
||||
web_dist_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(dummy_index, "w", encoding="utf-8") as f:
|
||||
f.write("<html><body>Dummy Dashboard</body></html>")
|
||||
created_dummy = True
|
||||
|
||||
try:
|
||||
sdist_dir = tmp_path / "sdist"
|
||||
build = subprocess.run(
|
||||
["uv", "build", "--sdist", "--out-dir", str(sdist_dir), "."],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
assert build.returncode == 0, f"uv build --sdist failed:\n{build.stderr}"
|
||||
tarballs = glob.glob(str(sdist_dir / "*.tar.gz"))
|
||||
assert tarballs, "no sdist produced"
|
||||
|
||||
with tarfile.open(tarballs[0]) as tf:
|
||||
names = tf.getnames()
|
||||
web_assets = [m for m in names if "hermes_cli/web_dist/" in m]
|
||||
|
||||
assert any(m.endswith("/hermes_cli/web_dist/index.html") for m in web_assets), (
|
||||
f"sdist missing hermes_cli/web_dist/index.html; shipped files in web_dist: {web_assets}"
|
||||
)
|
||||
finally:
|
||||
if created_dummy and dummy_index.exists():
|
||||
try:
|
||||
dummy_index.unlink()
|
||||
# Clean up directory if it's empty
|
||||
if not any(web_dist_dir.iterdir()):
|
||||
web_dist_dir.rmdir()
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3833,18 +3833,6 @@ def _session_info(agent, session: dict | None = None) -> dict:
|
|||
"usage": _session_usage_snapshot(session),
|
||||
"profile_name": _current_profile_name(),
|
||||
}
|
||||
try:
|
||||
from hermes_cli.config import (
|
||||
detect_install_method,
|
||||
format_unsupported_install_warning,
|
||||
is_unsupported_install_method,
|
||||
)
|
||||
|
||||
_install_method = detect_install_method()
|
||||
if is_unsupported_install_method(_install_method):
|
||||
info["install_warning"] = format_unsupported_install_warning(_install_method)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from hermes_cli import __version__, __release_date__
|
||||
|
||||
|
|
|
|||
4
uv.lock
generated
4
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ Key implementation files:
|
|||
- `acp_adapter/permissions.py`
|
||||
- `acp_adapter/tools.py`
|
||||
- `acp_adapter/auth.py`
|
||||
- `acp_registry/agent.json`
|
||||
|
||||
## Boot flow
|
||||
|
||||
|
|
@ -31,8 +30,6 @@ hermes acp / hermes-acp / python -m acp_adapter
|
|||
-> acp.run_agent(agent, use_unstable_protocol=True)
|
||||
```
|
||||
|
||||
The Zed ACP Registry path launches the same adapter through `uvx --from 'hermes-agent[acp]==<version>' hermes-acp`, pointed at the `hermes-agent` PyPI release.
|
||||
|
||||
Stdout is reserved for ACP JSON-RPC transport. Human-readable logs go to stderr.
|
||||
|
||||
## Major components
|
||||
|
|
@ -149,7 +146,7 @@ Instead it reuses Hermes' runtime resolver:
|
|||
- `acp_adapter/auth.py`
|
||||
- `hermes_cli/runtime_provider.py`
|
||||
|
||||
So ACP advertises and uses the currently configured Hermes provider/credentials. It also always advertises a terminal setup auth method (`hermes-setup`, args `--setup`) so first-run registry clients can open Hermes' interactive model/provider configuration before starting a normal ACP session.
|
||||
So ACP advertises and uses the currently configured Hermes provider/credentials. It also always advertises a terminal setup auth method (`hermes-setup`, args `--setup`) so first-run ACP clients can open Hermes' interactive model/provider configuration before starting a normal ACP session.
|
||||
|
||||
## Working directory binding
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -33,10 +33,10 @@ It intentionally excludes things that do not fit typical editor UX, such as mess
|
|||
|
||||
## Installation
|
||||
|
||||
Install Hermes normally, then add the ACP extra:
|
||||
Install Hermes normally, then add the ACP extra from the install checkout:
|
||||
|
||||
```bash
|
||||
pip install -e '.[acp]'
|
||||
cd ~/.hermes/hermes-agent && uv pip install -e '.[acp]'
|
||||
```
|
||||
|
||||
This installs the `agent-client-protocol` dependency and enables:
|
||||
|
|
@ -45,14 +45,6 @@ This installs the `agent-client-protocol` dependency and enables:
|
|||
- `hermes-acp`
|
||||
- `python -m acp_adapter`
|
||||
|
||||
For Zed registry installs, Zed launches Hermes through the official ACP Registry entry. That entry uses a `uvx` distribution that runs:
|
||||
|
||||
```bash
|
||||
uvx --from 'hermes-agent[acp]==<version>' hermes-acp
|
||||
```
|
||||
|
||||
Make sure `uv` is available on `PATH` before using the registry install path.
|
||||
|
||||
## Launching the ACP server
|
||||
|
||||
Any of the following starts Hermes in ACP mode:
|
||||
|
|
@ -89,7 +81,7 @@ hermes acp --setup-browser # interactive (prompts before ~400 MB downl
|
|||
hermes acp --setup-browser --yes # accept the download non-interactively
|
||||
```
|
||||
|
||||
This is the standalone command. The Zed registry's terminal-auth flow (`hermes acp --setup`) also offers the browser bootstrap as a follow-up question after model selection, so most users never need to run `--setup-browser` directly.
|
||||
This is the standalone command. The terminal-auth flow (`hermes acp --setup`) also offers the browser bootstrap as a follow-up question after model selection, so most users never need to run `--setup-browser` directly.
|
||||
|
||||
What it does:
|
||||
|
||||
|
|
@ -126,19 +118,10 @@ If you want to define Hermes manually, add it through VS Code settings under `ac
|
|||
|
||||
### Zed
|
||||
|
||||
Zed v0.221.x and newer installs external agents through the official ACP Registry.
|
||||
Configure Hermes as a custom agent server in Zed settings:
|
||||
|
||||
1. Open the Agent Panel.
|
||||
2. Click **Add Agent**, or run the `zed: acp registry` command.
|
||||
3. Search for **Hermes Agent**.
|
||||
4. Install it and start a new Hermes external-agent thread.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Configure Hermes provider credentials first with `hermes model`, or set them in `~/.hermes/.env` / `~/.hermes/config.yaml`.
|
||||
- Install `uv` so the registry launcher can run `uvx --from 'hermes-agent[acp]==<version>' hermes-acp`.
|
||||
|
||||
For local development before the registry entry is available, use a custom agent server in Zed settings:
|
||||
2. Add a custom agent server with the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -152,32 +135,15 @@ For local development before the registry entry is available, use a custom agent
|
|||
}
|
||||
```
|
||||
|
||||
3. Start a new Hermes external-agent thread.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Configure Hermes provider credentials first with `hermes model`, or set them in `~/.hermes/.env` / `~/.hermes/config.yaml`.
|
||||
|
||||
### JetBrains
|
||||
|
||||
Use an ACP-compatible plugin and point it at:
|
||||
|
||||
```text
|
||||
/path/to/hermes-agent/acp_registry
|
||||
```
|
||||
|
||||
## Registry manifest
|
||||
|
||||
The source copy of Hermes' official ACP Registry metadata lives at:
|
||||
|
||||
```text
|
||||
acp_registry/agent.json
|
||||
acp_registry/icon.svg
|
||||
```
|
||||
|
||||
The upstream registry PR copies those files into the top-level `hermes-agent/` directory in `agentclientprotocol/registry`.
|
||||
|
||||
The registry entry uses a `uvx` distribution that points directly at the `hermes-agent` PyPI release:
|
||||
|
||||
```text
|
||||
uvx --from 'hermes-agent[acp]==<version>' hermes-acp
|
||||
```
|
||||
|
||||
The registry CI verifies that the pinned version exists on PyPI, so the manifest's `version` and uvx `package` pin must always match `pyproject.toml`. `scripts/release.py` keeps them in lockstep automatically.
|
||||
Use an ACP-compatible plugin and point it at `hermes acp` or `hermes-acp`.
|
||||
|
||||
## Configuration and credentials
|
||||
|
||||
|
|
@ -188,7 +154,7 @@ ACP mode uses the same Hermes configuration as the CLI:
|
|||
- `~/.hermes/skills/`
|
||||
- `~/.hermes/state.db`
|
||||
|
||||
Provider resolution uses Hermes' normal runtime resolver, so ACP inherits the currently configured provider and credentials. Hermes also advertises a terminal auth method (`--setup`) for first-run registry clients; this opens Hermes' interactive model/provider setup.
|
||||
Provider resolution uses Hermes' normal runtime resolver, so ACP inherits the currently configured provider and credentials. Hermes also advertises a terminal auth method (`--setup`) for first-run ACP clients; this opens Hermes' interactive model/provider setup.
|
||||
|
||||
## Session behavior
|
||||
|
||||
|
|
@ -239,11 +205,9 @@ The ACP bridge maps these options onto Hermes' internal approval semantics — `
|
|||
|
||||
Check:
|
||||
|
||||
- In Zed, open the ACP Registry with `zed: acp registry` and search for **Hermes Agent**.
|
||||
- For manual/local development, verify the custom `agent_servers` command points to `hermes acp`.
|
||||
- Hermes is installed and on your PATH.
|
||||
- The ACP extra is installed (`pip install -e '.[acp]'`).
|
||||
- `uv` is installed if launching from the official Zed registry entry.
|
||||
- The ACP extra is installed (`cd ~/.hermes/hermes-agent && uv pip install -e '.[acp]'`).
|
||||
|
||||
### ACP starts but immediately errors
|
||||
|
||||
|
|
@ -264,11 +228,7 @@ ACP mode uses Hermes' existing provider setup. Configure credentials with:
|
|||
hermes model
|
||||
```
|
||||
|
||||
or by editing `~/.hermes/.env`. Registry clients can also trigger Hermes' terminal auth flow, which runs the same interactive provider/model setup.
|
||||
|
||||
### Zed registry launcher cannot find uv
|
||||
|
||||
Install `uv` from the official uv installation docs, then retry the Hermes Agent thread from Zed.
|
||||
or by editing `~/.hermes/.env`. The terminal auth flow (`hermes acp --setup`) can also trigger the interactive provider/model setup.
|
||||
|
||||
## See also
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ ACP 适配器将 Hermes 的同步 `AIAgent` 封装为异步 JSON-RPC stdio 服
|
|||
- `acp_adapter/permissions.py`
|
||||
- `acp_adapter/tools.py`
|
||||
- `acp_adapter/auth.py`
|
||||
- `acp_registry/agent.json`
|
||||
|
||||
## 启动流程
|
||||
|
||||
|
|
@ -31,8 +30,6 @@ hermes acp / hermes-acp / python -m acp_adapter
|
|||
-> acp.run_agent(agent, use_unstable_protocol=True)
|
||||
```
|
||||
|
||||
Zed ACP Registry 路径通过 `uvx --from 'hermes-agent[acp]==<version>' hermes-acp` 启动同一适配器,指向 `hermes-agent` PyPI 发布包。
|
||||
|
||||
stdout 保留用于 ACP JSON-RPC 传输。人类可读的日志输出至 stderr。
|
||||
|
||||
## 主要组件
|
||||
|
|
@ -149,7 +146,7 @@ ACP 不实现自己的认证存储。
|
|||
- `acp_adapter/auth.py`
|
||||
- `hermes_cli/runtime_provider.py`
|
||||
|
||||
因此 ACP 通告并使用当前配置的 Hermes provider/凭据。它还始终通告一个终端 setup 认证方法(`hermes-setup`,参数 `--setup`),以便首次运行的 registry 客户端在启动正常 ACP 会话前可以打开 Hermes 的交互式模型/provider 配置。
|
||||
因此 ACP 通告并使用当前配置的 Hermes provider/凭据。它还始终通告一个终端 setup 认证方法(`hermes-setup`,参数 `--setup`),以便首次运行的 ACP 客户端在启动正常 ACP 会话前可以打开 Hermes 的交互式模型/provider 配置。
|
||||
|
||||
## 工作目录绑定
|
||||
|
||||
|
|
|
|||
|
|
@ -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` 也会在其环境摘要中显示检测到的安装方式。
|
||||
|
|
|
|||
|
|
@ -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`)。
|
||||
|
|
|
|||
|
|
@ -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 语音频道模式
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -33,10 +33,10 @@ Hermes 使用专为编辑器工作流设计的精选 `hermes-acp` 工具集运
|
|||
|
||||
## 安装
|
||||
|
||||
正常安装 Hermes 后,添加 ACP 扩展:
|
||||
正常安装 Hermes 后,从安装检出目录添加 ACP 扩展:
|
||||
|
||||
```bash
|
||||
pip install -e '.[acp]'
|
||||
cd ~/.hermes/hermes-agent && uv pip install -e '.[acp]'
|
||||
```
|
||||
|
||||
这将安装 `agent-client-protocol` 依赖并启用:
|
||||
|
|
@ -45,14 +45,6 @@ pip install -e '.[acp]'
|
|||
- `hermes-acp`
|
||||
- `python -m acp_adapter`
|
||||
|
||||
对于 Zed registry 安装,Zed 通过官方 ACP Registry 条目启动 Hermes。该条目使用 `uvx` 发行版运行:
|
||||
|
||||
```bash
|
||||
uvx --from 'hermes-agent[acp]==<version>' hermes-acp
|
||||
```
|
||||
|
||||
使用 registry 安装路径前,请确保 `uv` 已在 `PATH` 中可用。
|
||||
|
||||
## 启动 ACP 服务器
|
||||
|
||||
以下任意命令均可以 ACP 模式启动 Hermes:
|
||||
|
|
@ -87,7 +79,7 @@ hermes acp --setup-browser # 交互式(下载约 400 MB 前会提示
|
|||
hermes acp --setup-browser --yes # 非交互式接受下载
|
||||
```
|
||||
|
||||
这是独立命令。Zed registry 的终端认证流程(`hermes acp --setup`)在模型选择后也会将浏览器引导作为后续问题提供,因此大多数用户无需直接运行 `--setup-browser`。
|
||||
这是独立命令。终端认证流程(`hermes acp --setup`)在模型选择后也会将浏览器引导作为后续问题提供,因此大多数用户无需直接运行 `--setup-browser`。
|
||||
|
||||
具体操作:
|
||||
|
||||
|
|
@ -124,19 +116,10 @@ hermes acp --setup-browser --yes # 非交互式接受下载
|
|||
|
||||
### Zed
|
||||
|
||||
Zed v0.221.x 及更新版本通过官方 ACP Registry 安装外部 agent。
|
||||
在 Zed 设置中将 Hermes 配置为自定义 agent 服务器:
|
||||
|
||||
1. 打开 Agent 面板。
|
||||
2. 点击 **Add Agent**,或运行 `zed: acp registry` 命令。
|
||||
3. 搜索 **Hermes Agent**。
|
||||
4. 安装后启动新的 Hermes 外部 agent 线程。
|
||||
|
||||
前提条件:
|
||||
|
||||
- 先通过 `hermes model` 配置 Hermes provider 凭据,或在 `~/.hermes/.env` / `~/.hermes/config.yaml` 中设置。
|
||||
- 安装 `uv`,以便 registry 启动器可以运行 `uvx --from 'hermes-agent[acp]==<version>' hermes-acp`。
|
||||
|
||||
在 registry 条目可用之前进行本地开发时,在 Zed 设置中使用自定义 agent 服务器:
|
||||
2. 使用以下配置添加自定义 agent 服务器:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -150,32 +133,15 @@ Zed v0.221.x 及更新版本通过官方 ACP Registry 安装外部 agent。
|
|||
}
|
||||
```
|
||||
|
||||
3. 启动新的 Hermes 外部 agent 线程。
|
||||
|
||||
前提条件:
|
||||
|
||||
- 先通过 `hermes model` 配置 Hermes provider 凭据,或在 `~/.hermes/.env` / `~/.hermes/config.yaml` 中设置。
|
||||
|
||||
### JetBrains
|
||||
|
||||
使用兼容 ACP 的插件并将其指向:
|
||||
|
||||
```text
|
||||
/path/to/hermes-agent/acp_registry
|
||||
```
|
||||
|
||||
## Registry 清单
|
||||
|
||||
Hermes 官方 ACP Registry 元数据的源文件位于:
|
||||
|
||||
```text
|
||||
acp_registry/agent.json
|
||||
acp_registry/icon.svg
|
||||
```
|
||||
|
||||
上游 registry PR 将这些文件复制到 `agentclientprotocol/registry` 中的顶层 `hermes-agent/` 目录。
|
||||
|
||||
Registry 条目使用直接指向 `hermes-agent` PyPI 发行版的 `uvx` 发行版:
|
||||
|
||||
```text
|
||||
uvx --from 'hermes-agent[acp]==<version>' hermes-acp
|
||||
```
|
||||
|
||||
Registry CI 会验证固定版本是否存在于 PyPI,因此清单的 `version` 和 uvx `package` 固定版本必须始终与 `pyproject.toml` 匹配。`scripts/release.py` 会自动保持它们同步。
|
||||
使用兼容 ACP 的插件并将其指向 `hermes acp` 或 `hermes-acp`。
|
||||
|
||||
## 配置与凭据
|
||||
|
||||
|
|
@ -186,7 +152,7 @@ ACP 模式使用与 CLI 相同的 Hermes 配置:
|
|||
- `~/.hermes/skills/`
|
||||
- `~/.hermes/state.db`
|
||||
|
||||
Provider 解析使用 Hermes 的正常运行时解析器,因此 ACP 继承当前配置的 provider 和凭据。Hermes 还为首次运行的 registry 客户端提供终端认证方法(`--setup`);这将打开 Hermes 的交互式模型/provider 设置。
|
||||
Provider 解析使用 Hermes 的正常运行时解析器,因此 ACP 继承当前配置的 provider 和凭据。Hermes 还为首次运行的 ACP 客户端提供终端认证方法(`--setup`);这将打开 Hermes 的交互式模型/provider 设置。
|
||||
|
||||
## 会话行为
|
||||
|
||||
|
|
@ -237,11 +203,9 @@ ACP 桥接将这些选项映射到 Hermes 的内部审批语义——`allow_alwa
|
|||
|
||||
检查:
|
||||
|
||||
- 在 Zed 中,使用 `zed: acp registry` 打开 ACP Registry 并搜索 **Hermes Agent**。
|
||||
- 对于手动/本地开发,验证自定义 `agent_servers` 命令是否指向 `hermes acp`。
|
||||
- Hermes 已安装且在 PATH 中。
|
||||
- ACP 扩展已安装(`pip install -e '.[acp]'`)。
|
||||
- 如果从官方 Zed registry 条目启动,`uv` 已安装。
|
||||
- ACP 扩展已安装(`cd ~/.hermes/hermes-agent && uv pip install -e '.[acp]'`)。
|
||||
|
||||
### ACP 启动后立即报错
|
||||
|
||||
|
|
@ -262,11 +226,7 @@ ACP 模式使用 Hermes 现有的 provider 设置。通过以下方式配置凭
|
|||
hermes model
|
||||
```
|
||||
|
||||
或编辑 `~/.hermes/.env`。Registry 客户端也可以触发 Hermes 的终端认证流程,该流程运行相同的交互式 provider/模型设置。
|
||||
|
||||
### Zed registry 启动器找不到 uv
|
||||
|
||||
从官方 uv 安装文档安装 `uv`,然后从 Zed 重试 Hermes Agent 线程。
|
||||
或编辑 `~/.hermes/.env`。终端认证流程(`hermes acp --setup`)也可以触发交互式 provider/模型设置。
|
||||
|
||||
## 另请参阅
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue