diff --git a/.dockerignore b/.dockerignore index ec3d52f81413..cfd0616efb84 100644 --- a/.dockerignore +++ b/.dockerignore @@ -97,9 +97,6 @@ packaging/ plans/ .plans/ -# ACP registry manifest (icon + agent.json) — not consumed at runtime -acp_registry/ - # Repo-level dotfiles that are git-only or dev-tooling config .env.example .envrc diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 47114b306546..7ee20e029a7c 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -76,6 +76,7 @@ jobs: id: scan env: GH_TOKEN: ${{ steps.app-token.outputs.token }} + CI_REVIEWED: ${{ contains(github.event.pull_request.labels.*.name, 'ci-reviewed') }} run: | set -euo pipefail @@ -93,7 +94,7 @@ jobs: # --- .pth files (auto-execute on Python startup) --- # The exact mechanism used in the litellm supply chain attack: # https://github.com/BerriAI/litellm/issues/24512 - PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true) + PTH_FILES=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true) if [ -n "$PTH_FILES" ]; then FINDINGS="${FINDINGS} ### 🚨 CRITICAL: .pth file added or modified @@ -141,8 +142,11 @@ jobs: # auto-loaded by the interpreter via site.py. Any nested file with the # same name (e.g. hermes_cli/setup.py — the CLI setup wizard) is unrelated # and produced false positives that trained reviewers to ignore the scanner. - SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true) - if [ -n "$SETUP_HITS" ]; then + SETUP_HITS=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true) + # A maintainer-applied ci-reviewed label records the manual review + # required for intentional changes to an install hook. The scanner + # still blocks every unreviewed addition or modification. + if [ -n "$SETUP_HITS" ] && [ "$CI_REVIEWED" != "true" ]; then FINDINGS="${FINDINGS} ### 🚨 CRITICAL: Install-hook file added or modified These files can execute code during package installation or interpreter startup. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1f29b25008e4..cdae2e037a59 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -215,11 +215,6 @@ jobs: # re-download, keeping the persisted cache small and fast to restore. run: uv cache prune --ci - - name: Packaged-wheel i18n smoke test - run: | - source .venv/bin/activate - python -m pytest -m integration tests/test_wheel_locales_e2e.py -v - - name: Run e2e tests run: | source .venv/bin/activate diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml deleted file mode 100644 index e95ef194fa71..000000000000 --- a/.github/workflows/upload_to_pypi.yml +++ /dev/null @@ -1,188 +0,0 @@ -name: Publish to PyPI - -# Triggered by CalVer tag pushes from scripts/release.py (e.g. v2026.5.15) -# Can also be triggered manually from the Actions tab as an escape hatch. -on: - push: - tags: - - "v20*" # CalVer tags: v2026.5.15, v2026.5.15.2, etc. - workflow_dispatch: - inputs: - confirm_tag: - description: "Tag to publish (e.g. v2026.5.15). Must already exist." - required: true - type: string - -# Restrict default token to read-only; each job escalates as needed. -permissions: - contents: read - -# Prevent overlapping publishes (e.g. two same-day tags pushed quickly). -concurrency: - group: pypi-publish - cancel-in-progress: false - -jobs: - build: - name: Build distribution 📦 - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - # On workflow_dispatch, check out the confirmed tag. - ref: ${{ inputs.confirm_tag || github.ref }} - fetch-tags: true - - - name: Validate tag exists - if: github.event_name == 'workflow_dispatch' - run: | - if ! git tag -l "${{ inputs.confirm_tag }}" | grep -q .; then - echo "::error::Tag '${{ inputs.confirm_tag }}' does not exist in the repo" - exit 1 - fi - - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.13" - - - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 - - - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "22" - - - name: Build web dashboard - uses: ./.github/actions/retry - with: - command: npm ci - working-directory: web - - - name: Compile web dashboard - run: npm run build - working-directory: web - - - name: Build TUI bundle - uses: ./.github/actions/retry - with: - command: npm ci - working-directory: ui-tui - - - name: Compile TUI bundle - run: npm run build - working-directory: ui-tui - - - name: Bundle TUI into hermes_cli - run: | - mkdir -p hermes_cli/tui_dist - cp ui-tui/dist/entry.js hermes_cli/tui_dist/entry.js - - - name: Verify frontend assets exist - run: | - test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; } - test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; } - - - name: Bundle install scripts into wheel - run: | - mkdir -p hermes_cli/scripts - cp scripts/install.sh hermes_cli/scripts/install.sh - cp scripts/install.ps1 hermes_cli/scripts/install.ps1 - - - name: Build wheel and sdist - run: uv build --sdist --wheel - - - name: Upload distribution artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: python-package-distributions - path: dist/ - - publish: - name: Publish to PyPI - needs: build - runs-on: ubuntu-latest - timeout-minutes: 30 - environment: - name: pypi - url: https://pypi.org/p/hermes-agent - permissions: - id-token: write # OIDC trusted publishing - - steps: - - name: Download distribution artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: python-package-distributions - path: dist/ - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 - with: - skip-existing: true - - sign: - name: Sign and attach to GitHub Release - # Only runs on tag pushes — release.py creates the GitHub Release, - # and workflow_dispatch won't have a matching release to attach to. - if: startsWith(github.ref, 'refs/tags/') - needs: publish - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: write # attach assets to the existing release - id-token: write # sigstore signing - - steps: - - name: Download distribution artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: python-package-distributions - path: dist/ - - - name: Get GitHub App token - id: app-token - uses: ./.github/actions/get-app-token - with: - client-id: ${{ secrets.APP_CLIENT_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - - - name: Wait for GitHub Release to exist - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - # release.py creates the GitHub Release after pushing the tag, - # but this workflow starts from the tag push — wait for it. - run: | - for i in $(seq 1 30); do - if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "Release $GITHUB_REF_NAME found" - exit 0 - fi - echo "Waiting for release... ($i/30)" - sleep 10 - done - echo "::warning::Release $GITHUB_REF_NAME not found after 5 minutes — skipping signature upload" - echo "skip_sign=true" >> "$GITHUB_ENV" - - - name: Sign with Sigstore - if: env.skip_sign != 'true' - uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0 - with: - inputs: >- - ./dist/*.tar.gz - ./dist/*.whl - - - name: Attach signed artifacts to GitHub Release - if: env.skip_sign != 'true' - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - # release.py already created the GitHub Release — just upload - # the Sigstore signatures alongside the existing assets. - run: >- - gh release upload - "$GITHUB_REF_NAME" dist/*.sigstore.json - --repo "$GITHUB_REPOSITORY" - --clobber diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index c09d58c71283..000000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,14 +0,0 @@ -graft skills -graft optional-skills -graft optional-mcps -graft hermes_cli/web_dist -graft locales -# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the -# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs -# built from the sdist (e.g. Homebrew, downstream packagers). package-data -# below covers the wheel; this covers the sdist. See #34034 / #28149. -recursive-include plugins plugin.yaml plugin.yml -# Gateway assets include images plus YAML catalogs such as status_phrases.yaml. -recursive-include gateway/assets * -global-exclude __pycache__ -global-exclude *.py[cod] diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 5048b7025982..55773536122a 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -190,7 +190,7 @@ def _run_setup_browser(assume_yes: bool = False) -> int: """Bootstrap agent-browser + Chromium. Routes through dep_ensure -> install.{sh,ps1} --ensure, sharing code - with ``hermes postinstall`` and the runtime lazy installer. + with the runtime lazy installer. Returns 0 on success, 1 on failure. """ diff --git a/acp_registry/agent.json b/acp_registry/agent.json deleted file mode 100644 index 1d3752bf15a1..000000000000 --- a/acp_registry/agent.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "hermes-agent", - "name": "Hermes Agent", - "version": "0.19.0", - "description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.", - "repository": "https://github.com/NousResearch/hermes-agent", - "website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", - "authors": ["Nous Research"], - "license": "MIT", - "distribution": { - "uvx": { - "package": "hermes-agent[acp]==0.19.0", - "args": ["hermes-acp"] - } - } -} diff --git a/acp_registry/icon.svg b/acp_registry/icon.svg deleted file mode 100644 index f42c0daea458..000000000000 --- a/acp_registry/icon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/agent/i18n.py b/agent/i18n.py index ef9fd4b06c28..b55b8128c92d 100644 --- a/agent/i18n.py +++ b/agent/i18n.py @@ -32,7 +32,6 @@ from __future__ import annotations import logging import os -import sysconfig import threading from functools import lru_cache from pathlib import Path @@ -92,12 +91,8 @@ def _locales_dir() -> Path: 1. ``HERMES_BUNDLED_LOCALES`` env var -- set by the Nix wrapper (or any sealed-packaging system) to point at the installed catalog directory. - 2. ``/locales`` -- source checkouts and ``pip install -e .``, + 2. ``/locales`` -- source checkouts and editable installs, where the working tree sits next to ``agent/``. - 3. ``/locales`` -- pip wheel installs. - setuptools ``data-files`` extracts ``locales/*.yaml`` under the - interpreter's ``data`` scheme; the other schemes are checked as a - safety net for nonstandard layouts. Falling through to the source-style path (even when missing) keeps ``_load_catalog`` error messages informative -- it logs the path it @@ -116,25 +111,6 @@ def _locales_dir() -> Path: # agent/i18n.py -> agent/ -> repo root (source checkout, editable install) source_dir = Path(__file__).resolve().parent.parent / "locales" - if source_dir.is_dir(): - return source_dir - - # pip wheel install: data-files lands under the interpreter data scheme. - # ``data`` (== sys.prefix in a venv) is where setuptools data-files extract - # and is checked first. ``purelib``/``platlib`` (site-packages) are a safety - # net for nonstandard layouts. NOTE: this does NOT cover ``pip install - # --user`` (user scheme, ~/.local/locales) or ``pip install --target`` -- - # both are out of scope; see the plan header. - for scheme in ("data", "purelib", "platlib"): - raw = sysconfig.get_path(scheme) - if not raw: - continue - candidate = Path(raw) / "locales" - if candidate.is_dir(): - return candidate - - # Last resort: return the source-style path so _load_catalog's catalog-missing - # log (logger.debug "i18n catalog missing for %s at %s") stays informative. return source_dir diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 1fde9070d4a3..0d74014b50b3 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -220,7 +220,7 @@ export function usePromptActions({ const copy = t.desktop const appendSessionTextMessage = useCallback( - (sessionId: string, role: ChatMessage['role'], text: string) => { + (sessionId: string, role: ChatMessage['role'], text: string, storedSessionId = selectedStoredSessionIdRef.current) => { // Strip ANSI: slash-command output from the backend worker carries SGR // color codes (e.g. "Unknown command" in red). The ESC byte is invisible // in the chat panel, so without this the `[1;31m…[0m` payload leaks as @@ -244,7 +244,7 @@ export function usePromptActions({ } ] }), - selectedStoredSessionIdRef.current + storedSessionId ) }, [selectedStoredSessionIdRef, updateSessionState] diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 7b0beef7bb35..e487475f62b1 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -251,48 +251,6 @@ def _check_via_local_git(repo_dir: Path) -> Optional[int]: return None -def _version_tuple(v: str) -> tuple[int, ...]: - """Parse '0.13.0' into (0, 13, 0) for comparison. Non-numeric segments become 0.""" - parts = [] - for segment in v.split("."): - try: - parts.append(int(segment)) - except ValueError: - parts.append(0) - return tuple(parts) - - -def _fetch_pypi_latest(package: str = "hermes-agent") -> Optional[str]: - """Fetch the latest version of a package from PyPI. Returns None on failure.""" - try: - import urllib.request - url = f"https://pypi.org/pypi/{package}/json" - req = urllib.request.Request(url, headers={"Accept": "application/json"}) - with urllib.request.urlopen(req, timeout=5) as resp: - data = json.loads(resp.read()) - return data.get("info", {}).get("version") - except Exception: - return None - - -def check_via_pypi() -> Optional[int]: - """Compare installed version against PyPI latest. - - Returns 0 if up-to-date, 1 if behind, None on failure. - """ - latest = _fetch_pypi_latest() - if latest is None: - return None - if latest == VERSION: - return 0 - try: - if _version_tuple(latest) > _version_tuple(VERSION): - return 1 - return 0 - except Exception: - return 1 if latest != VERSION else 0 - - def check_for_updates() -> Optional[int]: """Check whether a Hermes update is available. @@ -310,16 +268,11 @@ def check_for_updates() -> Optional[int]: # Docker images have no working tree to count commits against — the # published image excludes `.git` (see .dockerignore) and sets no - # HERMES_REVISION (that's nix-only). Without this guard the checks below - # fall through to `check_via_pypi()`, whose PyPI-version mismatch flag (1) - # then gets rendered by the CLI banner and the TUI badge as a phantom - # "1 commit behind" — even though no git repo or commit math is involved, - # and `hermes update` correctly refuses to run in-place inside the - # container anyway. The dashboard's REST `/api/hermes/update/check` - # endpoint already short-circuits docker the same way (web_server.py); - # mirror that here so the banner/TUI surfaces agree. Returning None makes - # both the Rich banner (build_welcome_banner) and the Ink badge - # (branding.tsx, guarded on `typeof === 'number' && > 0`) show nothing. + # HERMES_REVISION (that's nix-only). Returning None makes both the Rich + # banner (build_welcome_banner) and the Ink badge (branding.tsx, guarded + # on `typeof === 'number' && > 0`) show nothing. The dashboard's REST + # `/api/hermes/update/check` endpoint short-circuits docker the same way + # (web_server.py); mirror that here so the banner/TUI surfaces agree. try: from hermes_cli.config import detect_install_method, get_project_root if detect_install_method(get_project_root()) == "docker": @@ -328,10 +281,7 @@ def check_for_updates() -> Optional[int]: pass # Read cache — invalidate if the embedded rev OR installed version has - # changed since the last check. The version guard matters for pip installs: - # `check_via_pypi()` compares against VERSION, so a `pip install --upgrade` - # changes VERSION but leaves rev unchanged (both None), and without this - # the stale "behind" count would survive the upgrade for up to 6h. See #34491. + # changed since the last check. now = time.time() try: if cache_file.exists(): @@ -355,7 +305,10 @@ def check_for_updates() -> Optional[int]: if not (repo_dir / ".git").exists(): repo_dir = hermes_home / "hermes-agent" if not (repo_dir / ".git").exists(): - behind = check_via_pypi() + # No git checkout and no embedded revision — can't determine + # update status. This is the Docker path (already short-circuited + # above) or an unsupported install without a source tree. + behind = None else: behind = _check_via_local_git(repo_dir) @@ -888,27 +841,6 @@ def build_welcome_banner(console: "Console", model: str, cwd: str, except Exception: pass # Never break the banner over an update check - # Unsupported install-method warning — pip/PyPI and Homebrew are no - # longer an officially supported distribution method (see - # website/docs/getting-started/platform-support.md). Such installs miss - # the git checkout + installer-managed deps, so updates, self-update, and - # issue triage don't behave correctly. Warn, don't block. NixOS is fully - # supported and never hits this. - try: - from hermes_cli.config import ( - detect_install_method, - format_unsupported_install_warning, - is_unsupported_install_method, - get_project_root - ) - _install_method = detect_install_method(get_project_root()) - if is_unsupported_install_method(_install_method): - right_lines.append( - f"[bold yellow]⚠ {format_unsupported_install_warning(_install_method)}[/]" - ) - except Exception: - pass # Never break the banner over the install-method check - right_content = "\n".join(right_lines) layout_table.add_row(left_content, right_content) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5ef6ae381352..33076a7f238c 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -326,11 +326,19 @@ from hermes_cli.default_soul import DEFAULT_SOUL_MD, is_legacy_template_soul _MANAGED_TRUE_VALUES = ("true", "1", "yes") _MANAGED_SYSTEM_NAMES = { - "brew": "Homebrew", - "homebrew": "Homebrew", "nix": "NixOS", "nixos": "NixOS", } +# The Nix store root. Used by detect_install_method to identify installs +# from `nix run` / `nix profile install` (which don't set HERMES_MANAGED). +# A module-level constant so tests can patch it without creating files +# under the real /nix/store. +_NIX_STORE = Path("/nix/store") +# Values that used to signal a Homebrew-managed install. Homebrew is no +# longer a supported distribution method, so these are explicitly ignored +# rather than treated as a managed system — they fall through to git/unknown +# detection instead of blocking config writes. +_IGNORED_MANAGED_VALUES = frozenset({"brew", "homebrew"}) def get_managed_system() -> Optional[str]: @@ -338,6 +346,8 @@ def get_managed_system() -> Optional[str]: raw = os.getenv("HERMES_MANAGED", "").strip() if raw: normalized = raw.lower() + if normalized in _IGNORED_MANAGED_VALUES: + return None if normalized in _MANAGED_TRUE_VALUES: return "NixOS" return _MANAGED_SYSTEM_NAMES.get(normalized, raw) @@ -364,8 +374,6 @@ _NIX_UPDATE_MSG = "Update your Nix flake input and rebuild (e.g. nix flake updat def get_managed_update_command() -> Optional[str]: """Return the preferred upgrade command for a managed install.""" managed_system = get_managed_system() - if managed_system == "Homebrew": - return "brew upgrade hermes-agent" if managed_system == "NixOS": return _NIX_UPDATE_MSG return None @@ -375,10 +383,10 @@ def _install_method_project_root(project_root: Optional[Path] = None) -> Path: """Resolve the directory that holds the *running code* (the install tree). This is the parent of ``hermes_cli/`` — i.e. the git checkout for source - installs, ``/opt/hermes`` inside the published image, the venv's - site-packages root for pip installs. It is a property of the running - interpreter, NOT of ``$HERMES_HOME``, which is why a code-scoped stamp - here is immune to two installs sharing one data directory. + installs, ``/opt/hermes`` inside the published image. It is a property of + the running interpreter, NOT of ``$HERMES_HOME``, which is why a + code-scoped stamp here is immune to two installs sharing one data + directory. """ if project_root is not None: return project_root @@ -386,7 +394,7 @@ def _install_method_project_root(project_root: Optional[Path] = None) -> Path: def detect_install_method(project_root: Optional[Path] = None) -> str: - """Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'. + """Detect how Hermes was installed: 'docker', 'nixos', 'git', or 'unknown'. Resolution order: 1. Code-scoped stamp ``/.install_method`` (next to the @@ -394,9 +402,10 @@ def detect_install_method(project_root: Optional[Path] = None) -> str: 2. Legacy home-scoped stamp ``$HERMES_HOME/.install_method`` — read for backward compatibility, but a ``docker`` value is IGNORED when we are not actually running inside a container (see below). - 3. HERMES_MANAGED env / .managed marker (NixOS, Homebrew) - 4. .git directory presence -> 'git' - 5. Fallback -> 'pip' + 3. HERMES_MANAGED env / .managed marker (NixOS managed mode) + 4. /nix/store/ path detection -> 'nixos' (nix run / nix profile install) + 5. .git directory presence -> 'git' + 6. Fallback -> 'unknown' Why the stamp is code-scoped, not home-scoped (issue: shared ``~/.hermes``) -------------------------------------------------------------------------- @@ -415,7 +424,7 @@ def detect_install_method(project_root: Optional[Path] = None) -> str: Self-healing for already-poisoned homes: a legacy ``docker`` value in the home-scoped stamp is only honoured when we are genuinely in a container. On a host install that read a contaminating ``docker`` stamp, we fall - through to managed/.git/pip detection instead — so existing shared-home + through to managed/.git detection instead — so existing shared-home setups recover without the user touching anything. Note: running inside a container is NOT treated as "docker" on its own. @@ -425,15 +434,16 @@ def detect_install_method(project_root: Optional[Path] = None) -> str: - the published ``nousresearch/hermes-agent`` image bakes a ``docker`` stamp into ``/opt/hermes`` at build time. An unsupported manual install dropped into a container (no stamp) falls - through to the ``.git``/pip checks and behaves like any off-path install. + through to the ``.git`` checks and behaves like any off-path install. See issue #34397. """ root = _install_method_project_root(project_root) + supported_methods = {"docker", "nixos", "git", "unknown"} # 1. Code-scoped stamp — authoritative, immune to shared $HERMES_HOME. try: method = (root / ".install_method").read_text(encoding="utf-8").strip().lower() - if method: + if method in supported_methods: return method except OSError: pass @@ -449,7 +459,7 @@ def detect_install_method(project_root: Optional[Path] = None) -> str: .strip() .lower() ) - if method and not (method == "docker" and not _running_in_container()): + if method in supported_methods and not (method == "docker" and not _running_in_container()): return method except OSError: pass @@ -457,7 +467,18 @@ def detect_install_method(project_root: Optional[Path] = None) -> str: managed = get_managed_system() if managed: return managed.lower().replace(" ", "-") - + + # detect Nix installs that don't set HERMES_MANAGED (e.g. ``nix run``, + # ``nix profile install``). The code lives under /nix/store/ which is the + # hallmark of a nix-built install — no other supported install path puts + # code there. + try: + resolved = root.resolve() + if resolved != _NIX_STORE and _NIX_STORE in resolved.parents: + return "nixos" + except OSError: + pass + # detect git repo installs (normal installer, development env) git_path = root / ".git" if git_path.is_dir(): @@ -471,7 +492,7 @@ def detect_install_method(project_root: Optional[Path] = None) -> str: return "git" except OSError: pass - return "pip" + return "unknown" def _running_in_container() -> bool: @@ -505,49 +526,12 @@ def stamp_install_method(method: str, project_root: Optional[Path] = None) -> No pass -def is_uv_tool_install() -> bool: - """Return True when the *running* Hermes lives in a ``uv tool`` layout. - - ``uv tool install hermes-agent`` places the install at - ``.../uv/tools/hermes-agent/...`` (default ``~/.local/share/uv/tools``, - or ``$UV_TOOL_DIR/...``). Such installs live outside any virtualenv, so - ``uv pip install`` fails with ``No virtual environment found`` and the - update path must use ``uv tool upgrade`` instead. - - Detection is intentionally restricted to properties of the running - interpreter (``sys.prefix`` / ``sys.executable``). We deliberately do - NOT consult ``uv tool list``: it would also return True when - ``hermes-agent`` happens to be uv-tool-installed on the machine while - the *active* Hermes is a regular pip/venv install, causing - ``hermes update`` to upgrade the wrong copy. It would also block on a - subprocess call (~seconds) just to compute a recommendation string. - """ - def _has_uv_tool_marker(path: str) -> bool: - norm = os.path.normpath(path).replace(os.sep, "/").lower() - return "/uv/tools/hermes-agent/" in norm + "/" - - if _has_uv_tool_marker(sys.prefix): - return True - if _has_uv_tool_marker(sys.executable or ""): - return True - return False - - def recommended_update_command_for_method(method: str) -> str: """Return the update command or guidance for a given install method.""" if method == "nixos": return _NIX_UPDATE_MSG - if method == "homebrew": - return "brew upgrade hermes-agent" if method == "docker": return "docker pull nousresearch/hermes-agent:latest" - if method == "pip": - if is_uv_tool_install(): - return "uv tool upgrade hermes-agent" - import shutil - if shutil.which("uv"): - return "uv pip install --upgrade hermes-agent" - return "pip install --upgrade hermes-agent" return "hermes update" @@ -560,50 +544,6 @@ def recommended_update_command() -> str: return recommended_update_command_for_method(method) -# ============================================================================= -# Unsupported install methods (pip, Homebrew) — deprecation notice -# ============================================================================= -# -# pip/PyPI and Homebrew are NOT an officially supported distribution method -# (see website/docs/getting-started/platform-support.md, "Unsupported" -# section). pip exists on PyPI for internal/CI reasons, not end-user installs; -# Homebrew is a legacy packaging path. Unlike NixOS/Homebrew "managed mode" -# (which hard-blocks config writes), this is a warn-don't-block deprecation -# notice surfaced everywhere the user might see install-method state: the CLI -# banner, the TUI/desktop session info panel, and ``hermes update``. NixOS -# stays fully supported (Tier 2) and must never hit this path. - -PLATFORM_SUPPORT_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/getting-started/platform-support" - -_UNSUPPORTED_INSTALL_METHODS = frozenset({"pip", "homebrew"}) - - -def is_unsupported_install_method(method: str) -> bool: - """Whether ``method`` (from ``detect_install_method()``) is deprecated.""" - return method in _UNSUPPORTED_INSTALL_METHODS - - -def unsupported_install_method_label(method: str) -> str: - """Human-readable name for an unsupported install method.""" - return "pip" if method == "pip" else "Homebrew" - - -def format_unsupported_install_warning(method: str) -> str: - """Plain-text (no markup) deprecation notice for pip/Homebrew installs. - - Shared verbatim across the CLI banner, TUI/desktop ``session.info``, and - ``hermes update`` / ``hermes update --check`` so the wording — and the - docs link — stays consistent across every surface instead of drifting - into three slightly different warnings. - """ - label = unsupported_install_method_label(method) - return ( - f"{label} installs are no longer an officially supported platform and " - f"will not receive further updates. See {PLATFORM_SUPPORT_DOCS_URL} " - "for supported install methods." - ) - - # Long-form text for ``hermes update`` / ``--check`` when running inside the # Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in # hermes_cli/main.py; lives here so the wording stays consistent and we @@ -670,15 +610,6 @@ def format_managed_message(action: str = "modify this Hermes installation") -> s " sudo nixos-rebuild switch" ) - if managed_system == "Homebrew": - env_hint = raw or "homebrew" - return ( - f"Cannot {action}: this Hermes installation is managed by Homebrew " - f"(HERMES_MANAGED={env_hint}).\n" - "Use:\n" - " brew upgrade hermes-agent" - ) - return ( f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n" "Use your package manager to upgrade or reinstall Hermes." diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index 3806ee509dd0..24b258e84e29 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -1180,7 +1180,7 @@ class HermesConsoleEngine: "model", "moa", "oneshot", - "postinstall", + "proxy", "serve", "setup", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c233594fcf81..61b921b6c4f1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -406,7 +406,7 @@ from hermes_cli.subcommands.gateway import build_gateway_parser from hermes_cli.subcommands.profile import build_profile_parser from hermes_cli.subcommands.model import build_model_parser from hermes_cli.subcommands.setup import build_setup_parser -from hermes_cli.subcommands.postinstall import build_postinstall_parser + from hermes_cli.subcommands.whatsapp import build_whatsapp_parser from hermes_cli.subcommands.slack import build_slack_parser from hermes_cli.subcommands.login import build_login_parser @@ -1886,14 +1886,15 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: ) sys.exit(1) - # 1. Prebuilt bundle (nix / packaged release): just run it. + # 1. Prebuilt bundle (nix / packaged release / Docker image): just run it. # - # This must run BEFORE _ensure_tui_workspace() below. A pip/pipx install - # ships hermes_cli/tui_dist/entry.js in the wheel but never ships ui-tui/ - # at all (that directory only exists in a git checkout) — so requiring - # the workspace to exist first made every pip/pipx dashboard Chat tab - # connection hard-exit before it ever got a chance to try the bundled - # entry.js it already has. See #56665. + # This must run BEFORE _ensure_tui_workspace() below. A prebuilt install + # (Docker image, Nix build, or prior `npm run build`) ships + # hermes_cli/tui_dist/entry.js but never ships ui-tui/ at all (that + # directory only exists in a git checkout) — so requiring the workspace + # to exist first made every prebuilt dashboard Chat tab connection + # hard-exit before it ever got a chance to try the bundled entry.js it + # already has. See #56665. if not tui_dev: if ext_dir: p = Path(ext_dir) @@ -1901,7 +1902,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: node = _node_bin("node") return [node, "--expose-gc", str(p / "dist" / "entry.js")], p - # 1b. Bundled in wheel (pip install) + # 1b. Bundled prebuilt TUI (Docker image, Nix build, or prior npm build) bundled = _find_bundled_tui() if bundled is not None: node = _node_bin("node") @@ -2902,27 +2903,6 @@ def cmd_setup(args): run_setup_wizard(args) -def cmd_postinstall(args): - """One-shot bootstrap for pip users: install non-Python deps + run setup.""" - from hermes_cli.config import stamp_install_method - from hermes_cli.dep_ensure import ensure_dependency - - stamp_install_method("pip") - - print("⚕ Hermes post-install bootstrap") - print() - - for dep in ("node", "browser", "ripgrep", "ffmpeg"): - ensure_dependency(dep) - - if not _has_any_provider_configured(): - print() - cmd_setup(args) - else: - print() - print("✓ Post-install complete.") - - def cmd_model(args): """Select default model — starts with provider selection, then model picker.""" _require_tty("model") @@ -8962,18 +8942,11 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): on origin/?" without performing the update. ``branch_explicit`` is True iff the caller passed --branch on the CLI. - PyPI installs can't honor non-default branches, so when this is True - on a PyPI install we surface a one-line notice instead of silently - dropping the flag. + Installs that can't honor non-default branches (e.g. Docker) surface a + one-line notice instead of silently dropping the flag. """ - from hermes_cli.config import ( - detect_install_method, - format_unsupported_install_warning, - is_unsupported_install_method, - ) + from hermes_cli.config import detect_install_method method = detect_install_method(PROJECT_ROOT) - if is_unsupported_install_method(method): - print(f"⚠ {format_unsupported_install_warning(method)}") if method == "docker": # Docker can't ``git fetch`` from within the container. Surface the # same long-form ``docker pull`` guidance ``hermes update`` (apply @@ -8982,21 +8955,6 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False): from hermes_cli.config import format_docker_update_message print(format_docker_update_message()) sys.exit(1) - if method == "pip": - from hermes_cli.config import recommended_update_command - from hermes_cli.banner import check_via_pypi - if branch_explicit and branch != "main": - print(f"⚠ --branch is ignored for PyPI installs (would have checked '{branch}').") - result = check_via_pypi() - if result is None: - print("✗ Could not reach PyPI to check for updates.") - sys.exit(1) - elif result == 0: - print("✓ Already up to date.") - else: - print("⚕ Update available on PyPI.") - print(f" Run '{recommended_update_command()}' to install.") - return git_dir = PROJECT_ROOT / ".git" if not git_dir.exists(): @@ -9927,21 +9885,10 @@ def cmd_update(args): from hermes_cli.config import ( detect_install_method, format_docker_update_message, - format_unsupported_install_warning, is_managed, - is_unsupported_install_method, managed_error, ) - # Deprecation notice for pip/Homebrew installs — printed before the - # managed-mode early-return below so Homebrew users (who are blocked from - # applying the update here) still see it. Warn, don't block: the update - # itself still proceeds (except Homebrew, which is managed-mode blocked - # for an unrelated reason — brew owns its own upgrade path). - _install_method_for_warning = detect_install_method(PROJECT_ROOT) - if is_unsupported_install_method(_install_method_for_warning): - print(f"⚠ {format_unsupported_install_warning(_install_method_for_warning)}") - if is_managed(): managed_error("update Hermes Agent") return @@ -9978,67 +9925,6 @@ def cmd_update(args): _finalize_update_output(_update_io_state) -def _cmd_update_pip(args): - """Update Hermes via pip (for PyPI installs).""" - from hermes_cli import __version__ - from hermes_cli.config import is_uv_tool_install - - print(f"→ Current version: {__version__}") - print("→ Checking PyPI for updates...") - - from hermes_cli.managed_uv import ensure_uv, update_managed_uv - - # Keep managed uv current before using it. - update_managed_uv() - - uv = ensure_uv() - in_venv = sys.prefix != sys.base_prefix - # pipx-managed installs live under .../pipx/venvs//... - pipx_managed = "pipx" in sys.prefix.split(os.sep) - pipx = shutil.which("pipx") if pipx_managed else None - - # Only the ``uv pip install`` path inside a venv needs VIRTUAL_ENV - # exported (uv refuses to install without it when the launcher shim - # didn't activate the venv). ``uv tool upgrade`` / ``pipx upgrade`` - # operate on a named environment and ignore VIRTUAL_ENV, so we don't - # set it for them. - export_virtualenv = False - - if is_uv_tool_install(): - if not uv: - print("✗ Detected a uv-tool install but managed uv install failed.") - print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/") - sys.exit(1) - cmd = [uv, "tool", "upgrade", "hermes-agent"] - elif pipx_managed and pipx: - # pipx owns its own venv; ``pipx upgrade`` is the only correct path. - # Matches scripts/auto-update.sh, which already uses pipx upgrade. - cmd = [pipx, "upgrade", "hermes-agent"] - elif uv: - cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"] - if in_venv: - # Launcher shim runs the venv interpreter but doesn't export - # VIRTUAL_ENV; without it uv errors "No virtual environment found". - export_virtualenv = True - else: - # Outside any venv, ``--system`` lets uv target the active - # interpreter, matching pip's default behaviour. - cmd.insert(3, "--system") - else: - cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"] - - print(f"→ Running: {' '.join(cmd)}") - run_kwargs = {} - if export_virtualenv: - run_kwargs["env"] = {**os.environ, "VIRTUAL_ENV": sys.prefix} - result = subprocess.run(cmd, **run_kwargs) - if result.returncode != 0: - print("✗ Update failed") - sys.exit(1) - - print("✓ Update complete! Restart hermes to use the new version.") - - def _cmd_update_impl(args, gateway_mode: bool): """Body of ``cmd_update`` — kept separate so the wrapper can always restore stdio even on ``sys.exit``.""" @@ -10130,11 +10016,6 @@ def _cmd_update_impl(args, gateway_mode: bool): if sys.platform == "win32": use_zip_update = True else: - from hermes_cli.config import detect_install_method - method = detect_install_method(PROJECT_ROOT) - if method == "pip": - _cmd_update_pip(args) - return print("✗ Not a git repository. Please reinstall:") print( " curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash" @@ -13089,7 +12970,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "dump", "fallback", "gateway", "hooks", "import", "insights", "gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa", "journey", "memory-graph", "learning", - "model", "pairing", "pets", "plugins", "portal", "postinstall", "profile", + "model", "pairing", "pets", "plugins", "portal", "profile", "project", "proxy", "prompt-size", "send", "sessions", "setup", @@ -13790,10 +13671,6 @@ def main(): # ========================================================================= build_setup_parser(subparsers, cmd_setup=cmd_setup) - # ========================================================================= - # postinstall command (parser built in hermes_cli/subcommands/postinstall.py) - # ========================================================================= - build_postinstall_parser(subparsers, cmd_postinstall=cmd_postinstall) # ========================================================================= # whatsapp command (parser built in hermes_cli/subcommands/whatsapp.py) diff --git a/hermes_cli/proxy/cli.py b/hermes_cli/proxy/cli.py index 7c7b86caf085..5fc184509c43 100644 --- a/hermes_cli/proxy/cli.py +++ b/hermes_cli/proxy/cli.py @@ -20,9 +20,7 @@ logger = logging.getLogger(__name__) def _print_aiohttp_missing() -> None: print( - "hermes proxy requires aiohttp. Install one of:\n" - " pip install 'hermes-agent[messaging]'\n" - " pip install aiohttp", + "hermes proxy requires aiohttp. Run `hermes setup` to install it.", file=sys.stderr, ) diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py index 66749ba664aa..91d7fb338a22 100644 --- a/hermes_cli/proxy/server.py +++ b/hermes_cli/proxy/server.py @@ -89,8 +89,7 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application": """Build the aiohttp application bound to a specific upstream adapter.""" if not AIOHTTP_AVAILABLE: raise RuntimeError( - "aiohttp is required for `hermes proxy`. Install with: " - "pip install 'hermes-agent[messaging]' or `pip install aiohttp`." + "aiohttp is required for `hermes proxy`. Run `hermes setup` to install it." ) app = web.Application(client_max_size=MAX_REQUEST_BYTES) @@ -256,8 +255,7 @@ async def run_server( """ if not AIOHTTP_AVAILABLE: raise RuntimeError( - "aiohttp is required for `hermes proxy`. Install with: " - "pip install 'hermes-agent[messaging]' or `pip install aiohttp`." + "aiohttp is required for `hermes proxy`. Run `hermes setup` to install it." ) app = create_app(adapter) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 6fee9b013a93..aad776f86a85 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1626,7 +1626,7 @@ def resolve_runtime_provider( "in ~/.hermes/.env, or run 'gcloud auth application-default " "login' for ADC. Set the GCP project/region under vertex: in " "config.yaml if they aren't embedded in the credentials. " - "Install the extra with: pip install 'hermes-agent[vertex]'." + "Run `hermes setup` to install Vertex support." ) return { "provider": "vertex", diff --git a/hermes_cli/subcommands/postinstall.py b/hermes_cli/subcommands/postinstall.py deleted file mode 100644 index 207040ada2ff..000000000000 --- a/hermes_cli/subcommands/postinstall.py +++ /dev/null @@ -1,23 +0,0 @@ -"""``hermes postinstall`` subcommand parser. - -Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2). -Handler injected to avoid importing ``main``. -""" - -from __future__ import annotations - -from typing import Callable - - -def build_postinstall_parser(subparsers, *, cmd_postinstall: Callable) -> None: - """Attach the ``postinstall`` subcommand to ``subparsers``.""" - # ========================================================================= - # postinstall command - # ========================================================================= - postinstall_parser = subparsers.add_parser( - "postinstall", - help="Bootstrap non-Python deps for pip installs (node, browser, ripgrep, ffmpeg)", - description="One-shot post-install for pip users. Installs system " - "dependencies that pip cannot provide, then runs setup if needed.", - ) - postinstall_parser.set_defaults(func=cmd_postinstall) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index bf5ca3d357a4..c98951098535 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4099,7 +4099,7 @@ async def check_hermes_update(force: bool = False): "current_version": __version__, "behind": None, "update_available": False, - "can_apply": install_method in ("git", "pip"), + "can_apply": install_method == "git", "update_command": update_command, "message": None, } @@ -4133,9 +4133,9 @@ async def check_hermes_update(force: bool = False): else: payload["update_available"] = True # Enrich with the actual commits we're behind by, so the desktop's - # remote update overlay can show "what's changed". git/pip only; + # remote update overlay can show "what's changed". git only; # best-effort (empty list on any failure). - if install_method in ("git", "pip"): + if install_method == "git": payload["commits"] = await asyncio.to_thread(_recent_upstream_commits) return payload diff --git a/hermes_constants.py b/hermes_constants.py index 639d6d48f000..7cb52e16ca2a 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -8,7 +8,6 @@ import os import shutil import stat import sys -import sysconfig from contextvars import ContextVar, Token from pathlib import Path @@ -191,23 +190,6 @@ def get_default_hermes_root() -> Path: return env_path -def _get_packaged_data_dir(name: str) -> Path | None: - """Return an installed data-files directory if one exists. - - Used to discover bundled skills/optional-skills when Hermes is installed - from a wheel that emitted them via setuptools data_files. - """ - candidates = [] - for scheme in ("data", "purelib", "platlib"): - raw = sysconfig.get_path(scheme) - if raw: - candidates.append(Path(raw) / name) - for candidate in candidates: - if candidate.exists(): - return candidate - return None - - def get_optional_skills_dir(default: Path | None = None) -> Path: """Return the optional-skills directory, honoring package-manager wrappers. @@ -217,9 +199,6 @@ def get_optional_skills_dir(default: Path | None = None) -> Path: override = os.getenv("HERMES_OPTIONAL_SKILLS", "").strip() if override: return Path(override) - packaged = _get_packaged_data_dir("optional-skills") - if packaged is not None: - return packaged if default is not None: return default return get_hermes_home() / "optional-skills" @@ -236,9 +215,6 @@ def get_optional_mcps_dir(default: Path | None = None) -> Path: override = os.getenv("HERMES_OPTIONAL_MCPS", "").strip() if override: return Path(override) - packaged = _get_packaged_data_dir("optional-mcps") - if packaged is not None: - return packaged if default is not None: return default return get_hermes_home() / "optional-mcps" @@ -249,16 +225,12 @@ def get_bundled_skills_dir(default: Path | None = None) -> Path: Resolution order: 1. ``HERMES_BUNDLED_SKILLS`` env var (Nix wrapper / explicit override) - 2. Wheel-installed ``/skills`` (pip install path) - 3. Caller-supplied ``default`` (typically the source-checkout path) - 4. ``/skills`` last-resort + 2. Caller-supplied ``default`` (typically the source-checkout path) + 3. ``/skills`` last-resort """ override = os.getenv("HERMES_BUNDLED_SKILLS", "").strip() if override: return Path(override) - packaged = _get_packaged_data_dir("skills") - if packaged is not None: - return packaged if default is not None: return default return get_hermes_home() / "skills" diff --git a/nix/checks.nix b/nix/checks.nix index 7f625ca4fe4b..9e69d4c0f832 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -192,7 +192,9 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) (echo "FAIL: HERMES_BUNDLED_LOCALES not in wrapper"; exit 1) echo "PASS: HERMES_BUNDLED_LOCALES set in wrapper" - echo "=== Rendering via the wrapper override (HERMES_BUNDLED_LOCALES) ===" + # locales/ is a bare data dir (no __init__.py), shipped via a + # symlink + HERMES_BUNDLED_LOCALES (not via wheel data-files). + # Verify the wrapper override resolves real strings. export HOME=$(mktemp -d) RENDERED=$(cd "$HOME" && HERMES_BUNDLED_LOCALES=${hermes-agent}/share/hermes-agent/locales \ ${hermesVenv}/bin/python3 -c "from agent import i18n; print(i18n.t('gateway.reset.header_default', lang='en'))") @@ -200,25 +202,39 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) test "$RENDERED" != "gateway.reset.header_default" || (echo "FAIL: i18n returned the raw key with HERMES_BUNDLED_LOCALES set"; exit 1) echo "PASS: i18n renders a human string via the wrapper override" - # Defense-in-depth check: the sealed venv must ALSO resolve catalogs - # with NO env var, via the wheel's setuptools data-files materialized - # into the venv data scheme. If a future uv2nix bump drops data-files, - # the wrapper override above would mask the regression at runtime while - # `pip install`/other sealed paths silently break — this catches it. - echo "=== Rendering WITHOUT the env var (data-files materialization) ===" - BARE_DIR=$(cd "$HOME" && ${hermesVenv}/bin/python3 -c "from agent import i18n; print(i18n._locales_dir())") - BARE=$(cd "$HOME" && ${hermesVenv}/bin/python3 -c "from agent import i18n; print(i18n.t('gateway.reset.header_default', lang='en'))") - echo "resolved dir (no env var): $BARE_DIR" - echo "rendered: $BARE" - test "$BARE" != "gateway.reset.header_default" || \ - (echo "FAIL: sealed venv could not resolve locales without HERMES_BUNDLED_LOCALES — data-files materialization regressed"; exit 1) - echo "PASS: sealed venv resolves locales via data-files without the env var" - echo "=== All bundled locales checks passed ===" mkdir -p $out echo "ok" > $out/result ''; + # Verify bundled optional-mcps catalog is present and resolvable. + # optional-mcps/ is a bare data dir shipped via symlink + + # HERMES_OPTIONAL_MCPS (not via wheel data-files). + bundled-mcps = pkgs.runCommand "hermes-bundled-mcps" { } '' + set -e + echo "=== Checking bundled optional-mcps ===" + test -d ${hermes-agent}/share/hermes-agent/optional-mcps || (echo "FAIL: optional-mcps directory missing"; exit 1) + echo "PASS: optional-mcps directory exists" + + MANIFEST_COUNT=$(find -L ${hermes-agent}/share/hermes-agent/optional-mcps -name "manifest.yaml" | wc -l) + test "$MANIFEST_COUNT" -gt 0 || (echo "FAIL: no manifest.yaml files found"; exit 1) + echo "PASS: $MANIFEST_COUNT catalog manifests found" + + grep -q "HERMES_OPTIONAL_MCPS" ${hermes-agent}/bin/hermes || \ + (echo "FAIL: HERMES_OPTIONAL_MCPS not in wrapper"; exit 1) + echo "PASS: HERMES_OPTIONAL_MCPS set in wrapper" + + export HOME=$(mktemp -d) + CATALOG=$(cd "$HOME" && ${hermes-agent}/bin/hermes mcp catalog 2>/dev/null || true) + echo "catalog output: $CATALOG" + test -n "$CATALOG" || (echo "FAIL: hermes mcp catalog returned empty"; exit 1) + echo "PASS: mcp catalog resolves entries" + + echo "=== All bundled optional-mcps checks passed ===" + mkdir -p $out + echo "ok" > $out/result + ''; + # Verify bundled TUI is present and compiled bundled-tui = pkgs.runCommand "hermes-bundled-tui" { } '' set -e diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index f1a23b1029d5..90cbdbeb69d9 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -86,18 +86,16 @@ let # i18n locale catalogs (locales/*.yaml). Shipped into the store and pointed # at by HERMES_BUNDLED_LOCALES so the wrapped binary always resolves human # strings instead of raw i18n keys (#23943 / #27632 / #35374). - # - # Defense-in-depth, not load-bearing: the wheel already declares locales/ as - # setuptools data-files, so uv2nix materializes them into the venv's data - # scheme and agent/i18n.py resolves them with no env var. The wrapper override - # pins the store path so a future uv2nix change that drops data-files can't - # silently ship raw keys via `nix build` (checks don't run on a plain build). - # The bundled-locales flake check verifies BOTH paths independently. - # - # Plain cleanSource (no __pycache__ filter): locales/ is bare *.yaml, never - # compiled, so it never carries a __pycache__ dir to exclude. bundledLocales = lib.cleanSource ../locales; + # Shipped MCP catalog (optional-mcps//manifest.yaml). Same bare-data-dir + # case as locales: not a Python package, so it's symlinked into the store and + # exposed via HERMES_OPTIONAL_MCPS. + bundledOptionalMcps = lib.cleanSourceWith { + src = ../optional-mcps; + filter = path: _type: !(lib.hasInfix "/__pycache__/" path); + }; + runtimeDeps = [ nodejs ripgrep @@ -181,6 +179,7 @@ stdenv.mkDerivation (finalAttrs: { ln -s ${bundledOptionalSkills} $out/share/hermes-agent/optional-skills ln -s ${bundledPlugins} $out/share/hermes-agent/plugins ln -s ${bundledLocales} $out/share/hermes-agent/locales + ln -s ${bundledOptionalMcps} $out/share/hermes-agent/optional-mcps ln -s ${hermesWeb} $out/share/hermes-agent/web_dist ln -s ${hermesTui}/lib/hermes-tui $out/ui-tui @@ -192,6 +191,7 @@ stdenv.mkDerivation (finalAttrs: { --set HERMES_OPTIONAL_SKILLS $out/share/hermes-agent/optional-skills \ --set HERMES_BUNDLED_PLUGINS $out/share/hermes-agent/plugins \ --set HERMES_BUNDLED_LOCALES $out/share/hermes-agent/locales \ + --set HERMES_OPTIONAL_MCPS $out/share/hermes-agent/optional-mcps \ --set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \ --set HERMES_TUI_DIR $out/ui-tui \ --set HERMES_PYTHON ${hermesVenv}/bin/python3 \ diff --git a/nix/lib.nix b/nix/lib.nix index a705331c12b3..f18d3f0b23d7 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -106,10 +106,14 @@ let # wheel's data_files — setup.py's _data_file_tree returns [] # for a missing dir, so the wheel builds fine without them. # This keeps SKILL.md edits from rebuilding the Python venv. - # NOTE: optional-mcps must stay — pyproject.toml lists its - # manifests as explicit data-files, which error when missing. "skills" "optional-skills" + # locales/ and optional-mcps/ are bare data dirs (no + # __init__.py) shipped via symlinks + HERMES_BUNDLED_LOCALES + # / HERMES_OPTIONAL_MCPS, not via the wheel. Excluding them + # keeps catalog edits from rebuilding the Python venv. + "locales" + "optional-mcps" ]; excludedFiles = [ # JS root manifests diff --git a/nix/python.nix b/nix/python.nix index 602c08a3fd04..316b7687a663 100644 --- a/nix/python.nix +++ b/nix/python.nix @@ -110,6 +110,15 @@ let overlay buildSystemOverrides pythonPackageOverrides + # ``setup.py`` permits wheel/sdist creation only from the sealed + # Hermes derivation. This is deliberately a derivation environment + # variable, not a devShell variable: ``nix develop -c uv build`` + # must remain blocked. + (final: prev: { + hermes-agent = prev.hermes-agent.overrideAttrs (_old: { + HERMES_NIX_BUILD = "1"; + }); + }) ] ); diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md deleted file mode 100644 index e53d3fd0b3e1..000000000000 --- a/packaging/homebrew/README.md +++ /dev/null @@ -1,14 +0,0 @@ -Homebrew packaging notes for Hermes Agent. - -Use `packaging/homebrew/hermes-agent.rb` as a tap or `homebrew-core` starting point. - -Key choices: -- Stable builds should target the semver-named sdist asset attached to each GitHub release, not the CalVer tag tarball. -- `faster-whisper` now lives in the `voice` extra, which keeps wheel-only transitive dependencies out of the base Homebrew formula. -- The wrapper exports `HERMES_BUNDLED_SKILLS`, `HERMES_OPTIONAL_SKILLS`, and `HERMES_MANAGED=homebrew` so packaged installs keep runtime assets and defer upgrades to Homebrew. - -Typical update flow: -1. Bump the formula `url`, `version`, and `sha256`. -2. Refresh Python resources with `brew update-python-resources --print-only hermes-agent`. -3. Keep `ignore_packages: %w[certifi cryptography pydantic]`. -4. Verify `brew audit --new --strict hermes-agent` and `brew test hermes-agent`. diff --git a/packaging/homebrew/hermes-agent.rb b/packaging/homebrew/hermes-agent.rb deleted file mode 100644 index 7c00fc6acf8f..000000000000 --- a/packaging/homebrew/hermes-agent.rb +++ /dev/null @@ -1,48 +0,0 @@ -class HermesAgent < Formula - include Language::Python::Virtualenv - - desc "Self-improving AI agent that creates skills from experience" - homepage "https://hermes-agent.nousresearch.com" - # Stable source should point at the semver-named sdist asset attached by - # scripts/release.py, not the CalVer tag tarball. - url "https://github.com/NousResearch/hermes-agent/releases/download/v2026.3.30/hermes_agent-0.6.0.tar.gz" - sha256 "" - license "MIT" - - depends_on "certifi" => :no_linkage - depends_on "cryptography" => :no_linkage - depends_on "libyaml" - depends_on "python@3.14" - - pypi_packages ignore_packages: %w[certifi cryptography pydantic] - - # Refresh resource stanzas after bumping the source url/version: - # brew update-python-resources --print-only hermes-agent - - def install - venv = virtualenv_create(libexec, "python3.14") - venv.pip_install resources - venv.pip_install buildpath - - pkgshare.install "skills", "optional-skills" - - %w[hermes hermes-agent hermes-acp].each do |exe| - next unless (libexec/"bin"/exe).exist? - - (bin/exe).write_env_script( - libexec/"bin"/exe, - HERMES_BUNDLED_SKILLS: pkgshare/"skills", - HERMES_OPTIONAL_SKILLS: pkgshare/"optional-skills", - HERMES_MANAGED: "homebrew" - ) - end - end - - test do - assert_match "Hermes Agent v#{version}", shell_output("#{bin}/hermes version") - - managed = shell_output("#{bin}/hermes update 2>&1") - assert_match "managed by Homebrew", managed - assert_match "brew upgrade hermes-agent", managed - end -end diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index f843565cc771..a97d351a406f 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -9472,7 +9472,7 @@ def register(ctx) -> None: check_fn=check_discord_requirements, is_connected=_is_connected, required_env=["DISCORD_BOT_TOKEN"], - install_hint="pip install 'hermes-agent[messaging]'", + install_hint="Run `hermes setup` to install Discord support.", # Interactive setup wizard — replaces the central # hermes_cli/setup.py::_setup_discord function. Same shape as Teams. setup_fn=interactive_setup, diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 519c9afa413c..882b695b90c5 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -5465,7 +5465,7 @@ async def _standalone_send( (images, video, voice, documents). Replaces the legacy _send_feishu helper. """ if not FEISHU_AVAILABLE: - return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"} + return {"error": "Feishu dependencies not installed. Run `hermes setup` to install Feishu support."} media_files = media_files or [] try: @@ -5713,7 +5713,7 @@ def register(ctx) -> None: is_connected=_is_connected, validate_config=_is_connected, required_env=["FEISHU_APP_ID", "FEISHU_APP_SECRET"], - install_hint="pip install 'hermes-agent[feishu]'", + install_hint="Run `hermes setup` to install Feishu support.", setup_fn=interactive_setup, apply_yaml_config_fn=_apply_yaml_config, allowed_users_env="FEISHU_ALLOWED_USERS", diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 2be49ff1742e..540f9bf84566 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -3689,7 +3689,7 @@ def register(ctx) -> None: required_env=[ "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", ], - install_hint="pip install 'hermes-agent[google_chat]'", + install_hint="Run `hermes setup` to install Google Chat support.", setup_fn=interactive_setup, # Env-driven auto-configuration — the core env-populator hook calls # this during ``_apply_env_overrides`` and seeds diff --git a/plugins/platforms/google_chat/oauth.py b/plugins/platforms/google_chat/oauth.py index 277b2396f0c5..eb92528bbee4 100644 --- a/plugins/platforms/google_chat/oauth.py +++ b/plugins/platforms/google_chat/oauth.py @@ -199,7 +199,7 @@ def load_user_credentials(email: Optional[str] = None) -> Optional[Any]: except ImportError: logger.warning( "[google_chat_user_oauth] google-auth not installed; user-OAuth " - "attachment delivery is disabled. Install hermes-agent[google_chat]." + "attachment delivery is disabled. Run `hermes setup` to install Google Chat support." ) return None @@ -388,8 +388,7 @@ def install_deps() -> bool: return True except Exception as exc: print(f"ERROR: Failed to install dependencies: {exc}") - print("Or install via the optional extra:") - print(" pip install 'hermes-agent[google_chat]'") + print("Run `hermes setup` to repair the managed installation, then retry.") return False diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index bde89f4ad1af..420d0af27996 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -5163,7 +5163,7 @@ def register(ctx) -> None: check_fn=check_slack_requirements, is_connected=_is_connected, required_env=["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"], - install_hint="pip install 'hermes-agent[slack]'", + install_hint="Run `hermes setup` to install Slack support.", # Interactive setup wizard — replaces hermes_cli/setup.py::_setup_slack # and the static _PLATFORMS["slack"] dict in hermes_cli/gateway.py. setup_fn=interactive_setup, diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 09f97598e264..a33f41361de5 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -9435,7 +9435,7 @@ def register(ctx) -> None: check_fn=check_telegram_requirements, is_connected=_is_connected, required_env=["TELEGRAM_BOT_TOKEN"], - install_hint="pip install 'hermes-agent[telegram]'", + install_hint="Run `hermes setup` to install Telegram support.", setup_fn=interactive_setup, apply_yaml_config_fn=_apply_yaml_config, allowed_users_env="TELEGRAM_ALLOWED_USERS", diff --git a/plugins/platforms/wecom/adapter.py b/plugins/platforms/wecom/adapter.py index 80ca2ca1439f..4fc743aa6e23 100644 --- a/plugins/platforms/wecom/adapter.py +++ b/plugins/platforms/wecom/adapter.py @@ -1861,7 +1861,7 @@ def register(ctx) -> None: is_connected=_is_connected, validate_config=_is_connected, required_env=["WECOM_BOT_ID", "WECOM_SECRET"], - install_hint="pip install 'hermes-agent[wecom]'", + install_hint="Run `hermes setup` to install WeCom support.", setup_fn=interactive_setup, allowed_users_env="WECOM_ALLOWED_USERS", allow_all_env="WECOM_ALLOW_ALL_USERS", @@ -1881,7 +1881,7 @@ def register(ctx) -> None: is_connected=_callback_is_connected, validate_config=_callback_is_connected, required_env=["WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"], - install_hint="pip install 'hermes-agent[wecom]'", + install_hint="Run `hermes setup` to install WeCom support.", allowed_users_env="WECOM_CALLBACK_ALLOWED_USERS", allow_all_env="WECOM_CALLBACK_ALLOW_ALL_USERS", emoji="💼", diff --git a/pyproject.toml b/pyproject.toml index ca3648279575..a2a1d6c0ee24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,4 @@ -# PEP 639 SPDX license expression (`license = "MIT"` below) requires -# setuptools>=77. Keep this floor in lockstep with the `license` form in -# [project]; an older build backend rejects the string form. -[build-system] -requires = ["setuptools>=77.0,<83"] -build-backend = "setuptools.build_meta" + [project] name = "hermes-agent" @@ -175,18 +170,11 @@ wecom = ["defusedxml==0.7.1"] cli = ["simple-term-menu==1.6.6"] tts-premium = ["elevenlabs==1.59.0"] voice = [ - # Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime), - # so keep it out of the base install for source-build packagers like Homebrew. + # Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime). "faster-whisper==1.2.1", "sounddevice==0.5.5", "numpy==2.4.3", ] -pty = [ - # Kept as a no-op back-compat alias — `ptyprocess` and `pywinpty` are now - # in the main `dependencies` list (with the same platform markers), so - # any existing `pip install hermes-agent[pty]` invocations resolve cleanly - # without pulling in extra packages. -] honcho = ["honcho-ai==2.2.0"] # Cloud memory providers — opt-in, lazy-installed via tools/lazy_deps.py # (memory.supermemory / memory.mem0) at first use. Exact pins MUST match the @@ -196,11 +184,16 @@ honcho = ["honcho-ai==2.2.0"] supermemory = ["supermemory==3.50.0"] mem0 = ["mem0ai==2.0.10"] # Image resize recovery for the vision tools. Pillow is now a CORE dependency -# (see the main `dependencies` list) since the byte/pixel shrink paths are on +# (see the main `dependencies` list above) since the byte/pixel shrink paths are on # the default vision-embed path and the mid-session lazy install deadlocked the # CLI under prompt_toolkit (#40490). This extra is kept as a no-op back-compat -# alias so existing `pip install hermes-agent[vision]` invocations still resolve. +# alias so existing requests for the `vision` extra resolve. vision = [] +# Kept as a no-op back-compat alias — `ptyprocess` and `pywinpty` are now +# in the main `dependencies` list (with the same platform markers), so +# any existing requests for the `pty` extra resolve cleanly +# without pulling in extra packages. +pty = [] # CVE-2026-48710 (BadHost): Starlette is pulled transitively by mcp's # sse-starlette / HTTP-SSE stack (and by fastapi in the `web` extra). Before # 1.0.1, a malformed Host header makes `request.url.path` desync from the path @@ -236,7 +229,6 @@ termux = [ "python-telegram-bot[webhooks]==22.6", "hermes-agent[cron]", "hermes-agent[cli]", - "hermes-agent[pty]", "hermes-agent[mcp]", "hermes-agent[honcho]", "hermes-agent[acp]", @@ -251,14 +243,15 @@ termux-all = [ "hermes-agent[homeassistant]", "hermes-agent[sms]", "hermes-agent[web]", + "hermes-agent[pty]", ] dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"] feishu = ["lark-oapi==1.6.8", "qrcode==7.4.2"] google = [ # Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts, - # Sheets, Docs). Declared here so packagers (Nix, Homebrew) ship them with - # the [all] extra and users don't hit runtime `pip install` paths that fail - # in environments without pip (e.g. Nix-managed Python). + # Sheets, Docs). Declared here so dev environments (`uv sync --extra google`) + # and packagers ship them without hitting runtime `pip install` paths that + # fail in environments without pip (e.g. Nix-managed Python). "google-api-python-client==2.194.0", "google-auth-oauthlib==1.3.1", "google-auth-httplib2==0.3.1", @@ -278,11 +271,11 @@ all = [ # Policy (2026-05-12): `[all]` includes only extras that genuinely # CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every # session can use, things needed before the agent loop is alive - # (terminal/CLI), and skill deps that packagers (Nix, AUR, Homebrew) - # need in the wheel. Anything an opt-in backend (provider, search, - # TTS, image, memory, messaging platform, terminal sandbox) needs - # MUST live exclusively in `LAZY_DEPS` and resolve at first use — - # otherwise one quarantined PyPI release breaks every fresh install. + # (terminal/CLI), and skill deps that dev environments need. + # Anything an opt-in backend (provider, search, TTS, image, memory, + # messaging platform, terminal sandbox) needs MUST live exclusively in + # `LAZY_DEPS` and resolve at first use — otherwise one quarantined PyPI + # release breaks every fresh install. # # Removed from [all] on 2026-05-12 (covered by lazy-install): # anthropic, exa, firecrawl, parallel-web, fal, edge-tts, @@ -308,58 +301,31 @@ all = [ "hermes-agent[youtube]", ] +[build-system] +requires = ["setuptools>=77.0,<83"] +build-backend = "setuptools.build_meta" + [project.scripts] hermes = "hermes_cli.main:main" hermes-agent = "run_agent:main" hermes-acp = "acp_adapter.entry:main" [tool.setuptools] +# Top-level single-file modules (not packages). Without this, uv2nix's +# sealed venv is missing hermes_constants, run_agent, etc. py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils", "mcp_serve"] -[tool.setuptools.data-files] -# i18n catalogs. locales/ is a bare data directory (no __init__.py), so it is -# neither a package (packages.find) nor package-data (which attaches to a -# package). data-files ships it in the wheel; MANIFEST.in `graft locales` -# ships it in the sdist. Without this, sealed installs (pip wheel, Nix store -# venv) drop the catalogs and gateway/CLI commands surface raw i18n keys like -# `gateway.reset.header_default` (#27632, #35374, #23943). -locales = ["locales/*.yaml"] -# Shipped MCP catalog (optional-mcps//manifest.yaml). Same bare-data-dir -# case as locales: data-files ships it in the wheel, `graft optional-mcps` in -# MANIFEST.in ships it in the sdist. Without this, `hermes mcp catalog` and the -# dashboard catalog screen come up empty on packaged installs even though the -# manifests exist in the repo (hermes_cli/mcp_catalog.py:_catalog_root resolves -# the packaged dir; list_catalog() returns [] when it's missing). -# -# data-files flattens every glob match into its single target dir, so each -# catalog entry needs its OWN target to preserve the per-entry directory the -# catalog iterates over (a shared `optional-mcps/*/*` glob would collapse all -# manifests into one colliding optional-mcps/manifest.yaml). One target per -# entry; tests/test_packaging_metadata.py enforces an entry per optional-mcps/. -"optional-mcps/linear" = ["optional-mcps/linear/manifest.yaml"] -"optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"] - -[tool.setuptools.package-data] -hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"] -gateway = ["assets/**/*"] -plugins = [ - "*/dashboard/manifest.json", - "*/dashboard/dist/*", - "*/dashboard/dist/**/*", - # Plugin discovery (hermes_cli/plugins.py) reads a plugin.yaml/plugin.yml - # manifest from each bundled plugin directory to register it. Wheels only - # carry files declared here, so without this glob the wheel ships every - # plugin's Python code but none of its manifests — the scan finds zero - # plugins and all gateway platforms fail with "No adapter available for - # " (#34034), web-search providers go missing (#28149), etc. - "**/plugin.yaml", - "**/plugin.yml", - "**/README.md", -] - [tool.setuptools.packages.find] include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "cron.*", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"] +[tool.setuptools.package-data] +# gateway/assets/ ships status_phrases.yaml and the Telegram BotFather +# screenshot. Without this, sealed venvs (uv2nix) silently lose both — +# status phrases fall back to the tiny hardcoded set and the Telegram +# topic-setup image disappears. Loaded via Path(__file__).parent / "assets" +# in gateway/status_phrases.py and gateway/run.py. +gateway = ["assets/**/*"] + [tool.pytest.ini_options] testpaths = ["tests"] markers = [ diff --git a/scripts/check-windows-footguns.py b/scripts/check-windows-footguns.py index 7ae7ca50c4e7..c0ca6622ec33 100644 --- a/scripts/check-windows-footguns.py +++ b/scripts/check-windows-footguns.py @@ -576,7 +576,6 @@ def main(argv: list[str]) -> int: REPO_ROOT / "plugins", REPO_ROOT / "scripts", REPO_ROOT / "acp_adapter", - REPO_ROOT / "acp_registry", ] roots = [r for r in roots if r.exists()] elif args.diff: diff --git a/scripts/install.sh b/scripts/install.sh index ef95fc7aecf2..43755a59a762 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -74,7 +74,7 @@ NO_SKILLS=false BRANCH="main" INSTALL_COMMIT="" ENSURE_DEPS="" -POSTINSTALL_MODE=false + MANIFEST_MODE=false STAGE_NAME="" JSON_OUTPUT=false @@ -150,10 +150,7 @@ while [[ $# -gt 0 ]]; do ENSURE_DEPS="$2" shift 2 ;; - --postinstall) - POSTINSTALL_MODE=true - shift - ;; + -h|--help) echo "Hermes Agent Installer" echo "" @@ -190,9 +187,7 @@ while [[ $# -gt 0 ]]; do echo " --ensure DEPS Install only specified deps (comma-separated)" echo " Supported: node, browser, ripgrep, ffmpeg" echo " Does NOT clone repo or create venv" - echo " --postinstall Run post-install setup only (for pip users)" - echo " Installs optional deps + runs hermes setup" - echo " Does NOT clone repo or create venv" + exit 0 ;; *) @@ -2584,29 +2579,6 @@ ensure_mode() { done } -postinstall_mode() { - print_banner - detect_os - - log_info "Post-install mode: setting up Hermes for pip install" - - check_node - check_network_prerequisites - install_system_packages - - if [ "$HAS_NODE" = true ] && [ "$SKIP_BROWSER" = false ]; then - ensure_browser - fi - - HERMES_CMD="$(command -v hermes 2>/dev/null || echo "")" - if [ -n "$HERMES_CMD" ]; then - log_info "Running hermes setup..." - "$HERMES_CMD" setup - else - log_warn "hermes command not found on PATH" - log_info "Try: python -m hermes_cli.main setup" - fi -} # Clear the cached Electron download + any half-written unpacked output so the # next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in @@ -3150,8 +3122,6 @@ elif [ -n "$STAGE_NAME" ]; then run_stage_protocol "$STAGE_NAME" elif [ -n "$ENSURE_DEPS" ]; then ensure_mode -elif [ "$POSTINSTALL_MODE" = true ]; then - postinstall_mode else main fi diff --git a/scripts/release.py b/scripts/release.py index 00da845f8f20..9e90850928e4 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -34,11 +34,6 @@ REPO_ROOT = Path(__file__).resolve().parent.parent VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py" PYPROJECT_FILE = REPO_ROOT / "pyproject.toml" -# ACP Registry manifest must stay version-locked with pyproject.toml. -# tests/acp/test_registry_manifest.py enforces this lockstep so the release -# bump touches both files atomically. -ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" - # ────────────────────────────────────────────────────────────────────── # Git email → GitHub username mapping # ────────────────────────────────────────────────────────────────────── @@ -2203,70 +2198,6 @@ def update_version_files(semver: str, calver_date: str): ) desktop_pkg.write_text(pkg_text, encoding="utf-8") - # Update ACP Registry manifest + npm launcher (must stay version-locked - # with pyproject — enforced by tests/acp/test_registry_manifest.py). - _update_acp_registry_versions(semver) - - -def _update_acp_registry_versions(semver: str) -> None: - """Bump the ACP Registry manifest's version + uvx package pin in lockstep - with pyproject. - - Skips silently if the manifest is missing — older release branches predate - the ACP Registry assets. - """ - if ACP_REGISTRY_MANIFEST.exists(): - manifest = json.loads(ACP_REGISTRY_MANIFEST.read_text(encoding="utf-8")) - manifest["version"] = semver - uvx = manifest.get("distribution", {}).get("uvx", {}) - if "package" in uvx: - uvx["package"] = f"hermes-agent[acp]=={semver}" - # Preserve trailing newline + 2-space indent the file already uses. - ACP_REGISTRY_MANIFEST.write_text( - json.dumps(manifest, indent=2) + "\n", encoding="utf-8" - ) - - -def build_release_artifacts(semver: str) -> list[Path]: - """Build sdist/wheel artifacts for the current release. - - Tries ``uv build`` first (matching the CI workflow), falls back to - ``python -m build`` if uv is unavailable. - """ - dist_dir = REPO_ROOT / "dist" - shutil.rmtree(dist_dir, ignore_errors=True) - - # Prefer uv build (matches CI workflow), fall back to python -m build. - uv_bin = shutil.which("uv") - if uv_bin: - cmd = [uv_bin, "build", "--sdist", "--wheel"] - else: - cmd = [sys.executable, "-m", "build", "--sdist", "--wheel"] - - result = subprocess.run( - cmd, - cwd=str(REPO_ROOT), - capture_output=True, - text=True, - ) - if result.returncode != 0: - print(" ⚠ Could not build Python release artifacts.") - stderr = result.stderr.strip() - stdout = result.stdout.strip() - if stderr: - print(f" {stderr.splitlines()[-1]}") - elif stdout: - print(f" {stdout.splitlines()[-1]}") - print(" Install uv or the 'build' package to attach sdist/wheel assets.") - return [] - - artifacts = sorted(p for p in dist_dir.iterdir() if p.is_file()) - matching = [p for p in artifacts if semver in p.name] - if not matching: - print(" ⚠ Built artifacts did not match the expected release version.") - return [] - return matching - def resolve_author(name: str, email: str) -> str: """Resolve a git author to a GitHub @mention.""" @@ -2606,8 +2537,6 @@ def main(): # Commit version bump add_files = [str(VERSION_FILE), str(PYPROJECT_FILE)] - if ACP_REGISTRY_MANIFEST.exists(): - add_files.append(str(ACP_REGISTRY_MANIFEST)) add_result = git_result("add", *add_files) if add_result.returncode != 0: print(f" ✗ Failed to stage version files: {add_result.stderr.strip()}") @@ -2640,14 +2569,6 @@ def main(): print(" Continue manually after fixing access:") print(" git push origin HEAD --tags") - # Build semver-named Python artifacts so downstream packagers - # (e.g. Homebrew) can target them without relying on CalVer tag names. - artifacts = build_release_artifacts(new_version) - if artifacts: - print(" ✓ Built release artifacts:") - for artifact in artifacts: - print(f" - {artifact.relative_to(REPO_ROOT)}") - # Create GitHub release changelog_file = REPO_ROOT / ".release_notes.md" changelog_file.write_text(changelog, encoding="utf-8") @@ -2657,7 +2578,6 @@ def main(): "--title", f"Hermes Agent v{new_version} ({calver_date})", "--notes-file", str(changelog_file), ] - gh_cmd.extend(str(path) for path in artifacts) gh_bin = shutil.which("gh") if gh_bin: @@ -2682,9 +2602,9 @@ def main(): print(" Tag was created locally. Create the release manually:") print( f" gh release create {tag_name} --title 'Hermes Agent v{new_version} ({calver_date})' " - f"--notes-file .release_notes.md {' '.join(str(path) for path in artifacts)}" + f"--notes-file .release_notes.md" ) - print(f"\n ✓ Release artifacts prepared for manual publish: v{new_version} ({tag_name})") + print(f"\n ✓ Release v{new_version} ({tag_name}) prepared for manual publish.") else: print(f"\n{'='*60}") print(" Dry run complete. To publish, add --publish") diff --git a/setup.py b/setup.py index 6e3e8c4272e8..fac7fe88161e 100644 --- a/setup.py +++ b/setup.py @@ -1,87 +1,74 @@ -from __future__ import annotations +""" +setup.py — wheel/sdist build guard. -from collections import defaultdict -from pathlib import Path -import tempfile +pip/PyPI and Homebrew are no longer supported distribution methods for +Hermes Agent (see website/docs/getting-started/platform-support.md). The +wheel would ship without bundled assets (locales, skills, optional-mcps, +web_dist, tui_dist, plugin manifests) since those are resolved at runtime +via env-var overrides set by the nix wrapper or the source-checkout layout. + +This file overrides the ``bdist_wheel`` and ``sdist`` setuptools commands +to raise an error when run outside a Nix build. The PEP 517 +``build_wheel`` / ``build_sdist`` hooks in +``setuptools.build_meta`` call these commands internally, so the guard +fires for ``uv build``, ``pip wheel``, ``python -m build``, and direct +``setup.py`` invocations alike. + +The one legitimate consumer of ``build_wheel`` is uv2nix, which calls +``setuptools.build_meta.build_wheel`` (→ ``bdist_wheel``) inside a Nix +build sandbox. ``nix/python.nix`` sets ``HERMES_NIX_BUILD=1`` on the +Hermes package derivation, so only that build may create an artifact. + +Editable installs (``uv sync``, ``pip install -e .``, ``nix develop``) +use ``build_editable``, which does NOT call ``bdist_wheel`` — it calls +``build_ext`` in editable mode. So the guard does not affect development. +""" + +import os from setuptools import setup -from setuptools.command.build import build as _build -from setuptools.command.egg_info import egg_info as _egg_info +from setuptools.command.sdist import sdist +_IN_NIX_BUILD = os.environ.get("HERMES_NIX_BUILD") == "1" -REPO_ROOT = Path(__file__).parent.resolve() - - -def _source_tree_is_writable() -> bool: - probe = REPO_ROOT / ".setuptools-write-probe" - try: - with probe.open("w", encoding="utf-8") as handle: - handle.write("") - probe.unlink() - except OSError: - try: - probe.unlink(missing_ok=True) - except OSError: - pass - return False - return True - - -def _temporary_build_dir(kind: str) -> str: - return tempfile.mkdtemp(prefix=f"hermes-agent-{kind}-") - - -def _would_write_under_source(path_value: str | None) -> bool: - if path_value is None: - return True - path = Path(path_value) - if not path.is_absolute(): - path = REPO_ROOT / path - try: - path.resolve().relative_to(REPO_ROOT) - except ValueError: - return False - return True - - -class ReadOnlySourceBuild(_build): - def finalize_options(self) -> None: - if ( - not _source_tree_is_writable() - and _would_write_under_source(self.build_base) - ): - self.build_base = _temporary_build_dir("build") - super().finalize_options() - - -class ReadOnlySourceEggInfo(_egg_info): - def finalize_options(self) -> None: - if ( - not _source_tree_is_writable() - and _would_write_under_source(self.egg_base) - ): - self.egg_base = _temporary_build_dir("egg-info") - super().finalize_options() - - -def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]: - root = REPO_ROOT / root_name - grouped: defaultdict[str, list[str]] = defaultdict(list) - for path in sorted(root.rglob("*")): - if not path.is_file(): - continue - rel_path = path.relative_to(REPO_ROOT) - grouped[str(rel_path.parent)].append(str(rel_path)) - return sorted(grouped.items()) - - -setup( - cmdclass={ - "build": ReadOnlySourceBuild, - "egg_info": ReadOnlySourceEggInfo, - }, - data_files=[ - *_data_file_tree("skills"), - *_data_file_tree("optional-skills"), - ] +_BLOCK_MESSAGE = ( + "Building wheels or sdists for hermes-agent is not supported.\n" + "Hermes is distributed via the shell installer, Docker image, or Nix.\n" + "See: https://hermes-agent.nousresearch.com/docs/getting-started/installation\n" + "\n" + "If you are developing, use an editable install instead:\n" + " uv sync # or: uv pip install -e .\n" + "\n" + "If you are building with Nix (uv2nix), this error should not fire —\n" + "the Hermes Nix derivation sets HERMES_NIX_BUILD=1. If it does, file a bug." ) + + +class _GuardedSdist(sdist): + def run(self, *args, **kwargs): + if not _IN_NIX_BUILD: + raise RuntimeError(_BLOCK_MESSAGE) + return super().run(*args, **kwargs) + + +cmdclass = {"sdist": _GuardedSdist} + +# bdist_wheel is only available when the `wheel` package is installed. +# setuptools.build_meta.build_wheel() calls it internally, so the guard +# fires for all PEP 517 wheel build paths. Define the subclass only when +# the import succeeds — otherwise a None base class raises TypeError at +# class-definition time, before the cmdclass guard can run. +try: + from setuptools.command.bdist_wheel import bdist_wheel + + class _GuardedBdistWheel(bdist_wheel): + def run(self, *args, **kwargs): + if not _IN_NIX_BUILD: + raise RuntimeError(_BLOCK_MESSAGE) + return super().run(*args, **kwargs) + + cmdclass["bdist_wheel"] = _GuardedBdistWheel +except ImportError: + pass + +setup(cmdclass=cmdclass) diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 5ba4ee874b32..ac4c84dead9a 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -48,9 +48,6 @@ Good verification targets: # Install (shell installer — sets up uv, Python, the venv, and the launcher) curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -# Or via PyPI (ships the TUI bundle + shell launcher) -pip install hermes-agent # or: uv pip install hermes-agent - # Interactive chat (default surface; set display.interface: tui to launch the Ink TUI instead) hermes diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index f5300e9e6a9d..26f50c52c704 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -141,7 +141,7 @@ def install_deps(): "On environments without pip (e.g. Nix, or the Hermes Docker image's " "uv-managed venv), install the optional extra instead:" ) - print(" pip install 'hermes-agent[google]'") + print(" hermes setup") print(f"Or manually: {sys.executable} -m pip install {' '.join(REQUIRED_PACKAGES)}") return False diff --git a/tests/acp/test_registry_manifest.py b/tests/acp/test_registry_manifest.py deleted file mode 100644 index 633b4a8494c0..000000000000 --- a/tests/acp/test_registry_manifest.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Tests for ACP Registry metadata shipped with Hermes.""" - -from __future__ import annotations - -import json -import re -import tomllib -from pathlib import Path -import xml.etree.ElementTree as ET - -ROOT = Path(__file__).resolve().parents[2] -MANIFEST = ROOT / "acp_registry" / "agent.json" -ICON = ROOT / "acp_registry" / "icon.svg" -FORBIDDEN_MANIFEST_KEYS = {"schema_version", "display_name"} -ALLOWED_DISTRIBUTIONS = {"binary", "npx", "uvx"} - - -def _manifest() -> dict: - return json.loads(MANIFEST.read_text(encoding="utf-8")) - - -def _pyproject_version() -> str: - data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) - return data["project"]["version"] - - -def test_agent_json_matches_official_registry_required_fields(): - data = _manifest() - - assert FORBIDDEN_MANIFEST_KEYS.isdisjoint(data) - assert data["id"] == "hermes-agent" - assert re.fullmatch(r"[a-z][a-z0-9-]*", data["id"]) - assert data["name"] == "Hermes Agent" - assert data["description"] - assert data["repository"] == "https://github.com/NousResearch/hermes-agent" - assert data["website"].startswith("https://hermes-agent.nousresearch.com/") - assert data["authors"] == ["Nous Research"] - assert data["license"] == "MIT" - assert set(data["distribution"]) <= ALLOWED_DISTRIBUTIONS - - -def test_agent_json_uses_uvx_distribution_without_local_command_fields(): - data = _manifest() - - assert set(data["distribution"]) == {"uvx"} - uvx = data["distribution"]["uvx"] - # Schema allows {package, args, env}; we use {package, args}. - assert set(uvx) <= {"package", "args", "env"} - assert "package" in uvx - assert uvx["package"] == f"hermes-agent[acp]=={data['version']}" - assert uvx["args"] == ["hermes-acp"] - # Old command-shape fields must not leak back in. - assert "type" not in data["distribution"] - assert "command" not in data["distribution"] - - -def test_agent_json_version_matches_pyproject(): - assert _manifest()["version"] == _pyproject_version() - - -def test_agent_json_pins_uvx_package_to_pyproject_version(): - """The registry CI rejects ``@latest`` and floating pins; the manifest must - always reference the exact PyPI version listed in pyproject.toml.""" - assert _manifest()["distribution"]["uvx"]["package"] == ( - f"hermes-agent[acp]=={_pyproject_version()}" - ) - - -def test_icon_svg_is_16x16_current_color(): - root = ET.fromstring(ICON.read_text(encoding="utf-8")) - - assert root.attrib["viewBox"] == "0 0 16 16" - assert root.attrib["width"] == "16" - assert root.attrib["height"] == "16" - - -def test_icon_svg_has_no_hardcoded_colors_or_gradients(): - text = ICON.read_text(encoding="utf-8") - - assert "linearGradient" not in text - assert "radialGradient" not in text - assert "url(#" not in text - assert not re.search(r"#[0-9a-fA-F]{3,8}\b", text) - - root = ET.fromstring(text) - for element in root.iter(): - for attr in ("fill", "stroke"): - value = element.attrib.get(attr) - if value is not None: - assert value in {"currentColor", "none"} diff --git a/tests/agent/test_i18n.py b/tests/agent/test_i18n.py index 56a7568620d3..9c6e58bbb866 100644 --- a/tests/agent/test_i18n.py +++ b/tests/agent/test_i18n.py @@ -193,34 +193,6 @@ def test_locales_dir_env_override_ignored_when_missing(tmp_path, monkeypatch): assert result.name == "locales" -def test_locales_dir_falls_back_to_data_scheme(tmp_path, monkeypatch): - """When neither the env override nor a source-adjacent locales/ exists, - _locales_dir uses sysconfig's data scheme (the pip-wheel layout).""" - import sysconfig - - # No env override. - monkeypatch.delenv("HERMES_BUNDLED_LOCALES", raising=False) - - # Force the source-adjacent path to a location with no locales/ dir. - fake_pkg = tmp_path / "site-packages" / "agent" - fake_pkg.mkdir(parents=True) - monkeypatch.setattr(i18n, "__file__", str(fake_pkg / "i18n.py")) - - # Stand up a fake data scheme containing locales/. - data_root = tmp_path / "data-scheme" - (data_root / "locales").mkdir(parents=True) - real_get_path = sysconfig.get_path - - def fake_get_path(name, *args, **kwargs): - if name == "data": - return str(data_root) - return real_get_path(name, *args, **kwargs) - - monkeypatch.setattr(i18n.sysconfig, "get_path", fake_get_path) - - assert i18n._locales_dir() == data_root / "locales" - - def test_t_resolves_real_string_in_source_checkout(): """Sanity: in the test environment (a source checkout) t() must return a human string, never the bare key path. Guards against catalog-load diff --git a/tests/cli/test_update_command.py b/tests/cli/test_update_command.py index 392c11d1b265..6dd74ed0afdd 100644 --- a/tests/cli/test_update_command.py +++ b/tests/cli/test_update_command.py @@ -76,13 +76,13 @@ def test_managed_install_refuses_and_does_not_set_pending_relaunch(capsys): patch("hermes_cli.config.is_managed", return_value=True), patch( "hermes_cli.config.format_managed_message", - return_value="Use `brew upgrade hermes-agent` to update.", + return_value="Use `sudo nixos-rebuild switch` to update.", ), ): result = _call(self_) out = capsys.readouterr().out - assert "brew upgrade hermes-agent" in out + assert "sudo nixos-rebuild switch" in out assert self_._pending_relaunch is None assert not result diff --git a/tests/gateway/test_update_command.py b/tests/gateway/test_update_command.py index 5cc7f206e667..b25e79eba444 100644 --- a/tests/gateway/test_update_command.py +++ b/tests/gateway/test_update_command.py @@ -45,23 +45,6 @@ def _make_runner(): class TestHandleUpdateCommand: """Tests for GatewayRunner._handle_update_command.""" - @pytest.mark.asyncio - async def test_managed_install_returns_package_manager_guidance(self, monkeypatch): - runner = _make_runner() - event = _make_event() - monkeypatch.setenv("HERMES_MANAGED", "homebrew") - - # Guard: prevent any accidental fall-through from spawning a real - # `hermes update --gateway` against the CI checkout. The managed-install - # guard should return before Popen is ever reached, but mock it as - # belt-and-suspenders so a premature return doesn't corrupt the repo. - with patch("subprocess.Popen") as mock_popen: - result = await runner._handle_update_command(event) - - assert "managed by Homebrew" in result - assert "brew upgrade hermes-agent" in result - mock_popen.assert_not_called() # must return before reaching Popen - @pytest.mark.asyncio async def test_no_git_directory(self, tmp_path): """Returns an error when .git does not exist.""" diff --git a/tests/hermes_cli/test_banner_pip_update.py b/tests/hermes_cli/test_banner_pip_update.py deleted file mode 100644 index 205c97488a90..000000000000 --- a/tests/hermes_cli/test_banner_pip_update.py +++ /dev/null @@ -1,35 +0,0 @@ -from unittest.mock import patch - - -def testcheck_via_pypi_detects_update(): - """check_via_pypi returns 1 when PyPI has newer version.""" - from hermes_cli.banner import check_via_pypi - with patch("hermes_cli.banner.VERSION", "0.12.0"): - with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"): - result = check_via_pypi() - assert result == 1 - - -def testcheck_via_pypi_up_to_date(): - """check_via_pypi returns 0 when versions match.""" - from hermes_cli.banner import check_via_pypi - with patch("hermes_cli.banner.VERSION", "0.13.0"): - with patch("hermes_cli.banner._fetch_pypi_latest", return_value="0.13.0"): - result = check_via_pypi() - assert result == 0 - - -def testcheck_via_pypi_network_failure(): - """check_via_pypi returns None on network error.""" - from hermes_cli.banner import check_via_pypi - with patch("hermes_cli.banner._fetch_pypi_latest", return_value=None): - result = check_via_pypi() - assert result is None - - -def test_version_tuple_comparison(): - """Version comparison works with multi-segment versions.""" - from hermes_cli.banner import _version_tuple - assert _version_tuple("0.13.0") > _version_tuple("0.12.0") - assert _version_tuple("0.13.0") == _version_tuple("0.13.0") - assert _version_tuple("1.0.0") > _version_tuple("0.99.99") diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index b8b0244531a0..dc01dbae0efc 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -255,45 +255,6 @@ class TestCmdUpdateNpmLockfileCache: assert cache_roots == [shared_root, shared_root] -class TestCmdUpdatePip: - """Regression tests for pip-install update flows.""" - - @patch("shutil.which", return_value="/usr/bin/uv") - @patch("subprocess.run") - def test_update_pip_exports_virtualenv_from_sys_prefix( - self, mock_run, _mock_which, mock_args, monkeypatch - ): - from hermes_cli import main as hm - - mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") - monkeypatch.delenv("VIRTUAL_ENV", raising=False) - monkeypatch.setattr(hm.sys, "prefix", "/tmp/hermes-launcher-venv") - monkeypatch.setattr(hm.sys, "base_prefix", "/usr") - - hm._cmd_update_pip(mock_args) - - assert mock_run.call_count == 1 - assert mock_run.call_args.args[0] == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"] - assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/tmp/hermes-launcher-venv" - - @patch("shutil.which", return_value="/usr/bin/uv") - @patch("subprocess.run") - def test_update_pip_does_not_export_virtualenv_for_system_python( - self, mock_run, _mock_which, mock_args, monkeypatch - ): - from hermes_cli import main as hm - - mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") - monkeypatch.delenv("VIRTUAL_ENV", raising=False) - monkeypatch.setattr(hm.sys, "prefix", "/usr") - monkeypatch.setattr(hm.sys, "base_prefix", "/usr") - - hm._cmd_update_pip(mock_args) - - assert mock_run.call_count == 1 - assert "env" not in mock_run.call_args.kwargs - - class TestCmdUpdateTermuxUvBootstrap: """Regression tests for Termux-specific uv bootstrap behavior.""" @@ -989,21 +950,6 @@ class TestCmdUpdateCheckBranchFlag: rev_list_cmds = [c for c in commands if "rev-list" in c] assert any("upstream/main" in c for c in rev_list_cmds), rev_list_cmds - @patch("hermes_cli.config.detect_install_method", return_value="pip") - @patch("hermes_cli.banner.check_via_pypi", return_value=0) - @patch("subprocess.run") - def test_check_branch_warns_on_pypi_install( - self, mock_run, _mock_pypi, _mock_method, capsys - ): - """PyPI install + --branch= surfaces a warning instead of silent drop.""" - args = SimpleNamespace(check=True, branch="bb/gui") - - cmd_update(args) - - out = capsys.readouterr().out - assert "--branch is ignored for PyPI installs" in out - assert "bb/gui" in out - class TestCmdUpdateZipBranchRefusal: """``hermes update --branch=`` must refuse on the ZIP fallback path. diff --git a/tests/hermes_cli/test_cmd_update_docker.py b/tests/hermes_cli/test_cmd_update_docker.py index 827b41ec4583..fb26c689d676 100644 --- a/tests/hermes_cli/test_cmd_update_docker.py +++ b/tests/hermes_cli/test_cmd_update_docker.py @@ -143,19 +143,6 @@ def test_cmd_update_on_git_install_does_not_print_docker_message( assert "doesn't apply inside the Docker container" not in capsys.readouterr().out -@patch("hermes_cli.config.detect_install_method", return_value="pip") -@patch("hermes_cli.banner.check_via_pypi", return_value=0) -def test_cmd_update_check_on_pip_install_still_uses_pypi( - _mock_pypi, _mock_method, capsys -): - """PyPI installs route to PyPI check, not the Docker bail-out.""" - _cmd_update_check() - - out = capsys.readouterr().out - assert "Already up to date" in out - assert "doesn't apply inside the Docker container" not in out - - # ---------- format_docker_update_message — content lock ---------- diff --git a/tests/hermes_cli/test_console_engine.py b/tests/hermes_cli/test_console_engine.py index 0c8274bf0f15..40153ae8c7a7 100644 --- a/tests/hermes_cli/test_console_engine.py +++ b/tests/hermes_cli/test_console_engine.py @@ -350,7 +350,7 @@ def test_console_registry_covers_non_admin_cli_surface(): "oneshot hello", "model", "setup", - "postinstall", + "fallback add", "moa configure", "claw migrate", diff --git a/tests/hermes_cli/test_managed_installs.py b/tests/hermes_cli/test_managed_installs.py index 9dda45f4ffea..f942ffbc163a 100644 --- a/tests/hermes_cli/test_managed_installs.py +++ b/tests/hermes_cli/test_managed_installs.py @@ -1,31 +1,11 @@ from types import SimpleNamespace from unittest.mock import patch -from hermes_cli.config import ( - format_managed_message, - get_managed_system, - recommended_update_command, -) +from hermes_cli.config import recommended_update_command from hermes_cli.main import cmd_update from tools.skills_hub import OptionalSkillSource -def test_get_managed_system_homebrew(monkeypatch): - monkeypatch.setenv("HERMES_MANAGED", "homebrew") - - assert get_managed_system() == "Homebrew" - assert recommended_update_command() == "brew upgrade hermes-agent" - - -def test_format_managed_message_homebrew(monkeypatch): - monkeypatch.setenv("HERMES_MANAGED", "homebrew") - - message = format_managed_message("update Hermes Agent") - - assert "managed by Homebrew" in message - assert "brew upgrade hermes-agent" in message - - def test_recommended_update_command_defaults_to_hermes_update(monkeypatch): monkeypatch.delenv("HERMES_MANAGED", raising=False) @@ -39,18 +19,6 @@ def test_recommended_update_command_defaults_to_hermes_update(monkeypatch): assert recommended_update_command() == "hermes update" -def test_cmd_update_blocks_managed_homebrew(monkeypatch, capsys): - monkeypatch.setenv("HERMES_MANAGED", "homebrew") - - with patch("hermes_cli.main.subprocess.run") as mock_run: - cmd_update(SimpleNamespace()) - - assert not mock_run.called - captured = capsys.readouterr() - assert "managed by Homebrew" in captured.err - assert "brew upgrade hermes-agent" in captured.err - - def test_optional_skill_source_honors_env_override(monkeypatch, tmp_path): optional_dir = tmp_path / "optional-skills" optional_dir.mkdir() diff --git a/tests/hermes_cli/test_pip_install_detection.py b/tests/hermes_cli/test_pip_install_detection.py index c852b57052ae..e8c5a9fff939 100644 --- a/tests/hermes_cli/test_pip_install_detection.py +++ b/tests/hermes_cli/test_pip_install_detection.py @@ -1,13 +1,15 @@ from unittest.mock import patch +import pytest -def test_pip_install_detected_when_no_git_dir(tmp_path): - """When PROJECT_ROOT has no .git, detect as pip install.""" + +def test_unknown_install_detected_when_no_git_dir(tmp_path): + """When PROJECT_ROOT has no .git, detect as 'unknown' (not 'pip').""" with patch("hermes_cli.config.get_managed_system", return_value=None), \ patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): from hermes_cli.config import detect_install_method method = detect_install_method(project_root=tmp_path) - assert method == "pip" + assert method == "unknown" def test_git_install_detected_when_git_dir_exists(tmp_path): @@ -30,15 +32,6 @@ def test_managed_install_takes_precedence(tmp_path): assert method == "nixos" -def test_recommended_update_command_pip(): - """Pip installs recommend pip install --upgrade.""" - from hermes_cli.config import recommended_update_command_for_method - cmd = recommended_update_command_for_method("pip") - assert "pip install" in cmd or "uv pip install" in cmd - assert "--upgrade" in cmd - assert "hermes-agent" in cmd - - def test_stamp_file_takes_precedence(tmp_path): (tmp_path / ".git").mkdir() (tmp_path / ".install_method").write_text("docker\n") @@ -48,6 +41,30 @@ def test_stamp_file_takes_precedence(tmp_path): assert detect_install_method(project_root=tmp_path) == "docker" +@pytest.mark.parametrize("retired_method", ["pip", "homebrew"]) +def test_code_scoped_retired_stamp_falls_back_to_unknown(tmp_path, retired_method): + """Removed install methods must not survive in an upgraded code stamp.""" + (tmp_path / ".install_method").write_text(retired_method + "\n") + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): + from hermes_cli.config import detect_install_method + assert detect_install_method(project_root=tmp_path) == "unknown" + + +@pytest.mark.parametrize("retired_method", ["pip", "homebrew"]) +def test_home_scoped_retired_stamp_falls_back_to_unknown(tmp_path, retired_method): + """Removed install methods must not survive in an upgraded home stamp.""" + code = tmp_path / "code" + home = tmp_path / "home" + code.mkdir() + home.mkdir() + (home / ".install_method").write_text(retired_method + "\n") + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=home): + from hermes_cli.config import detect_install_method + assert detect_install_method(project_root=code) == "unknown" + + def test_code_scoped_stamp_wins_over_home_stamp(tmp_path): """The stamp next to the running code is authoritative over $HERMES_HOME. @@ -111,7 +128,7 @@ def test_home_non_docker_stamp_still_honored_for_backcompat(tmp_path): """Legacy non-'docker' home stamps (e.g. 'git') are still respected. Only the 'docker' value carries the cross-contamination risk, so a host - install that historically stamped 'git'/'pip' into $HERMES_HOME keeps + install that historically stamped 'git' into $HERMES_HOME keeps resolving from there when no code-scoped stamp exists yet. """ code = tmp_path / "code" @@ -134,8 +151,8 @@ def test_stamp_install_method_writes_code_scoped(tmp_path): home.mkdir() with patch("hermes_cli.config.get_hermes_home", return_value=home): from hermes_cli.config import stamp_install_method - stamp_install_method("pip", project_root=code) - assert (code / ".install_method").read_text().strip() == "pip" + stamp_install_method("git", project_root=code) + assert (code / ".install_method").read_text().strip() == "git" assert not (home / ".install_method").exists() @@ -158,13 +175,13 @@ def test_container_without_stamp_is_not_docker(tmp_path): assert detect_install_method(project_root=tmp_path) == "git" -def test_container_pip_install_without_stamp_is_pip(tmp_path): - """Container + no .git + no stamp -> pip, not docker (issue #34397).""" +def test_container_unknown_install_without_stamp_is_unknown(tmp_path): + """Container + no .git + no stamp -> unknown, not docker (issue #34397).""" with patch("hermes_cli.config.get_managed_system", return_value=None), \ patch("hermes_cli.config.get_hermes_home", return_value=tmp_path), \ patch("hermes_constants.is_container", return_value=True): from hermes_cli.config import detect_install_method - assert detect_install_method(project_root=tmp_path) == "pip" + assert detect_install_method(project_root=tmp_path) == "unknown" def test_recommended_update_command_docker(): @@ -172,77 +189,21 @@ def test_recommended_update_command_docker(): assert "docker pull" in recommended_update_command_for_method("docker") -def test_banner_warns_on_pip_install(tmp_path): - """The welcome banner surfaces a warning when the install method is pip.""" - import io - from rich.console import Console - from hermes_cli import banner +def test_nix_store_path_detected_as_nixos(tmp_path, monkeypatch): + """A code path under /nix/store/ (nix run / nix profile install) is detected + as 'nixos' even without HERMES_MANAGED or a .install_method stamp.""" + # detect_install_method checks whether the resolved root is a descendant + # of _NIX_STORE (Path("/nix/store")). We can't create files under the real + # /nix/store, so patch the constant to point at a temp dir and create the + # fake install path under it. + fake_nix_store = tmp_path / "fake-nix-store" + fake_nix_store.mkdir(parents=True) + fake_nix = fake_nix_store / "abc123-hermes-agent-0.19.0" + fake_nix.mkdir(parents=True) - hh = tmp_path / ".hermes" - hh.mkdir() - (hh / ".install_method").write_text("pip\n") + monkeypatch.setattr("hermes_cli.config._NIX_STORE", fake_nix_store) - with patch("hermes_cli.config.get_hermes_home", return_value=hh), \ - patch("hermes_constants.get_hermes_home", return_value=hh): - buf = io.StringIO() - # Wide console so the warning isn't wrapped across lines in the panel. - console = Console(file=buf, width=400, force_terminal=False, color_system=None) - banner.build_welcome_banner( - console, model="m", cwd="/tmp", - tools=[{"function": {"name": "terminal"}}], - enabled_toolsets=["terminal"], - ) - out = buf.getvalue() - - assert "officially" in out - assert "platform-support" in out - - -def test_banner_warns_on_homebrew_install(tmp_path): - """The welcome banner surfaces a warning when the install method is homebrew.""" - import io - from rich.console import Console - from hermes_cli import banner - - hh = tmp_path / ".hermes" - hh.mkdir() - (hh / ".install_method").write_text("homebrew\n") - - with patch("hermes_cli.config.get_hermes_home", return_value=hh), \ - patch("hermes_constants.get_hermes_home", return_value=hh): - buf = io.StringIO() - console = Console(file=buf, width=400, force_terminal=False, color_system=None) - banner.build_welcome_banner( - console, model="m", cwd="/tmp", - tools=[{"function": {"name": "terminal"}}], - enabled_toolsets=["terminal"], - ) - out = buf.getvalue() - - assert "officially" in out - assert "Homebrew" in out - assert "platform-support" in out - - -def test_banner_no_pip_warning_on_git_install(tmp_path): - """Git installs must not show the pip-install warning.""" - import io - from rich.console import Console - from hermes_cli import banner - - hh = tmp_path / ".hermes" - hh.mkdir() - (hh / ".install_method").write_text("git\n") - - with patch("hermes_cli.config.get_hermes_home", return_value=hh), \ - patch("hermes_constants.get_hermes_home", return_value=hh): - buf = io.StringIO() - console = Console(file=buf, width=400, force_terminal=False, color_system=None) - banner.build_welcome_banner( - console, model="m", cwd="/tmp", - tools=[{"function": {"name": "terminal"}}], - enabled_toolsets=["terminal"], - ) - out = buf.getvalue() - - assert "officially" not in out + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): + from hermes_cli.config import detect_install_method + assert detect_install_method(project_root=fake_nix) == "nixos" diff --git a/tests/hermes_cli/test_subcommands_batch.py b/tests/hermes_cli/test_subcommands_batch.py index d4ec37b6f3c7..712ca9f6b66c 100644 --- a/tests/hermes_cli/test_subcommands_batch.py +++ b/tests/hermes_cli/test_subcommands_batch.py @@ -27,7 +27,7 @@ from hermes_cli.subcommands.login import build_login_parser from hermes_cli.subcommands.logout import build_logout_parser from hermes_cli.subcommands.logs import build_logs_parser from hermes_cli.subcommands.model import build_model_parser -from hermes_cli.subcommands.postinstall import build_postinstall_parser + from hermes_cli.subcommands.prompt_size import build_prompt_size_parser from hermes_cli.subcommands.security import build_security_parser from hermes_cli.subcommands.setup import build_setup_parser @@ -51,7 +51,7 @@ def _h(name): SINGLE_HANDLER_CASES = [ ("model", build_model_parser, "cmd_model", ["model"]), ("setup", build_setup_parser, "cmd_setup", ["setup"]), - ("postinstall", build_postinstall_parser, "cmd_postinstall", ["postinstall"]), + ("whatsapp", build_whatsapp_parser, "cmd_whatsapp", ["whatsapp"]), ("slack", build_slack_parser, "cmd_slack", ["slack"]), ("login", build_login_parser, "cmd_login", ["login"]), diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index 84b9e3a6c991..48a8da3045cf 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -60,13 +60,12 @@ def test_check_for_updates_invalidates_on_version_change(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.delenv("HERMES_REVISION", raising=False) - with patch("hermes_cli.banner.subprocess.run") as mock_run, \ - patch("hermes_cli.banner.check_via_pypi", return_value=0) as mock_pypi: + with patch("hermes_cli.banner.subprocess.run") as mock_run: result = banner.check_for_updates() - # Stale-version cache rejected -> fresh check ran -> up-to-date result. - assert result == 0 - mock_pypi.assert_called_once() + # Stale-version cache rejected -> fresh check ran. No git checkout and no + # embedded rev means we can't determine update status, so result is None. + assert result is None mock_run.assert_not_called() # Cache rewritten with the current installed version. @@ -223,7 +222,7 @@ def test_check_via_local_git_full_clone_keeps_exact_count(tmp_path): def test_check_for_updates_no_git_dir(tmp_path, monkeypatch): - """Falls back to PyPI check when .git directory doesn't exist anywhere.""" + """Returns None when .git directory doesn't exist anywhere (no source tree).""" import hermes_cli.banner as banner # Create a fake banner.py so the fallback path also has no .git @@ -234,9 +233,8 @@ def test_check_for_updates_no_git_dir(tmp_path, monkeypatch): monkeypatch.setattr(banner, "__file__", str(fake_banner)) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) with patch("hermes_cli.banner.subprocess.run") as mock_run: - with patch("hermes_cli.banner.check_via_pypi", return_value=0): - result = banner.check_for_updates() - assert result == 0 + result = banner.check_for_updates() + assert result is None mock_run.assert_not_called() @@ -262,12 +260,9 @@ def test_check_for_updates_docker_returns_none(tmp_path, monkeypatch): Regression: the published image excludes .git (.dockerignore) and sets no HERMES_REVISION (nix-only), so without a docker guard check_for_updates() - falls through to check_via_pypi(), whose version-mismatch flag (1) gets - rendered by both the Rich banner and the Ink TUI badge as a phantom - "1 commit behind" — despite there being no git repo or commit math in the - container, and `hermes update` correctly refusing to run there. The guard + would fall through and try to probe a non-existent git checkout. The guard must return None (so the > 0 render guards stay false) AND not reach the - git/pypi probes or write a cache entry. + git probe or write a cache entry. """ import hermes_cli.banner as banner @@ -275,44 +270,16 @@ def test_check_for_updates_docker_returns_none(tmp_path, monkeypatch): cache_file = tmp_path / ".update_check" with patch("hermes_cli.config.detect_install_method", return_value="docker"), \ - patch("hermes_cli.banner.subprocess.run") as mock_run, \ - patch("hermes_cli.banner.check_via_pypi") as mock_pypi: + patch("hermes_cli.banner.subprocess.run") as mock_run: result = banner.check_for_updates() assert result is None - # Neither the git probe nor the PyPI probe should have run. + # The git probe should not have run. mock_run.assert_not_called() - mock_pypi.assert_not_called() # And no phantom "behind" count should be cached for the next 6h. assert not cache_file.exists() -def test_check_for_updates_non_docker_still_checks(tmp_path, monkeypatch): - """The docker guard must NOT over-broaden: a pip install still version-checks. - - Invariant guarding against the guard firing for non-docker methods — pip - installs legitimately reach check_via_pypi() and surface a real update. - """ - import hermes_cli.banner as banner - - # No local git checkout -> the PyPI (pip-install) path is exercised. - fake_banner = tmp_path / "hermes_cli" / "banner.py" - fake_banner.parent.mkdir(parents=True, exist_ok=True) - fake_banner.touch() - monkeypatch.setattr(banner, "__file__", str(fake_banner)) - monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - monkeypatch.delenv("HERMES_REVISION", raising=False) - - with patch("hermes_cli.config.detect_install_method", return_value="pip"), \ - patch("hermes_cli.banner.subprocess.run") as mock_run, \ - patch("hermes_cli.banner.check_via_pypi", return_value=1) as mock_pypi: - result = banner.check_for_updates() - - assert result == 1 - mock_pypi.assert_called_once() - mock_run.assert_not_called() - - def test_prefetch_non_blocking(): """prefetch_update_check() should return immediately without blocking.""" import hermes_cli.banner as banner diff --git a/tests/hermes_cli/test_uv_tool_update.py b/tests/hermes_cli/test_uv_tool_update.py deleted file mode 100644 index b8f294de373e..000000000000 --- a/tests/hermes_cli/test_uv_tool_update.py +++ /dev/null @@ -1,342 +0,0 @@ -"""Tests for uv-tool install detection in the update path (issue #29700). - -``uv tool install hermes-agent`` lives outside any venv, so the previous -``uv pip install --upgrade`` update path failed with ``No virtual -environment found``. ``is_uv_tool_install`` should detect this layout and -both the user-facing recommended command and the actual -``_cmd_update_pip`` subprocess invocation should switch to -``uv tool upgrade hermes-agent``. - -Detection is restricted to properties of the running interpreter -(``sys.prefix`` / ``sys.executable``) so a pip/venv install on a machine -that also has ``uv tool install hermes-agent`` does not get misclassified. -""" -from __future__ import annotations - -import subprocess -from types import SimpleNamespace -from unittest.mock import patch - -import pytest - - -# --------------------------------------------------------------------------- -# Managed-uv compatibility for tests that patch shutil.which -# --------------------------------------------------------------------------- -# The production code now uses ``ensure_uv()`` / ``update_managed_uv()`` -# instead of ``shutil.which("uv")``. Many tests in this file patch -# ``shutil.which`` to control whether uv is "available" — these autouse -# fixtures make the managed_uv functions delegate to the patched -# ``shutil.which`` so the existing test setup keeps working without -# per-test changes. -@pytest.fixture(autouse=True) -def _patch_managed_uv(request): - """Make managed_uv helpers follow shutil.which mocking in tests.""" - import shutil - - # resolve_uv delegates to shutil.which("uv") so that test patches - # on shutil.which flow through naturally. - def _fake_resolve_uv(): - return shutil.which("uv") - - def _fake_ensure_uv(): - return shutil.which("uv") - - def _fake_update_managed_uv(): - return None # never actually self-update in tests - - with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \ - patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \ - patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv): - yield - - -# --------------------------------------------------------------------------- -# is_uv_tool_install -# --------------------------------------------------------------------------- - - -class TestIsUvToolInstall: - def test_returns_true_when_sys_prefix_matches_uv_tool_layout(self): - from hermes_cli import config - - with patch.object(config.sys, "prefix", "/home/user/.local/share/uv/tools/hermes-agent"): - assert config.is_uv_tool_install() is True - - def test_returns_true_when_sys_executable_matches_uv_tool_layout(self): - """Some uv-tool layouts surface the marker on ``sys.executable`` (bin/python).""" - from hermes_cli import config - - with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ - patch.object( - config.sys, - "executable", - "/home/user/.local/share/uv/tools/hermes-agent/bin/python", - ): - assert config.is_uv_tool_install() is True - - def test_returns_false_when_neither_prefix_nor_executable_matches(self): - from hermes_cli import config - - with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ - patch.object(config.sys, "executable", "/usr/bin/python3"): - assert config.is_uv_tool_install() is False - - def test_does_not_consult_uv_tool_list(self): - """Detection must NOT shell out: ``uv tool list`` would false-positive - when the active install is pip/venv but the machine also has - ``uv tool install hermes-agent`` somewhere on disk. Copilot review on - PR #29703 flagged this; the fix is to never call ``uv tool list`` - from the detection path.""" - from hermes_cli import config - - with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ - patch.object(config.sys, "executable", "/usr/bin/python3"), \ - patch("subprocess.run") as mock_run: - assert config.is_uv_tool_install() is False - mock_run.assert_not_called() - - def test_case_insensitive_match(self): - """Match must be case-insensitive — Windows paths preserve case - (e.g. ``...AppData\\Local\\UV\\Tools\\hermes-agent``) and a case-sensitive - check would miss them. We exercise the lower-cased compare path here - without monkey-patching ``os.sep``, which would break the whole suite.""" - from hermes_cli import config - - with patch.object( - config.sys, "prefix", "/HOME/USER/.local/share/UV/Tools/hermes-agent" - ): - assert config.is_uv_tool_install() is True - - def test_handles_empty_executable(self): - from hermes_cli import config - - with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ - patch.object(config.sys, "executable", ""): - assert config.is_uv_tool_install() is False - - -# --------------------------------------------------------------------------- -# recommended_update_command_for_method -# --------------------------------------------------------------------------- - - -class TestRecommendedUpdateCommandForUvTool: - def test_uv_tool_install_recommends_uv_tool_upgrade(self): - from hermes_cli import config - - with patch("shutil.which", return_value="/usr/local/bin/uv"), \ - patch.object(config, "is_uv_tool_install", return_value=True): - cmd = config.recommended_update_command_for_method("pip") - assert cmd == "uv tool upgrade hermes-agent" - - def test_uv_tool_install_recommends_uv_tool_upgrade_even_without_uv_on_path(self): - """Recommendation reflects the *install method*, not whether ``uv`` is - currently on PATH — the user needs to know the right command to run.""" - from hermes_cli import config - - with patch("shutil.which", return_value=None), \ - patch.object(config, "is_uv_tool_install", return_value=True): - cmd = config.recommended_update_command_for_method("pip") - assert cmd == "uv tool upgrade hermes-agent" - - def test_uv_pip_install_keeps_legacy_recommendation(self): - """Existing behavior: uv is on PATH but Hermes is a regular pip install.""" - from hermes_cli import config - - with patch("shutil.which", return_value="/usr/local/bin/uv"), \ - patch.object(config, "is_uv_tool_install", return_value=False): - cmd = config.recommended_update_command_for_method("pip") - assert cmd == "uv pip install --upgrade hermes-agent" - - def test_no_uv_falls_back_to_plain_pip(self): - from hermes_cli import config - - with patch("shutil.which", return_value=None), \ - patch.object(config, "is_uv_tool_install", return_value=False): - cmd = config.recommended_update_command_for_method("pip") - assert cmd == "pip install --upgrade hermes-agent" - - def test_recommendation_does_not_spawn_subprocess(self): - """Computing the recommendation string must be cheap — no ``uv tool list`` - spawn. Copilot review on PR #29703 flagged the prior subprocess hop - as adding overhead and a multi-second timeout window for what is - purely a display string.""" - from hermes_cli import config - - with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ - patch.object(config.sys, "executable", "/usr/bin/python3"), \ - patch("shutil.which", return_value="/usr/local/bin/uv"), \ - patch("subprocess.run") as mock_run: - cmd = config.recommended_update_command_for_method("pip") - mock_run.assert_not_called() - assert cmd == "uv pip install --upgrade hermes-agent" - - -# --------------------------------------------------------------------------- -# _cmd_update_pip subprocess command -# --------------------------------------------------------------------------- - - -class TestCmdUpdatePipUsesUvTool: - @patch("subprocess.run") - def test_runs_uv_tool_upgrade_when_uv_tool_install(self, mock_run): - """The actual subprocess invocation must switch to ``uv tool upgrade``.""" - from hermes_cli.main import _cmd_update_pip - - mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="") - with patch("shutil.which", return_value="/usr/local/bin/uv"), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=True): - _cmd_update_pip(SimpleNamespace()) - - assert mock_run.call_args[0][0] == ["/usr/local/bin/uv", "tool", "upgrade", "hermes-agent"] - - @patch("subprocess.run") - def test_runs_uv_pip_install_when_not_uv_tool(self, mock_run): - """Existing behavior preserved when uv is present but Hermes isn't a tool install.""" - from hermes_cli.main import _cmd_update_pip - - mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="") - with patch("shutil.which", return_value="/usr/local/bin/uv"), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=False): - _cmd_update_pip(SimpleNamespace()) - - assert mock_run.call_args[0][0] == [ - "/usr/local/bin/uv", - "pip", - "install", - "--upgrade", - "hermes-agent", - ] - - @patch("subprocess.run") - def test_falls_back_to_pip_when_no_uv(self, mock_run): - from hermes_cli.main import _cmd_update_pip - - mock_run.return_value = subprocess.CompletedProcess(["pip"], 0, stdout="", stderr="") - with patch("shutil.which", return_value=None), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=False): - _cmd_update_pip(SimpleNamespace()) - - cmd = mock_run.call_args[0][0] - assert cmd[1:] == ["-m", "pip", "install", "--upgrade", "hermes-agent"] - - @patch("subprocess.run") - def test_exits_nonzero_on_subprocess_failure(self, mock_run): - from hermes_cli.main import _cmd_update_pip - - mock_run.return_value = subprocess.CompletedProcess(["uv"], 1, stdout="", stderr="") - with patch("shutil.which", return_value="/usr/local/bin/uv"), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=True): - with pytest.raises(SystemExit) as exc_info: - _cmd_update_pip(SimpleNamespace()) - assert exc_info.value.code == 1 - - @patch("subprocess.run") - def test_uv_tool_install_without_uv_on_path_exits_with_hint(self, mock_run): - """If the running interpreter looks like a uv-tool install but ``uv`` is - somehow missing from PATH, surface a clear hint instead of silently - falling back to ``python -m pip``, which would either fail (no venv) - or upgrade the wrong copy.""" - from hermes_cli.main import _cmd_update_pip - - with patch("shutil.which", return_value=None), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=True): - with pytest.raises(SystemExit) as exc_info: - _cmd_update_pip(SimpleNamespace()) - assert exc_info.value.code == 1 - mock_run.assert_not_called() - - -# --------------------------------------------------------------------------- -# pipx-managed installs, --system fallback, and VIRTUAL_ENV overlay -# (issue #29700 / #35031 family — consolidated update-path handling) -# --------------------------------------------------------------------------- - - -class TestCmdUpdatePipInstallLayouts: - """The uv pip path must adapt to where the running interpreter lives: - - - inside a venv (launcher shim) -> export VIRTUAL_ENV, no ``--system`` - - bare pip outside any venv -> add ``--system``, no overlay - - pipx-managed -> ``pipx upgrade`` - """ - - @patch("subprocess.run") - def test_pipx_managed_uses_pipx_upgrade(self, mock_run, monkeypatch): - from hermes_cli import main as hm - - mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") - monkeypatch.setattr(hm.sys, "prefix", "/home/u/.local/pipx/venvs/hermes-agent") - monkeypatch.setattr(hm.sys, "base_prefix", "/usr") - - def _which(name): - return {"uv": "/usr/bin/uv", "pipx": "/usr/bin/pipx"}.get(name) - - with patch("shutil.which", side_effect=_which), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=False): - hm._cmd_update_pip(SimpleNamespace()) - - assert mock_run.call_args[0][0] == ["/usr/bin/pipx", "upgrade", "hermes-agent"] - # pipx upgrade ignores VIRTUAL_ENV; we must not set it. - assert "env" not in mock_run.call_args.kwargs - - @patch("subprocess.run") - def test_pipx_layout_without_pipx_binary_treated_as_venv( - self, mock_run, monkeypatch - ): - from hermes_cli import main as hm - - mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") - monkeypatch.setattr(hm.sys, "prefix", "/home/u/.local/pipx/venvs/hermes-agent") - monkeypatch.setattr(hm.sys, "base_prefix", "/usr") - - # pipx layout detected via prefix, but pipx binary missing on PATH. - def _which(name): - return "/usr/bin/uv" if name == "uv" else None - - with patch("shutil.which", side_effect=_which), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=False): - hm._cmd_update_pip(SimpleNamespace()) - - # prefix != base_prefix, so this is treated as a venv -> overlay, no --system. - assert mock_run.call_args[0][0] == [ - "/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent", - ] - assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"].endswith("hermes-agent") - - @patch("subprocess.run") - def test_bare_pip_outside_venv_adds_system(self, mock_run, monkeypatch): - from hermes_cli import main as hm - - mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") - # No venv: prefix == base_prefix. - monkeypatch.setattr(hm.sys, "prefix", "/usr") - monkeypatch.setattr(hm.sys, "base_prefix", "/usr") - - with patch("shutil.which", return_value="/usr/bin/uv"), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=False): - hm._cmd_update_pip(SimpleNamespace()) - - assert mock_run.call_args[0][0] == [ - "/usr/bin/uv", "pip", "install", "--system", "--upgrade", "hermes-agent", - ] - assert "env" not in mock_run.call_args.kwargs - - @patch("subprocess.run") - def test_venv_exports_virtualenv_and_omits_system(self, mock_run, monkeypatch): - from hermes_cli import main as hm - - mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") - monkeypatch.delenv("VIRTUAL_ENV", raising=False) - monkeypatch.setattr(hm.sys, "prefix", "/home/u/.hermes/hermes-agent/venv") - monkeypatch.setattr(hm.sys, "base_prefix", "/usr") - - with patch("shutil.which", return_value="/usr/bin/uv"), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=False): - hm._cmd_update_pip(SimpleNamespace()) - - cmd = mock_run.call_args[0][0] - assert "--system" not in cmd - assert cmd == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"] - assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/home/u/.hermes/hermes-agent/venv" diff --git a/tests/scripts/test_release_acp_registry.py b/tests/scripts/test_release_acp_registry.py deleted file mode 100644 index 4d20cda25bde..000000000000 --- a/tests/scripts/test_release_acp_registry.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Tests for the ACP Registry version-lockstep bump in scripts/release.py. - -The official ACP Registry manifest must match ``pyproject.toml`` exactly — -``tests/acp/test_registry_manifest.py`` enforces this at lint time, and the -upstream registry CI rejects ``@latest`` / floating pins. The release script -is the single place that bumps the manifest in lockstep with pyproject; if -that bump ever silently breaks, weekly releases fail the manifest test -until someone hand-edits the JSON. -""" - -from __future__ import annotations - -import importlib.util -import json -from pathlib import Path - - -def _load_release_module(monkeypatch, tmp_root: Path): - """Import scripts/release.py with REPO_ROOT pinned to a temp tree.""" - spec = importlib.util.spec_from_file_location( - "_release_under_test", - Path(__file__).resolve().parents[2] / "scripts" / "release.py", - ) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - monkeypatch.setattr(module, "REPO_ROOT", tmp_root) - monkeypatch.setattr( - module, "ACP_REGISTRY_MANIFEST", tmp_root / "acp_registry" / "agent.json" - ) - return module - - -def _write_manifest(root: Path, version: str) -> None: - manifest_dir = root / "acp_registry" - manifest_dir.mkdir(parents=True) - (manifest_dir / "agent.json").write_text( - json.dumps( - { - "id": "hermes-agent", - "name": "Hermes Agent", - "version": version, - "description": "test", - "distribution": { - "uvx": { - "package": f"hermes-agent[acp]=={version}", - "args": ["hermes-acp"], - } - }, - }, - indent=2, - ) - + "\n", - encoding="utf-8", - ) - - -def test_update_acp_registry_versions_bumps_manifest_and_pin(monkeypatch, tmp_path): - _write_manifest(tmp_path, "0.13.0") - module = _load_release_module(monkeypatch, tmp_path) - - module._update_acp_registry_versions("0.14.0") - - manifest = json.loads( - (tmp_path / "acp_registry" / "agent.json").read_text(encoding="utf-8") - ) - assert manifest["version"] == "0.14.0" - assert manifest["distribution"]["uvx"]["package"] == "hermes-agent[acp]==0.14.0" - # args stay untouched so we don't accidentally rewrite them. - assert manifest["distribution"]["uvx"]["args"] == ["hermes-acp"] - - -def test_update_acp_registry_versions_is_silent_when_manifest_missing( - monkeypatch, tmp_path -): - """Older release branches predate the ACP Registry asset — must no-op.""" - module = _load_release_module(monkeypatch, tmp_path) - - # No fixture written; function should not raise. - module._update_acp_registry_versions("0.14.0") - - -def test_update_version_files_bumps_manifest_alongside_pyproject( - monkeypatch, tmp_path -): - """End-to-end: update_version_files() is the function release.py actually - calls, so it must drive the manifest bump too.""" - _write_manifest(tmp_path, "0.13.0") - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "hermes-agent"\nversion = "0.13.0"\n', encoding="utf-8" - ) - version_dir = tmp_path / "hermes_cli" - version_dir.mkdir() - (version_dir / "__init__.py").write_text( - '__version__ = "0.13.0"\n__release_date__ = "2026-05-14"\n', - encoding="utf-8", - ) - - module = _load_release_module(monkeypatch, tmp_path) - monkeypatch.setattr(module, "VERSION_FILE", version_dir / "__init__.py") - monkeypatch.setattr(module, "PYPROJECT_FILE", tmp_path / "pyproject.toml") - - module.update_version_files("0.14.0", "2026-05-21") - - pyproject_text = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") - assert 'version = "0.14.0"' in pyproject_text - - manifest = json.loads( - (tmp_path / "acp_registry" / "agent.json").read_text(encoding="utf-8") - ) - assert manifest["version"] == "0.14.0" - assert manifest["distribution"]["uvx"]["package"] == "hermes-agent[acp]==0.14.0" diff --git a/tests/test_packaging_build_guard.py b/tests/test_packaging_build_guard.py new file mode 100644 index 000000000000..0234e3dd340c --- /dev/null +++ b/tests/test_packaging_build_guard.py @@ -0,0 +1,46 @@ +"""Behavioral regression coverage for the wheel/sdist distribution guard.""" + +import os +import subprocess +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _build_sdist(tmp_path, *, nix_build: bool) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + # nix develop exports this too, so it must not grant permission to build + # a distributable artifact. + env["NIX_BUILD_TOP"] = "/build/devshell" + if nix_build: + env["HERMES_NIX_BUILD"] = "1" + else: + env.pop("HERMES_NIX_BUILD", None) + return subprocess.run( + [ + sys.executable, + "-c", + "from setuptools.build_meta import build_sdist; build_sdist(r'{}')".format(tmp_path), + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + +def test_sdist_rejects_nix_development_shell_environment(tmp_path): + result = _build_sdist(tmp_path, nix_build=False) + + assert result.returncode != 0 + assert "Building wheels or sdists for hermes-agent is not supported" in result.stderr + + +def test_sdist_allows_explicit_nix_package_build_marker(tmp_path): + result = _build_sdist(tmp_path, nix_build=True) + + assert result.returncode == 0, result.stderr + assert list(tmp_path.glob("hermes_agent-*.tar.gz")) \ No newline at end of file diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index f1ccee4773b0..c4a707065082 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -5,13 +5,6 @@ from pathlib import Path import pytest -# setuptools is declared in the [dev] extra and is the build backend, but -# guard the import so a runner without it skips these packaging checks -# instead of erroring out collection for the whole shard (it used to be -# picked up ambiently from the CI image; newer ubuntu-latest images don't -# ship it in the test venv). -find_packages = pytest.importorskip("setuptools", exc_type=ImportError).find_packages - REPO_ROOT = Path(__file__).resolve().parents[1] @@ -32,53 +25,6 @@ def _distribution_name(requirement: str) -> str: return spec.strip().lower() -def _packages_find_include(): - data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - return data["tool"]["setuptools"]["packages"]["find"]["include"] - - -def test_every_on_disk_subpackage_is_covered_by_packages_find(): - """Regression test for #34701 (and the bug class behind #34034 / #28149). - - ``[tool.setuptools.packages.find]`` ``include`` is hand-maintained. Every - top-level package is listed twice — bare (``hermes_cli``) for the package - itself and ``hermes_cli.*`` for its subpackages — EXCEPT when someone - forgets the wildcard. v0.15.x listed ``hermes_cli`` without ``hermes_cli.*``, - so the wheel shipped ``hermes_cli/*.py`` but dropped the ``dashboard_auth`` - and ``proxy`` subpackages. The dashboard then died on every install with - ``ModuleNotFoundError: No module named 'hermes_cli.dashboard_auth'``. - - This drives setuptools' own discovery against the live tree: every package - that exists on disk and would be found by a permissive ``.*`` scan - must also be found by the actual ``include`` list. A subpackage added under - any listed package without the matching wildcard fails here instead of in a - user's container. - """ - include = _packages_find_include() - - # What the real include list actually selects. - selected = set(find_packages(where=str(REPO_ROOT), include=include)) - - # Top-level packages we ship (bare names in the include list, no wildcard). - top_level = sorted({name for name in include if "." not in name}) - - # For each shipped top-level package, every on-disk subpackage must be - # covered by the include list. - expected = set( - find_packages( - where=str(REPO_ROOT), - include=[pattern for name in top_level for pattern in (name, f"{name}.*")], - ) - ) - - missing = sorted(expected - selected) - assert not missing, ( - "These packages exist on disk but are dropped from the wheel because " - "[tool.setuptools.packages.find] include is missing a wildcard. Add the " - f"matching '.*' entry in pyproject.toml: {missing}" - ) - - def test_packaging_declared_as_core_dependency(): """Regression for #40503. @@ -110,52 +56,6 @@ def test_faster_whisper_is_not_a_base_dependency(): assert any(dep.startswith("faster-whisper") for dep in voice_extra) -def test_manifest_includes_bundled_skills(): - manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8") - - assert "graft skills" in manifest - assert "graft optional-skills" in manifest - - -def test_bundled_plugin_manifests_ship_in_both_wheel_and_sdist(): - """Regression test for #34034 / #28149. - - Plugin discovery (hermes_cli/plugins.py) registers each bundled plugin by - reading its ``plugin.yaml`` / ``plugin.yml`` manifest. Those manifests are - data files, not Python modules, so they only reach installed packages when - declared explicitly: - - - wheel -> ``[tool.setuptools.package-data]`` ``plugins`` glob - - sdist -> ``MANIFEST.in`` (Homebrew and other downstream packagers build - from the sdist) - - v0.15.0 declared neither, so the wheel shipped every adapter's Python code - but none of its manifests, and *every* gateway platform failed with - "No adapter available for ". Both channels must cover manifests. - """ - # There must actually be manifests on disk for the globs to match. - on_disk = list((REPO_ROOT / "plugins").rglob("plugin.yaml")) + list( - (REPO_ROOT / "plugins").rglob("plugin.yml") - ) - assert on_disk, "expected bundled plugin manifests under plugins/" - - # Wheel channel: package-data must declare a glob that matches plugin - # manifests anywhere under the plugins package. - data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - plugins_pkg_data = data["tool"]["setuptools"]["package-data"].get("plugins", []) - assert any( - g.endswith("plugin.yaml") or g.endswith("plugin.yml") - for g in plugins_pkg_data - ), "pyproject package-data 'plugins' must ship plugin.yaml/plugin.yml (wheel)" - - # Sdist channel: MANIFEST.in must recursively include the manifests so - # downstream packagers building from the sdist also get them. - manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8") - assert "recursive-include plugins" in manifest and "plugin.yaml" in manifest, ( - "MANIFEST.in must recursive-include plugins plugin.yaml/plugin.yml (sdist)" - ) - - # Minimum non-vulnerable Starlette: CVE-2026-48710 ("BadHost") was fixed in # 1.0.1. Anything below that lets a malformed Host header desync # ``request.url.path`` from the dispatched ASGI path, bypassing path-based @@ -241,32 +141,6 @@ def test_locked_starlette_is_not_vulnerable_to_cve_2026_48710(): ) -def test_locale_catalogs_ship_in_both_wheel_and_sdist(): - """Regression test for #27632 / #35374 / #23943. - - locales/ is a bare data directory (no __init__.py), so it is invisible to - packages.find and to package-data (which attaches to a package). It must be - declared as setuptools data-files (wheel) AND grafted in MANIFEST.in - (sdist). Without both, sealed installs drop the catalogs and gateway/CLI - commands surface raw i18n keys like `gateway.reset.header_default`. - """ - data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - data_files = data["tool"]["setuptools"].get("data-files", {}) - assert data_files.get("locales") == ["locales/*.yaml"], ( - "pyproject [tool.setuptools.data-files] must declare " - 'locales = ["locales/*.yaml"] so the wheel ships i18n catalogs' - ) - - manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8") - assert "graft locales" in manifest, ( - "MANIFEST.in must `graft locales` so the sdist ships i18n catalogs" - ) - - # Every on-disk catalog has the .yaml extension the globs above match. - on_disk = list((REPO_ROOT / "locales").glob("*.yaml")) - assert on_disk, "expected locales/*.yaml catalogs on disk" - - # --------------------------------------------------------------------------- # Dependency-pin consistency: pyproject extras <-> tools/lazy_deps.py # diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 8c0836e9059e..1ee9777e99f7 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -11,13 +11,6 @@ def _load_optional_dependencies(): return project["optional-dependencies"] -def _load_package_data(): - pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" - with pyproject_path.open("rb") as handle: - tool = tomllib.load(handle)["tool"] - return tool["setuptools"]["package-data"] - - def test_matrix_extra_not_in_all(): """The [matrix] extra pulls `mautrix[encryption]` -> `python-olm`, which has Linux-only wheels and no native build path on Windows or @@ -31,7 +24,7 @@ def test_matrix_extra_not_in_all(): """ optional_dependencies = _load_optional_dependencies() - assert "matrix" in optional_dependencies, "[matrix] extra must still exist for explicit `pip install hermes-agent[matrix]`" + assert "matrix" in optional_dependencies, "[matrix] extra must still exist for `uv sync --extra matrix`" # Must NOT appear in [all] in any form — neither unconditional nor # platform-gated. Lazy-install handles it. matrix_in_all = [ @@ -230,25 +223,3 @@ def test_nemo_relay_extra_uses_supported_official_distribution_range(): spec == "hermes-agent[nemo-relay]" for spec in optional_dependencies["all"] ) - - -def test_dashboard_plugin_manifests_and_assets_are_packaged(): - """Bundled dashboard plugins need their manifests and built assets in - wheel installs so /api/dashboard/plugins can discover them outside a - source checkout.""" - package_data = _load_package_data() - plugin_data = package_data["plugins"] - - assert "*/dashboard/manifest.json" in plugin_data - assert "*/dashboard/dist/*" in plugin_data - assert "*/dashboard/dist/**/*" in plugin_data - - -def test_nested_bundled_plugin_metadata_is_packaged(): - """Nested opt-in plugins need manifests and READMEs in wheel installs.""" - package_data = _load_package_data() - plugin_data = package_data["plugins"] - - assert "**/plugin.yaml" in plugin_data - assert "**/plugin.yml" in plugin_data - assert "**/README.md" in plugin_data diff --git a/tests/test_setup_temporary_outputs.py b/tests/test_setup_temporary_outputs.py deleted file mode 100644 index 042f0e233af8..000000000000 --- a/tests/test_setup_temporary_outputs.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Test that setup.py uses temporary output directories when the source -tree is read-only (as it is inside the Docker WebUI install surface). -""" -from __future__ import annotations - -from pathlib import Path -import runpy - -from setuptools import Distribution -import setuptools - - -REPO_ROOT = Path(__file__).resolve().parent.parent - - -def _is_under(path: str, root: Path) -> bool: - try: - Path(path).resolve().relative_to(root.resolve()) - except ValueError: - return False - return True - - -def test_setup_uses_temporary_outputs_when_source_tree_is_read_only( - monkeypatch, -) -> None: - """WebUI installs from read-only /opt/hermes must not write build metadata.""" - captured: dict[str, object] = {} - - def capture_setup(**kwargs: object) -> None: - captured.update(kwargs) - - monkeypatch.setattr(setuptools, "setup", capture_setup) - namespace = runpy.run_path(str(REPO_ROOT / "setup.py")) - - cmdclass = captured["cmdclass"] - monkeypatch.setitem( - cmdclass["build"].finalize_options.__globals__, - "_source_tree_is_writable", - lambda: False, - ) - monkeypatch.setitem( - cmdclass["egg_info"].finalize_options.__globals__, - "_source_tree_is_writable", - lambda: False, - ) - - build_cmd = cmdclass["build"](Distribution()) - build_cmd.initialize_options() - build_cmd.finalize_options() - assert not _is_under(build_cmd.build_base, REPO_ROOT) - assert Path(build_cmd.build_base).name.startswith("hermes-agent-build") - - source_relative_build = cmdclass["build"](Distribution()) - source_relative_build.initialize_options() - source_relative_build.build_base = "nested/build" - source_relative_build.finalize_options() - assert not _is_under(source_relative_build.build_base, REPO_ROOT) - assert Path(source_relative_build.build_base).name.startswith("hermes-agent-build") - - egg_info_cmd = cmdclass["egg_info"](Distribution()) - egg_info_cmd.initialize_options() - egg_info_cmd.finalize_options() - assert egg_info_cmd.egg_base is not None - assert not _is_under(egg_info_cmd.egg_base, REPO_ROOT) - assert Path(egg_info_cmd.egg_base).name.startswith("hermes-agent-egg-info") - - source_relative_egg_info = cmdclass["egg_info"](Distribution()) - source_relative_egg_info.initialize_options() - source_relative_egg_info.egg_base = "." - source_relative_egg_info.finalize_options() - assert source_relative_egg_info.egg_base is not None - assert not _is_under(source_relative_egg_info.egg_base, REPO_ROOT) - assert Path(source_relative_egg_info.egg_base).name.startswith( - "hermes-agent-egg-info" - ) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index da815f9979c1..86615fee8015 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -6482,25 +6482,6 @@ def test_session_info_includes_session_title(monkeypatch): assert info["title"] == "Dashboard title" -def test_session_info_includes_install_warning_for_pip(monkeypatch): - """pip installs surface install_warning; git installs don't (issue: pip/brew deprecation).""" - monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda: "pip") - - info = server._session_info(types.SimpleNamespace(tools=[], model="", provider="")) - - assert "install_warning" in info - assert "pip" in info["install_warning"] - assert "platform-support" in info["install_warning"] - - -def test_session_info_omits_install_warning_for_git(monkeypatch): - monkeypatch.setattr("hermes_cli.config.detect_install_method", lambda: "git") - - info = server._session_info(types.SimpleNamespace(tools=[], model="", provider="")) - - assert "install_warning" not in info - - # --------------------------------------------------------------------------- # History-mutating commands must reject while session.running is True. # Without these guards, prompt.submit's post-run history write either diff --git a/tests/test_wheel_locales_e2e.py b/tests/test_wheel_locales_e2e.py deleted file mode 100644 index 40c7a5b59415..000000000000 --- a/tests/test_wheel_locales_e2e.py +++ /dev/null @@ -1,195 +0,0 @@ -"""End-to-end: a built wheel, installed without a source tree, must resolve -i18n catalogs and render human strings — not raw key paths. - -This is the test that would have caught #27632 / #35374 / #23943. Metadata -unit tests (test_packaging_metadata.py) prove the glob is declared; this proves -the runtime actually finds the catalogs after a real pip install. - -This lives in tests/ (NOT tests/e2e/) so it is collected by the dedicated CI -step in Task 9, not by the existing `python -m pytest tests/e2e/` runner. - -Assumption: `from agent import i18n` must import with only stdlib + pyyaml -available (the test installs the wheel --no-deps + pyyaml). agent/__init__.py's -jiter preload swallows ImportError, and i18n.py imports yaml lazily inside -_load_catalog, so this holds today. If i18n.py ever gains a top-level non-stdlib -import, add it to the pip install line below. - -Marked `integration` because it shells out to `uv build` + `venv` + `pip` and -takes ~15-30s. Run with: pytest -m integration tests/test_wheel_locales_e2e.py -""" - -from __future__ import annotations - -import glob -import os -import subprocess -import sys -import tarfile -import venv -from pathlib import Path - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[1] - - -@pytest.mark.integration -@pytest.mark.timeout(300) # overrides the global --timeout=30; cold-CI wheel build + venv + pip can exceed it -def test_installed_wheel_renders_i18n_strings(tmp_path): - # 1. Build the wheel from the current tree. - wheel_dir = tmp_path / "wheel" - build = subprocess.run( - ["uv", "build", "--wheel", "--out-dir", str(wheel_dir), "."], - cwd=REPO_ROOT, - capture_output=True, - text=True, - timeout=600, - ) - assert build.returncode == 0, f"uv build failed:\n{build.stderr}" - wheels = glob.glob(str(wheel_dir / "*.whl")) - assert wheels, "no wheel produced" - wheel = wheels[0] - - # 2. Fresh venv, install the wheel WITHOUT deps (we only exercise i18n, - # which needs pyyaml). --force-reinstall guards against pip's - # same-version no-op. - venv_dir = tmp_path / "venv" - venv.create(venv_dir, with_pip=True) - if sys.platform == "win32": - vpy = venv_dir / "Scripts" / "python.exe" - else: - vpy = venv_dir / "bin" / "python" - subprocess.run([str(vpy), "-m", "pip", "install", "-q", "pyyaml"], check=True, timeout=300) - subprocess.run( - [str(vpy), "-m", "pip", "install", "-q", "--no-deps", "--force-reinstall", wheel], - check=True, - timeout=300, - ) - - # 3. Run from a directory that is NOT the source tree, with a clean env - # (no PYTHONPATH leaking the repo, no HERMES_BUNDLED_LOCALES). - probe = ( - "from agent import i18n;" - "import sys;" - "r = i18n.t('gateway.reset.header_default', lang='en');" - "s = i18n.t('gateway.status.header', lang='en');" - "print(repr(r)); print(repr(s));" - "sys.exit(0 if (r != 'gateway.reset.header_default' " - "and s != 'gateway.status.header') else 1)" - ) - env = {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "HERMES_BUNDLED_LOCALES")} - env["PYTHONIOENCODING"] = "utf-8" - if sys.platform == "win32": - env["PATH"] = f"{venv_dir / 'Scripts'}{os.pathsep}{env['PATH']}" - else: - env["PATH"] = f"{venv_dir / 'bin'}{os.pathsep}{env['PATH']}" - env["VIRTUAL_ENV"] = str(venv_dir) - run = subprocess.run( - [str(vpy), "-c", probe], - cwd=str(tmp_path), # NOT the repo root - capture_output=True, - text=True, - env=env, - timeout=120, - ) - assert run.returncode == 0, ( - "installed wheel returned raw i18n keys instead of human strings:\n" - f"stdout: {run.stdout}\nstderr: {run.stderr}" - ) - - -@pytest.mark.integration -@pytest.mark.timeout(300) # overrides the global --timeout=30; cold-CI sdist build can exceed it -def test_built_sdist_ships_locale_catalogs(tmp_path): - """The sdist must carry locales/ too. - - The wheel is covered above; the sdist is a separately shipped artifact - (PyPI, and the form distro/Homebrew packagers build from). MANIFEST.in - `graft locales` is what puts the catalogs in the tarball — a stale graft or - a setuptools change would pass the metadata unit test (which only inspects - the declaration) while the actual artifact regresses. This inspects the - real tarball so that path can't rot silently. Closes the sdist half of - #27632 / #35374 / #23943. - """ - sdist_dir = tmp_path / "sdist" - build = subprocess.run( - ["uv", "build", "--sdist", "--out-dir", str(sdist_dir), "."], - cwd=REPO_ROOT, - capture_output=True, - text=True, - timeout=600, - ) - assert build.returncode == 0, f"uv build --sdist failed:\n{build.stderr}" - tarballs = glob.glob(str(sdist_dir / "*.tar.gz")) - assert tarballs, "no sdist produced" - - with tarfile.open(tarballs[0]) as tf: - # Members are prefixed with the sdist root dir, e.g. - # hermes_agent-0.15.1/locales/en.yaml — match on the suffix. - catalogs = [m for m in tf.getnames() if "/locales/" in m and m.endswith(".yaml")] - - # Compare against the canonical language list rather than a hardcoded floor - # so adding/removing a catalog updates the guard automatically and a dropped - # catalog (not just a fully-empty graft) trips it. - from agent.i18n import SUPPORTED_LANGUAGES - - expected = len(SUPPORTED_LANGUAGES) - assert len(catalogs) == expected, ( - f"sdist shipped {len(catalogs)} locale catalogs, expected {expected} " - f"({len(SUPPORTED_LANGUAGES)} supported languages) — check `graft " - "locales` in MANIFEST.in" - ) - assert any(m.endswith("/locales/en.yaml") for m in catalogs), ( - f"sdist missing locales/en.yaml; shipped: {catalogs[:5]}" - ) - - -@pytest.mark.integration -@pytest.mark.timeout(300) -def test_built_sdist_ships_web_dist(tmp_path): - """The sdist must carry hermes_cli/web_dist/ too. - - MANIFEST.in `graft hermes_cli/web_dist` is what puts the frontend assets - in the source distribution tarball. This test builds the sdist and asserts - that index.html exists inside it. - """ - # Create a dummy index.html in hermes_cli/web_dist if it doesn't exist - # so that the sdist build actually has files to bundle. - web_dist_dir = REPO_ROOT / "hermes_cli" / "web_dist" - dummy_index = web_dist_dir / "index.html" - created_dummy = False - if not dummy_index.exists(): - web_dist_dir.mkdir(parents=True, exist_ok=True) - with open(dummy_index, "w", encoding="utf-8") as f: - f.write("Dummy Dashboard") - created_dummy = True - - try: - sdist_dir = tmp_path / "sdist" - build = subprocess.run( - ["uv", "build", "--sdist", "--out-dir", str(sdist_dir), "."], - cwd=REPO_ROOT, - capture_output=True, - text=True, - timeout=600, - ) - assert build.returncode == 0, f"uv build --sdist failed:\n{build.stderr}" - tarballs = glob.glob(str(sdist_dir / "*.tar.gz")) - assert tarballs, "no sdist produced" - - with tarfile.open(tarballs[0]) as tf: - names = tf.getnames() - web_assets = [m for m in names if "hermes_cli/web_dist/" in m] - - assert any(m.endswith("/hermes_cli/web_dist/index.html") for m in web_assets), ( - f"sdist missing hermes_cli/web_dist/index.html; shipped files in web_dist: {web_assets}" - ) - finally: - if created_dummy and dummy_index.exists(): - try: - dummy_index.unlink() - # Clean up directory if it's empty - if not any(web_dist_dir.iterdir()): - web_dist_dir.rmdir() - except Exception: - pass diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index a4303bdab5c8..06cee62109ef 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -2343,10 +2343,8 @@ class MCPServerTask: if not _MCP_AVAILABLE: raise ImportError( f"MCP server '{self.name}' requires the 'mcp' Python SDK, but " - "it is not installed. Install with:\n" - " pip install 'hermes-agent[mcp]'\n" - "or (full install):\n" - " pip install 'hermes-agent[all]'" + "it is not installed. Run `hermes setup` to install MCP support, " + "then retry." ) command = config.get("command") diff --git a/tools/skills_sync.py b/tools/skills_sync.py index 2c0f41c47a62..09b8337540b0 100644 --- a/tools/skills_sync.py +++ b/tools/skills_sync.py @@ -54,8 +54,7 @@ def _get_bundled_dir() -> Path: """Locate the bundled skills/ directory. Checks HERMES_BUNDLED_SKILLS env var first (set by Nix wrapper), - then a wheel-installed data dir, then falls back to the relative - path from this source file. + then falls back to the relative path from this source file. """ return get_bundled_skills_dir(Path(__file__).parent.parent / "skills") diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 545d72bb6907..da08ec6103f0 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -2451,7 +2451,7 @@ def text_to_speech_tool( return json.dumps({ "success": False, "error": "Mistral provider selected but 'mistralai' package not installed. " - "Run: pip install 'hermes-agent[mistral]'" + "Run `hermes setup` to install Mistral support." }, ensure_ascii=False) logger.info("Generating speech with Mistral Voxtral TTS...") _generate_mistral_tts(text, file_str, tts_config) diff --git a/tools/voice_mode.py b/tools/voice_mode.py index d000e29d59d9..9beaa3e7cb5d 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -6,7 +6,7 @@ sounddevice or system audio players. Dependencies (optional): pip install sounddevice numpy - or: pip install hermes-agent[voice] + or: uv sync --extra voice """ import logging diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d841cdc39f8a..1aacd9c9c129 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3833,18 +3833,6 @@ def _session_info(agent, session: dict | None = None) -> dict: "usage": _session_usage_snapshot(session), "profile_name": _current_profile_name(), } - try: - from hermes_cli.config import ( - detect_install_method, - format_unsupported_install_warning, - is_unsupported_install_method, - ) - - _install_method = detect_install_method() - if is_unsupported_install_method(_install_method): - info["install_warning"] = format_unsupported_install_warning(_install_method) - except Exception: - pass try: from hermes_cli import __version__, __release_date__ diff --git a/uv.lock b/uv.lock index 13a9912aa785..3b035b7e277a 100644 --- a/uv.lock +++ b/uv.lock @@ -1784,7 +1784,7 @@ requires-dist = [ { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["pty"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" }, + { name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["sms"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["sms"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["termux"], marker = "extra == 'termux-all'" }, @@ -1855,7 +1855,7 @@ requires-dist = [ { name = "websockets", specifier = "==15.0.1" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "honcho", "supermemory", "mem0", "vision", "pty", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] [[package]] name = "hf-xet" diff --git a/website/docs/developer-guide/acp-internals.md b/website/docs/developer-guide/acp-internals.md index 89ae398b6af5..e739d8087118 100644 --- a/website/docs/developer-guide/acp-internals.md +++ b/website/docs/developer-guide/acp-internals.md @@ -17,7 +17,6 @@ Key implementation files: - `acp_adapter/permissions.py` - `acp_adapter/tools.py` - `acp_adapter/auth.py` -- `acp_registry/agent.json` ## Boot flow @@ -31,8 +30,6 @@ hermes acp / hermes-acp / python -m acp_adapter -> acp.run_agent(agent, use_unstable_protocol=True) ``` -The Zed ACP Registry path launches the same adapter through `uvx --from 'hermes-agent[acp]==' hermes-acp`, pointed at the `hermes-agent` PyPI release. - Stdout is reserved for ACP JSON-RPC transport. Human-readable logs go to stderr. ## Major components @@ -149,7 +146,7 @@ Instead it reuses Hermes' runtime resolver: - `acp_adapter/auth.py` - `hermes_cli/runtime_provider.py` -So ACP advertises and uses the currently configured Hermes provider/credentials. It also always advertises a terminal setup auth method (`hermes-setup`, args `--setup`) so first-run registry clients can open Hermes' interactive model/provider configuration before starting a normal ACP session. +So ACP advertises and uses the currently configured Hermes provider/credentials. It also always advertises a terminal setup auth method (`hermes-setup`, args `--setup`) so first-run ACP clients can open Hermes' interactive model/provider configuration before starting a normal ACP session. ## Working directory binding diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index 86aea0fc0158..39237bc6f4b6 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -159,4 +159,4 @@ For more diagnostics, run `hermes doctor` — it will tell you exactly what's mi ## Install method auto-detection -Hermes auto-detects whether it was installed via `pip`, the git installer, Homebrew, or NixOS, and `hermes update` prints the matching update command for that path. There's no env var to set — the detection is based on the install layout (Python site-packages, `~/.hermes/hermes-agent/`, Homebrew prefix, or Nix store path). `hermes doctor` also surfaces the detected method under its environment summary. +Hermes auto-detects whether it was installed via the git installer, Docker, or NixOS, and `hermes update` prints the matching update command for that path. There's no env var to set — the detection is based on the install layout (`~/.hermes/hermes-agent/` checkout, Docker image stamp, or Nix store path). `hermes doctor` also surfaces the detected method under its environment summary. diff --git a/website/docs/getting-started/platform-support.md b/website/docs/getting-started/platform-support.md index d0df354e5924..1791716717c9 100644 --- a/website/docs/getting-started/platform-support.md +++ b/website/docs/getting-started/platform-support.md @@ -44,7 +44,7 @@ PRs to fix them will _not_ be accepted, and any code that keeps compatibility wi - installs via the AUR (we might upstream patches if it helps out <3) - macOS on x86 (Intel) processors -- installs via `pypi` (e.g. `uv tool install hermes-agent`, `pip install hermse-agent`, etc.) +- installs via `pypi` (e.g. `uv tool install hermes-agent`, `pip install hermes-agent`, etc.) - installs via `brew` (`brew install hermes-agent`) If you are using an unsupported distribution method, please read the [the installation guide](./installation.md) to learn how to switch to a supported one. diff --git a/website/docs/guides/google-vertex.md b/website/docs/guides/google-vertex.md index 851a391691c8..54923db967d5 100644 --- a/website/docs/guides/google-vertex.md +++ b/website/docs/guides/google-vertex.md @@ -18,7 +18,7 @@ Vertex has **no static API key** for the standard endpoint. Every request needs - **Credentials**, one of: - a **service-account JSON** key file with the `roles/aiplatform.user` role, or - **Application Default Credentials** via `gcloud auth application-default login` (or the metadata server when running on a GCP VM). -- **`google-auth`** — installed automatically the first time you select Vertex (lazy install), or explicitly with `pip install 'hermes-agent[vertex]'`. +- **`google-auth`** — installed automatically the first time you select Vertex (lazy install). Run `hermes setup` to repair a managed install if that fails. ## Quick Start @@ -128,7 +128,7 @@ Hermes found neither a service-account JSON nor working ADC. Either set `VERTEX_ ### `google-auth` not installed -Install the extra: `pip install 'hermes-agent[vertex]'`. Hermes also lazy-installs it the first time you select the Vertex provider. +Hermes lazy-installs it the first time you select the Vertex provider. If that fails, run `hermes setup` to repair the managed install. ### 404 on Gemini 3.x models diff --git a/website/docs/guides/python-library.md b/website/docs/guides/python-library.md index 89fa122759b1..24d1aecc95e4 100644 --- a/website/docs/guides/python-library.md +++ b/website/docs/guides/python-library.md @@ -12,23 +12,15 @@ Hermes isn't just a CLI tool. You can import `AIAgent` directly and use it progr ## Installation -Install Hermes directly from the repository: +Clone Hermes and create its supported editable development environment: ```bash -pip install git+https://github.com/NousResearch/hermes-agent.git +git clone https://github.com/NousResearch/hermes-agent.git +cd hermes-agent +uv sync ``` -Or with [uv](https://docs.astral.sh/uv/): - -```bash -uv pip install git+https://github.com/NousResearch/hermes-agent.git -``` - -You can also pin it in your `requirements.txt`: - -```text -hermes-agent @ git+https://github.com/NousResearch/hermes-agent.git -``` +Run your application with `uv run python your_app.py` from that checkout. Hermes does not publish a supported wheel or source distribution for `requirements.txt` installs. :::tip The same environment variables used by the CLI are required when using Hermes as a library. At minimum, set `OPENROUTER_API_KEY` (or `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` if using direct provider access). diff --git a/website/docs/guides/use-voice-mode-with-hermes.md b/website/docs/guides/use-voice-mode-with-hermes.md index b99b19211a6b..7007e20efc5e 100644 --- a/website/docs/guides/use-voice-mode-with-hermes.md +++ b/website/docs/guides/use-voice-mode-with-hermes.md @@ -444,7 +444,7 @@ By default, the bot needs an `@mention` in Discord server text channels unless c If you want the shortest path to success: 1. get text Hermes working -2. install `hermes-agent[voice]` +2. run `hermes setup voice` to enable voice support 3. use CLI voice mode with local STT + Edge TTS 4. then enable `/voice on` in Telegram or Discord 5. only after that, try Discord VC mode diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index ed00d5b2a850..9ef33b90ef1b 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -403,7 +403,7 @@ vertex: region: "global" # required for the Gemini 3.x previews ``` -`VERTEX_PROJECT_ID` / `VERTEX_REGION` env vars override the `config.yaml` values. Install with `pip install 'hermes-agent[vertex]'` (or let Hermes lazy-install `google-auth` on first use). See the [Google Vertex AI guide](/guides/google-vertex) for the full walkthrough, and the [Google Gemini guide](/guides/google-gemini) for the static-API-key AI Studio path instead. +`VERTEX_PROJECT_ID` / `VERTEX_REGION` env vars override the `config.yaml` values. Hermes lazy-installs `google-auth` on first use; run `hermes setup` if the managed install needs repair. See the [Google Vertex AI guide](/guides/google-vertex) for the full walkthrough, and the [Google Gemini guide](/guides/google-gemini) for the static-API-key AI Studio path instead. ### Qwen Portal (OAuth) diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index b222039715fd..3e7782724b88 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1582,7 +1582,7 @@ Additional behavior: |---------|-------------| | `hermes version` | Print version information. | | `hermes update` | Pull latest changes and reinstall dependencies. | -| `hermes postinstall` | Internal bootstrap. Runs once after the install script provisions Hermes (or after `hermes update`) to install non-Python dependencies that pip cannot provide — Node.js runtime, headless browser, ripgrep, ffmpeg — and then trigger `hermes setup` if the profile has not been configured yet. Safe to re-run idempotently. | + | `hermes uninstall [--full] [--gui] [--yes]` | Remove Hermes, optionally deleting all config/data. `--gui` removes only the desktop Chat GUI, leaving the agent intact; `--full` also deletes config/data; `--yes` skips prompts. | ## See also diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index e04e1eb30e26..2500f3a7752a 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1767,7 +1767,7 @@ quick_commands: command: df -h / update: type: exec - command: cd ~/.hermes/hermes-agent && git pull && pip install -e . + command: cd ~/.hermes/hermes-agent && git pull && uv pip install -e . gpu: type: exec command: nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total --format=csv,noheader diff --git a/website/docs/user-guide/features/acp.md b/website/docs/user-guide/features/acp.md index 4dce234ef524..ccdaabc5832d 100644 --- a/website/docs/user-guide/features/acp.md +++ b/website/docs/user-guide/features/acp.md @@ -33,10 +33,10 @@ It intentionally excludes things that do not fit typical editor UX, such as mess ## Installation -Install Hermes normally, then add the ACP extra: +Install Hermes normally, then add the ACP extra from the install checkout: ```bash -pip install -e '.[acp]' +cd ~/.hermes/hermes-agent && uv pip install -e '.[acp]' ``` This installs the `agent-client-protocol` dependency and enables: @@ -45,14 +45,6 @@ This installs the `agent-client-protocol` dependency and enables: - `hermes-acp` - `python -m acp_adapter` -For Zed registry installs, Zed launches Hermes through the official ACP Registry entry. That entry uses a `uvx` distribution that runs: - -```bash -uvx --from 'hermes-agent[acp]==' hermes-acp -``` - -Make sure `uv` is available on `PATH` before using the registry install path. - ## Launching the ACP server Any of the following starts Hermes in ACP mode: @@ -89,7 +81,7 @@ hermes acp --setup-browser # interactive (prompts before ~400 MB downl hermes acp --setup-browser --yes # accept the download non-interactively ``` -This is the standalone command. The Zed registry's terminal-auth flow (`hermes acp --setup`) also offers the browser bootstrap as a follow-up question after model selection, so most users never need to run `--setup-browser` directly. +This is the standalone command. The terminal-auth flow (`hermes acp --setup`) also offers the browser bootstrap as a follow-up question after model selection, so most users never need to run `--setup-browser` directly. What it does: @@ -126,19 +118,10 @@ If you want to define Hermes manually, add it through VS Code settings under `ac ### Zed -Zed v0.221.x and newer installs external agents through the official ACP Registry. +Configure Hermes as a custom agent server in Zed settings: 1. Open the Agent Panel. -2. Click **Add Agent**, or run the `zed: acp registry` command. -3. Search for **Hermes Agent**. -4. Install it and start a new Hermes external-agent thread. - -Prerequisites: - -- Configure Hermes provider credentials first with `hermes model`, or set them in `~/.hermes/.env` / `~/.hermes/config.yaml`. -- Install `uv` so the registry launcher can run `uvx --from 'hermes-agent[acp]==' hermes-acp`. - -For local development before the registry entry is available, use a custom agent server in Zed settings: +2. Add a custom agent server with the following configuration: ```json { @@ -152,32 +135,15 @@ For local development before the registry entry is available, use a custom agent } ``` +3. Start a new Hermes external-agent thread. + +Prerequisites: + +- Configure Hermes provider credentials first with `hermes model`, or set them in `~/.hermes/.env` / `~/.hermes/config.yaml`. + ### JetBrains -Use an ACP-compatible plugin and point it at: - -```text -/path/to/hermes-agent/acp_registry -``` - -## Registry manifest - -The source copy of Hermes' official ACP Registry metadata lives at: - -```text -acp_registry/agent.json -acp_registry/icon.svg -``` - -The upstream registry PR copies those files into the top-level `hermes-agent/` directory in `agentclientprotocol/registry`. - -The registry entry uses a `uvx` distribution that points directly at the `hermes-agent` PyPI release: - -```text -uvx --from 'hermes-agent[acp]==' hermes-acp -``` - -The registry CI verifies that the pinned version exists on PyPI, so the manifest's `version` and uvx `package` pin must always match `pyproject.toml`. `scripts/release.py` keeps them in lockstep automatically. +Use an ACP-compatible plugin and point it at `hermes acp` or `hermes-acp`. ## Configuration and credentials @@ -188,7 +154,7 @@ ACP mode uses the same Hermes configuration as the CLI: - `~/.hermes/skills/` - `~/.hermes/state.db` -Provider resolution uses Hermes' normal runtime resolver, so ACP inherits the currently configured provider and credentials. Hermes also advertises a terminal auth method (`--setup`) for first-run registry clients; this opens Hermes' interactive model/provider setup. +Provider resolution uses Hermes' normal runtime resolver, so ACP inherits the currently configured provider and credentials. Hermes also advertises a terminal auth method (`--setup`) for first-run ACP clients; this opens Hermes' interactive model/provider setup. ## Session behavior @@ -239,11 +205,9 @@ The ACP bridge maps these options onto Hermes' internal approval semantics — ` Check: -- In Zed, open the ACP Registry with `zed: acp registry` and search for **Hermes Agent**. - For manual/local development, verify the custom `agent_servers` command points to `hermes acp`. - Hermes is installed and on your PATH. -- The ACP extra is installed (`pip install -e '.[acp]'`). -- `uv` is installed if launching from the official Zed registry entry. +- The ACP extra is installed (`cd ~/.hermes/hermes-agent && uv pip install -e '.[acp]'`). ### ACP starts but immediately errors @@ -264,11 +228,7 @@ ACP mode uses Hermes' existing provider setup. Configure credentials with: hermes model ``` -or by editing `~/.hermes/.env`. Registry clients can also trigger Hermes' terminal auth flow, which runs the same interactive provider/model setup. - -### Zed registry launcher cannot find uv - -Install `uv` from the official uv installation docs, then retry the Hermes Agent thread from Zed. +or by editing `~/.hermes/.env`. The terminal auth flow (`hermes acp --setup`) can also trigger the interactive provider/model setup. ## See also diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index bcd43951f372..2f81a54a557c 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -361,7 +361,7 @@ the API server and webhook endpoints) with its live connection status. A consolidated administration panel for installation-wide operations: -- **Host** — live system stats: OS / kernel, architecture, hostname, Python and Hermes versions, CPU core count + utilization, memory, disk usage of the Hermes home, uptime, and load average. (CPU/memory/disk come from `psutil` when installed; identity fields are always shown.) The Hermes version shows an **update-status badge** (up to date / N commits behind) and a **Check for updates** button. When an update is available on a git or pip install, an **Update now** button opens a confirmation dialog — showing how many commits you'll pull — before running `hermes update` in the background. On Docker/Nix/Homebrew installs the dashboard can't apply the update in place, so it shows the correct out-of-band command instead. +- **Host** — live system stats: OS / kernel, architecture, hostname, Python and Hermes versions, CPU core count + utilization, memory, disk usage of the Hermes home, uptime, and load average. (CPU/memory/disk come from `psutil` when installed; identity fields are always shown.) The Hermes version shows an **update-status badge** (up to date / N commits behind) and a **Check for updates** button. When an update is available on a git install, an **Update now** button opens a confirmation dialog — showing how many commits you'll pull — before running `hermes update` in the background. On Docker/Nix installs the dashboard can't apply the update in place, so it shows the correct out-of-band command instead. - **Nous Portal** — login status, the active inference provider, and the Tool Gateway routing table (which tools run via the Portal vs. locally), with a link to manage your subscription. Read-only mirror of `hermes portal`. - **Skill curator** — the background skill-maintenance status (active / paused, interval, last run) with pause/resume and a run-now button. Mirrors `hermes curator`. - **Gateway** — start, stop, and restart the messaging gateway, with live status (running/stopped, PID, state) @@ -542,7 +542,7 @@ same auth gate as the rest of `/api/`. | `GET /api/ops/checkpoints` · `POST .../prune` | Inspect / prune the `/rollback` store | | `POST /api/ops/hooks` · `DELETE /api/ops/hooks` | Create / remove a shell hook (consent-gated) | | `GET /api/system/stats` | Host stats — OS, CPU, memory, disk, uptime | -| `GET /api/hermes/update/check` | Report update availability (commits behind, install method) without applying. For git/pip installs that are behind, also returns a `commits` list (`sha`, `summary`, `author`, `at`) of what's changed. `?force=1` busts the 6h cache | +| `GET /api/hermes/update/check` | Report update availability (commits behind, install method) without applying. For git installs that are behind, also returns a `commits` list (`sha`, `summary`, `author`, `at`) of what's changed. `?force=1` busts the 6h cache | | `GET /api/curator` · `PUT .../paused` · `POST .../run` | Skill-curator status + pause/resume + run | | `GET /api/portal` | Nous Portal auth + Tool Gateway routing (read-only) | | `POST /api/ops/prompt-size` · `/dump` · `/config-migrate` | Diagnostics (backgrounded) | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/acp-internals.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/acp-internals.md index 8230d5534c17..830cce38550e 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/acp-internals.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/acp-internals.md @@ -17,7 +17,6 @@ ACP 适配器将 Hermes 的同步 `AIAgent` 封装为异步 JSON-RPC stdio 服 - `acp_adapter/permissions.py` - `acp_adapter/tools.py` - `acp_adapter/auth.py` -- `acp_registry/agent.json` ## 启动流程 @@ -31,8 +30,6 @@ hermes acp / hermes-acp / python -m acp_adapter -> acp.run_agent(agent, use_unstable_protocol=True) ``` -Zed ACP Registry 路径通过 `uvx --from 'hermes-agent[acp]==' hermes-acp` 启动同一适配器,指向 `hermes-agent` PyPI 发布包。 - stdout 保留用于 ACP JSON-RPC 传输。人类可读的日志输出至 stderr。 ## 主要组件 @@ -149,7 +146,7 @@ ACP 不实现自己的认证存储。 - `acp_adapter/auth.py` - `hermes_cli/runtime_provider.py` -因此 ACP 通告并使用当前配置的 Hermes provider/凭据。它还始终通告一个终端 setup 认证方法(`hermes-setup`,参数 `--setup`),以便首次运行的 registry 客户端在启动正常 ACP 会话前可以打开 Hermes 的交互式模型/provider 配置。 +因此 ACP 通告并使用当前配置的 Hermes provider/凭据。它还始终通告一个终端 setup 认证方法(`hermes-setup`,参数 `--setup`),以便首次运行的 ACP 客户端在启动正常 ACP 会话前可以打开 Hermes 的交互式模型/provider 配置。 ## 工作目录绑定 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md index 6e91995e7445..c2bf14764a2a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md @@ -204,4 +204,4 @@ hermes setup --portal ## 安装方式自动检测 -Hermes 会自动检测安装方式(`pip`、git 安装程序、Homebrew 或 NixOS),`hermes update` 会打印对应路径的更新命令。无需设置任何环境变量——检测基于安装目录结构(Python site-packages、`~/.hermes/hermes-agent/`、Homebrew 前缀或 Nix store 路径)。`hermes doctor` 也会在其环境摘要中显示检测到的安装方式。 +Hermes 会自动检测安装方式(git 安装程序、Docker 或 NixOS),`hermes update` 会打印对应路径的更新命令。无需设置任何环境变量——检测基于安装目录结构(`~/.hermes/hermes-agent/` 检出、Docker 镜像标记或 Nix store 路径)。`hermes doctor` 也会在其环境摘要中显示检测到的安装方式。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/python-library.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/python-library.md index e094cd1af104..59493c66fce6 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/python-library.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/python-library.md @@ -12,23 +12,15 @@ Hermes 不仅仅是一个 CLI 工具。你可以直接导入 `AIAgent`,在自 ## 安装 -直接从仓库安装 Hermes: +克隆 Hermes 并创建受支持的可编辑开发环境: ```bash -pip install git+https://github.com/NousResearch/hermes-agent.git +git clone https://github.com/NousResearch/hermes-agent.git +cd hermes-agent +uv sync ``` -或使用 [uv](https://docs.astral.sh/uv/): - -```bash -uv pip install git+https://github.com/NousResearch/hermes-agent.git -``` - -也可以在 `requirements.txt` 中固定版本: - -```text -hermes-agent @ git+https://github.com/NousResearch/hermes-agent.git -``` +在该检出目录中使用 `uv run python your_app.py` 运行应用。Hermes 不发布用于 `requirements.txt` 安装的受支持 wheel 或源代码发行版。 :::tip 将 Hermes 作为库使用时,CLI 所需的环境变量同样必须设置。至少需要设置 `OPENROUTER_API_KEY`(若直接访问提供商,则设置 `OPENAI_API_KEY` 或 `ANTHROPIC_API_KEY`)。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/use-voice-mode-with-hermes.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/use-voice-mode-with-hermes.md index 853e69310c71..5df1eea252f9 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/use-voice-mode-with-hermes.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/use-voice-mode-with-hermes.md @@ -440,7 +440,7 @@ Hermes 加入 Discord 语音频道(VC),监听用户语音,转录后运 如果你想走最短的成功路径: 1. 让文本 Hermes 正常工作 -2. 安装 `hermes-agent[voice]` +2. 运行 `hermes setup voice` 以启用语音支持 3. 使用本地 STT + Edge TTS 的 CLI 语音模式 4. 然后在 Telegram 或 Discord 中启用 `/voice on` 5. 只有在此之后,再尝试 Discord 语音频道模式 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md index 2d290c846526..6d4c7c14f6d1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md @@ -1389,7 +1389,7 @@ quick_commands: command: df -h / update: type: exec - command: cd ~/.hermes/hermes-agent && git pull && pip install -e . + command: cd ~/.hermes/hermes-agent && git pull && uv pip install -e . gpu: type: exec command: nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total --format=csv,noheader diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/acp.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/acp.md index 629430438cba..717381203e77 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/acp.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/acp.md @@ -33,10 +33,10 @@ Hermes 使用专为编辑器工作流设计的精选 `hermes-acp` 工具集运 ## 安装 -正常安装 Hermes 后,添加 ACP 扩展: +正常安装 Hermes 后,从安装检出目录添加 ACP 扩展: ```bash -pip install -e '.[acp]' +cd ~/.hermes/hermes-agent && uv pip install -e '.[acp]' ``` 这将安装 `agent-client-protocol` 依赖并启用: @@ -45,14 +45,6 @@ pip install -e '.[acp]' - `hermes-acp` - `python -m acp_adapter` -对于 Zed registry 安装,Zed 通过官方 ACP Registry 条目启动 Hermes。该条目使用 `uvx` 发行版运行: - -```bash -uvx --from 'hermes-agent[acp]==' hermes-acp -``` - -使用 registry 安装路径前,请确保 `uv` 已在 `PATH` 中可用。 - ## 启动 ACP 服务器 以下任意命令均可以 ACP 模式启动 Hermes: @@ -87,7 +79,7 @@ hermes acp --setup-browser # 交互式(下载约 400 MB 前会提示 hermes acp --setup-browser --yes # 非交互式接受下载 ``` -这是独立命令。Zed registry 的终端认证流程(`hermes acp --setup`)在模型选择后也会将浏览器引导作为后续问题提供,因此大多数用户无需直接运行 `--setup-browser`。 +这是独立命令。终端认证流程(`hermes acp --setup`)在模型选择后也会将浏览器引导作为后续问题提供,因此大多数用户无需直接运行 `--setup-browser`。 具体操作: @@ -124,19 +116,10 @@ hermes acp --setup-browser --yes # 非交互式接受下载 ### Zed -Zed v0.221.x 及更新版本通过官方 ACP Registry 安装外部 agent。 +在 Zed 设置中将 Hermes 配置为自定义 agent 服务器: 1. 打开 Agent 面板。 -2. 点击 **Add Agent**,或运行 `zed: acp registry` 命令。 -3. 搜索 **Hermes Agent**。 -4. 安装后启动新的 Hermes 外部 agent 线程。 - -前提条件: - -- 先通过 `hermes model` 配置 Hermes provider 凭据,或在 `~/.hermes/.env` / `~/.hermes/config.yaml` 中设置。 -- 安装 `uv`,以便 registry 启动器可以运行 `uvx --from 'hermes-agent[acp]==' hermes-acp`。 - -在 registry 条目可用之前进行本地开发时,在 Zed 设置中使用自定义 agent 服务器: +2. 使用以下配置添加自定义 agent 服务器: ```json { @@ -150,32 +133,15 @@ Zed v0.221.x 及更新版本通过官方 ACP Registry 安装外部 agent。 } ``` +3. 启动新的 Hermes 外部 agent 线程。 + +前提条件: + +- 先通过 `hermes model` 配置 Hermes provider 凭据,或在 `~/.hermes/.env` / `~/.hermes/config.yaml` 中设置。 + ### JetBrains -使用兼容 ACP 的插件并将其指向: - -```text -/path/to/hermes-agent/acp_registry -``` - -## Registry 清单 - -Hermes 官方 ACP Registry 元数据的源文件位于: - -```text -acp_registry/agent.json -acp_registry/icon.svg -``` - -上游 registry PR 将这些文件复制到 `agentclientprotocol/registry` 中的顶层 `hermes-agent/` 目录。 - -Registry 条目使用直接指向 `hermes-agent` PyPI 发行版的 `uvx` 发行版: - -```text -uvx --from 'hermes-agent[acp]==' hermes-acp -``` - -Registry CI 会验证固定版本是否存在于 PyPI,因此清单的 `version` 和 uvx `package` 固定版本必须始终与 `pyproject.toml` 匹配。`scripts/release.py` 会自动保持它们同步。 +使用兼容 ACP 的插件并将其指向 `hermes acp` 或 `hermes-acp`。 ## 配置与凭据 @@ -186,7 +152,7 @@ ACP 模式使用与 CLI 相同的 Hermes 配置: - `~/.hermes/skills/` - `~/.hermes/state.db` -Provider 解析使用 Hermes 的正常运行时解析器,因此 ACP 继承当前配置的 provider 和凭据。Hermes 还为首次运行的 registry 客户端提供终端认证方法(`--setup`);这将打开 Hermes 的交互式模型/provider 设置。 +Provider 解析使用 Hermes 的正常运行时解析器,因此 ACP 继承当前配置的 provider 和凭据。Hermes 还为首次运行的 ACP 客户端提供终端认证方法(`--setup`);这将打开 Hermes 的交互式模型/provider 设置。 ## 会话行为 @@ -237,11 +203,9 @@ ACP 桥接将这些选项映射到 Hermes 的内部审批语义——`allow_alwa 检查: -- 在 Zed 中,使用 `zed: acp registry` 打开 ACP Registry 并搜索 **Hermes Agent**。 - 对于手动/本地开发,验证自定义 `agent_servers` 命令是否指向 `hermes acp`。 - Hermes 已安装且在 PATH 中。 -- ACP 扩展已安装(`pip install -e '.[acp]'`)。 -- 如果从官方 Zed registry 条目启动,`uv` 已安装。 +- ACP 扩展已安装(`cd ~/.hermes/hermes-agent && uv pip install -e '.[acp]'`)。 ### ACP 启动后立即报错 @@ -262,11 +226,7 @@ ACP 模式使用 Hermes 现有的 provider 设置。通过以下方式配置凭 hermes model ``` -或编辑 `~/.hermes/.env`。Registry 客户端也可以触发 Hermes 的终端认证流程,该流程运行相同的交互式 provider/模型设置。 - -### Zed registry 启动器找不到 uv - -从官方 uv 安装文档安装 `uv`,然后从 Zed 重试 Hermes Agent 线程。 +或编辑 `~/.hermes/.env`。终端认证流程(`hermes acp --setup`)也可以触发交互式 provider/模型设置。 ## 另请参阅