chore: prepare Hermes for Homebrew packaging (#4099)

Co-authored-by: Yabuku-xD <78594762+Yabuku-xD@users.noreply.github.com>
This commit is contained in:
Teknium
2026-03-30 17:34:43 -07:00
committed by GitHub
parent 11aa44d34d
commit e64b047663
17 changed files with 400 additions and 56 deletions

4
MANIFEST.in Normal file
View File

@@ -0,0 +1,4 @@
graft skills
graft optional-skills
global-exclude __pycache__
global-exclude *.py[cod]

View File

@@ -325,9 +325,9 @@ def _check_unavailable_skill(command_name: str) -> str | None:
) )
# Check optional skills (shipped with repo but not installed) # Check optional skills (shipped with repo but not installed)
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home, get_optional_skills_dir
repo_root = Path(__file__).resolve().parent.parent repo_root = Path(__file__).resolve().parent.parent
optional_dir = repo_root / "optional-skills" optional_dir = get_optional_skills_dir(repo_root / "optional-skills")
if optional_dir.exists(): if optional_dir.exists():
for skill_md in optional_dir.rglob("SKILL.md"): for skill_md in optional_dir.rglob("SKILL.md"):
name = skill_md.parent.name.lower().replace("_", "-") name = skill_md.parent.name.lower().replace("_", "-")
@@ -4695,6 +4695,10 @@ class GatewayRunner:
import shutil import shutil
import subprocess import subprocess
from datetime import datetime from datetime import datetime
from hermes_cli.config import is_managed, format_managed_message
if is_managed():
return f"{format_managed_message('update Hermes Agent')}"
project_root = Path(__file__).parent.parent.resolve() project_root = Path(__file__).parent.parent.resolve()
git_dir = project_root / '.git' git_dir = project_root / '.git'

View File

@@ -432,10 +432,11 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
try: try:
behind = get_update_result(timeout=0.5) behind = get_update_result(timeout=0.5)
if behind and behind > 0: if behind and behind > 0:
from hermes_cli.config import recommended_update_command
commits_word = "commit" if behind == 1 else "commits" commits_word = "commit" if behind == 1 else "commits"
right_lines.append( right_lines.append(
f"[bold yellow]⚠ {behind} {commits_word} behind[/]" f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
f"[dim yellow] — run [bold]hermes update[/bold] to update[/]" f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
) )
except Exception: except Exception:
pass # Never break the banner over an update check pass # Never break the banner over an update check

View File

@@ -12,6 +12,7 @@ import sys
from pathlib import Path from pathlib import Path
from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config
from hermes_constants import get_optional_skills_dir
from hermes_cli.setup import ( from hermes_cli.setup import (
Colors, Colors,
color, color,
@@ -27,8 +28,7 @@ logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).parent.parent.resolve() PROJECT_ROOT = Path(__file__).parent.parent.resolve()
_OPENCLAW_SCRIPT = ( _OPENCLAW_SCRIPT = (
PROJECT_ROOT get_optional_skills_dir(PROJECT_ROOT / "optional-skills")
/ "optional-skills"
/ "migration" / "migration"
/ "openclaw-migration" / "openclaw-migration"
/ "scripts" / "scripts"

View File

@@ -52,26 +52,86 @@ from hermes_cli.default_soul import DEFAULT_SOUL_MD
# Managed mode (NixOS declarative config) # Managed mode (NixOS declarative config)
# ============================================================================= # =============================================================================
_MANAGED_TRUE_VALUES = ("true", "1", "yes")
_MANAGED_SYSTEM_NAMES = {
"brew": "Homebrew",
"homebrew": "Homebrew",
"nix": "NixOS",
"nixos": "NixOS",
}
def get_managed_system() -> Optional[str]:
"""Return the package manager owning this install, if any."""
raw = os.getenv("HERMES_MANAGED", "").strip()
if raw:
normalized = raw.lower()
if normalized in _MANAGED_TRUE_VALUES:
return "NixOS"
return _MANAGED_SYSTEM_NAMES.get(normalized, raw)
managed_marker = get_hermes_home() / ".managed"
if managed_marker.exists():
return "NixOS"
return None
def is_managed() -> bool: def is_managed() -> bool:
"""Check if hermes is running in Nix-managed mode. """Check if Hermes is running in package-manager-managed mode.
Two signals: the HERMES_MANAGED env var (set by the systemd service), Two signals: the HERMES_MANAGED env var (set by the systemd service),
or a .managed marker file in HERMES_HOME (set by the NixOS activation or a .managed marker file in HERMES_HOME (set by the NixOS activation
script, so interactive shells also see it). script, so interactive shells also see it).
""" """
if os.getenv("HERMES_MANAGED", "").lower() in ("true", "1", "yes"): return get_managed_system() is not None
return True
managed_marker = get_hermes_home() / ".managed"
return managed_marker.exists() 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 "sudo nixos-rebuild switch"
return None
def recommended_update_command() -> str:
"""Return the best update command for the current installation."""
return get_managed_update_command() or "hermes update"
def format_managed_message(action: str = "modify this Hermes installation") -> str:
"""Build a user-facing error for managed installs."""
managed_system = get_managed_system() or "a package manager"
raw = os.getenv("HERMES_MANAGED", "").strip().lower()
if managed_system == "NixOS":
env_hint = "true" if raw in _MANAGED_TRUE_VALUES else raw or "true"
return (
f"Cannot {action}: this Hermes installation is managed by NixOS "
f"(HERMES_MANAGED={env_hint}).\n"
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
" 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."
)
def managed_error(action: str = "modify configuration"): def managed_error(action: str = "modify configuration"):
"""Print user-friendly error for managed mode.""" """Print user-friendly error for managed mode."""
print( print(format_managed_message(action), file=sys.stderr)
f"Cannot {action}: configuration is managed by NixOS (HERMES_MANAGED=true).\n"
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
" sudo nixos-rebuild switch",
file=sys.stderr,
)
# ============================================================================= # =============================================================================

View File

@@ -2467,10 +2467,14 @@ def cmd_version(args):
# Show update status (synchronous — acceptable since user asked for version info) # Show update status (synchronous — acceptable since user asked for version info)
try: try:
from hermes_cli.banner import check_for_updates from hermes_cli.banner import check_for_updates
from hermes_cli.config import recommended_update_command
behind = check_for_updates() behind = check_for_updates()
if behind and behind > 0: if behind and behind > 0:
commits_word = "commit" if behind == 1 else "commits" commits_word = "commit" if behind == 1 else "commits"
print(f"Update available: {behind} {commits_word} behind — run 'hermes update'") print(
f"Update available: {behind} {commits_word} behind — "
f"run '{recommended_update_command()}'"
)
elif behind == 0: elif behind == 0:
print("Up to date") print("Up to date")
except Exception: except Exception:
@@ -2821,6 +2825,11 @@ def _invalidate_update_cache():
def cmd_update(args): def cmd_update(args):
"""Update Hermes Agent to the latest version.""" """Update Hermes Agent to the latest version."""
import shutil import shutil
from hermes_cli.config import is_managed, managed_error
if is_managed():
managed_error("update Hermes Agent")
return
print("⚕ Updating Hermes Agent...") print("⚕ Updating Hermes Agent...")
print() print()

View File

@@ -265,10 +265,11 @@ def cmd_install(identifier: str, force: bool = False) -> None:
) )
sys.exit(1) sys.exit(1)
if mv_int > _SUPPORTED_MANIFEST_VERSION: if mv_int > _SUPPORTED_MANIFEST_VERSION:
from hermes_cli.config import recommended_update_command
console.print( console.print(
f"[red]Error:[/red] Plugin '{plugin_name}' requires manifest_version " f"[red]Error:[/red] Plugin '{plugin_name}' requires manifest_version "
f"{mv}, but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}.\n" f"{mv}, but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}.\n"
f"Run [bold]hermes update[/bold] to get a newer installer." f"Run [bold]{recommended_update_command()}[/bold] to get a newer installer."
) )
sys.exit(1) sys.exit(1)

View File

@@ -18,6 +18,8 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from hermes_constants import get_optional_skills_dir
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).parent.parent.resolve() PROJECT_ROOT = Path(__file__).parent.parent.resolve()
@@ -3121,8 +3123,7 @@ def _skip_configured_section(
_OPENCLAW_SCRIPT = ( _OPENCLAW_SCRIPT = (
PROJECT_ROOT get_optional_skills_dir(PROJECT_ROOT / "optional-skills")
/ "optional-skills"
/ "migration" / "migration"
/ "openclaw-migration" / "openclaw-migration"
/ "scripts" / "scripts"

View File

@@ -17,6 +17,20 @@ def get_hermes_home() -> Path:
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
def get_optional_skills_dir(default: Path | None = None) -> Path:
"""Return the optional-skills directory, honoring package-manager wrappers.
Packaged installs may ship ``optional-skills`` outside the Python package
tree and expose it via ``HERMES_OPTIONAL_SKILLS``.
"""
override = os.getenv("HERMES_OPTIONAL_SKILLS", "").strip()
if override:
return Path(override)
if default is not None:
return default
return get_hermes_home() / "optional-skills"
def get_hermes_dir(new_subpath: str, old_name: str) -> Path: def get_hermes_dir(new_subpath: str, old_name: str) -> Path:
"""Resolve a Hermes subdirectory with backward compatibility. """Resolve a Hermes subdirectory with backward compatibility.

View File

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

View File

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

View File

@@ -32,7 +32,6 @@ dependencies = [
"fal-client>=0.13.1,<1", "fal-client>=0.13.1,<1",
# Text-to-speech (Edge TTS is free, no API key needed) # Text-to-speech (Edge TTS is free, no API key needed)
"edge-tts>=7.2.7,<8", "edge-tts>=7.2.7,<8",
"faster-whisper>=1.0.0,<2",
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
"PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597 "PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597
] ]
@@ -47,7 +46,13 @@ slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
matrix = ["matrix-nio[e2e]>=0.24.0,<1"] matrix = ["matrix-nio[e2e]>=0.24.0,<1"]
cli = ["simple-term-menu>=1.0,<2"] cli = ["simple-term-menu>=1.0,<2"]
tts-premium = ["elevenlabs>=1.0,<2"] tts-premium = ["elevenlabs>=1.0,<2"]
voice = ["sounddevice>=0.4.6,<1", "numpy>=1.24.0,<3"] 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.
"faster-whisper>=1.0.0,<2",
"sounddevice>=0.4.6,<1",
"numpy>=1.24.0,<3",
]
pty = [ pty = [
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'", "ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
"pywinpty>=2.0.0,<3; sys_platform == 'win32'", "pywinpty>=2.0.0,<3; sys_platform == 'win32'",

View File

@@ -24,6 +24,7 @@ import argparse
import json import json
import os import os
import re import re
import shutil
import subprocess import subprocess
import sys import sys
from collections import defaultdict from collections import defaultdict
@@ -128,6 +129,16 @@ def git(*args, cwd=None):
return result.stdout.strip() return result.stdout.strip()
def git_result(*args, cwd=None):
"""Run a git command and return the full CompletedProcess."""
return subprocess.run(
["git"] + list(args),
capture_output=True,
text=True,
cwd=cwd or str(REPO_ROOT),
)
def get_last_tag(): def get_last_tag():
"""Get the most recent CalVer tag.""" """Get the most recent CalVer tag."""
tags = git("tag", "--list", "v20*", "--sort=-v:refname") tags = git("tag", "--list", "v20*", "--sort=-v:refname")
@@ -136,6 +147,18 @@ def get_last_tag():
return None return None
def next_available_tag(base_tag: str) -> tuple[str, str]:
"""Return a tag/calver pair, suffixing same-day releases when needed."""
if not git("tag", "--list", base_tag):
return base_tag, base_tag.removeprefix("v")
suffix = 2
while git("tag", "--list", f"{base_tag}.{suffix}"):
suffix += 1
tag_name = f"{base_tag}.{suffix}"
return tag_name, tag_name.removeprefix("v")
def get_current_version(): def get_current_version():
"""Read current semver from __init__.py.""" """Read current semver from __init__.py."""
content = VERSION_FILE.read_text() content = VERSION_FILE.read_text()
@@ -192,6 +215,41 @@ def update_version_files(semver: str, calver_date: str):
PYPROJECT_FILE.write_text(pyproject) PYPROJECT_FILE.write_text(pyproject)
def build_release_artifacts(semver: str) -> list[Path]:
"""Build sdist/wheel artifacts for the current release.
Returns the artifact paths when the local environment has ``python -m build``
available. If build tooling is missing or the build fails, returns an empty
list and lets the release proceed without attached Python artifacts.
"""
dist_dir = REPO_ROOT / "dist"
shutil.rmtree(dist_dir, ignore_errors=True)
result = subprocess.run(
[sys.executable, "-m", "build", "--sdist", "--wheel"],
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 the 'build' package to attach semver-named 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: def resolve_author(name: str, email: str) -> str:
"""Resolve a git author to a GitHub @mention.""" """Resolve a git author to a GitHub @mention."""
# Try email lookup first # Try email lookup first
@@ -424,18 +482,10 @@ def main():
now = datetime.now() now = datetime.now()
calver_date = f"{now.year}.{now.month}.{now.day}" calver_date = f"{now.year}.{now.month}.{now.day}"
tag_name = f"v{calver_date}" base_tag = f"v{calver_date}"
tag_name, calver_date = next_available_tag(base_tag)
# Check for existing tag with same date if tag_name != base_tag:
existing = git("tag", "--list", tag_name) print(f"Note: Tag {base_tag} already exists, using {tag_name}")
if existing and not args.publish:
# Append a suffix for same-day releases
suffix = 2
while git("tag", "--list", f"{tag_name}.{suffix}"):
suffix += 1
tag_name = f"{tag_name}.{suffix}"
calver_date = f"{calver_date}.{suffix}"
print(f"Note: Tag {tag_name[:-2]} already exists, using {tag_name}")
# Determine semver # Determine semver
current_version = get_current_version() current_version = get_current_version()
@@ -494,41 +544,83 @@ def main():
print(f" ✓ Updated version files to v{new_version} ({calver_date})") print(f" ✓ Updated version files to v{new_version} ({calver_date})")
# Commit version bump # Commit version bump
git("add", str(VERSION_FILE), str(PYPROJECT_FILE)) add_result = git_result("add", str(VERSION_FILE), str(PYPROJECT_FILE))
git("commit", "-m", f"chore: bump version to v{new_version} ({calver_date})") if add_result.returncode != 0:
print(f" ✗ Failed to stage version files: {add_result.stderr.strip()}")
return
commit_result = git_result(
"commit", "-m", f"chore: bump version to v{new_version} ({calver_date})"
)
if commit_result.returncode != 0:
print(f" ✗ Failed to commit version bump: {commit_result.stderr.strip()}")
return
print(f" ✓ Committed version bump") print(f" ✓ Committed version bump")
# Create annotated tag # Create annotated tag
git("tag", "-a", tag_name, "-m", tag_result = git_result(
f"Hermes Agent v{new_version} ({calver_date})\n\nWeekly release") "tag", "-a", tag_name, "-m",
f"Hermes Agent v{new_version} ({calver_date})\n\nWeekly release"
)
if tag_result.returncode != 0:
print(f" ✗ Failed to create tag {tag_name}: {tag_result.stderr.strip()}")
return
print(f" ✓ Created tag {tag_name}") print(f" ✓ Created tag {tag_name}")
# Push # Push
push_result = git("push", "origin", "HEAD", "--tags") push_result = git_result("push", "origin", "HEAD", "--tags")
print(f" ✓ Pushed to origin") if push_result.returncode == 0:
print(f" ✓ Pushed to origin")
else:
print(f" ✗ Failed to push to origin: {push_result.stderr.strip()}")
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 # Create GitHub release
changelog_file = REPO_ROOT / ".release_notes.md" changelog_file = REPO_ROOT / ".release_notes.md"
changelog_file.write_text(changelog) changelog_file.write_text(changelog)
result = subprocess.run( gh_cmd = [
["gh", "release", "create", tag_name, "gh", "release", "create", tag_name,
"--title", f"Hermes Agent v{new_version} ({calver_date})", "--title", f"Hermes Agent v{new_version} ({calver_date})",
"--notes-file", str(changelog_file)], "--notes-file", str(changelog_file),
capture_output=True, text=True, ]
cwd=str(REPO_ROOT), gh_cmd.extend(str(path) for path in artifacts)
)
changelog_file.unlink(missing_ok=True) gh_bin = shutil.which("gh")
if gh_bin:
if result.returncode == 0: result = subprocess.run(
print(f" ✓ GitHub release created: {result.stdout.strip()}") gh_cmd,
capture_output=True, text=True,
cwd=str(REPO_ROOT),
)
else: else:
print(f" ✗ GitHub release failed: {result.stderr}") result = None
print(f" Tag was created. Create the release manually:")
print(f" gh release create {tag_name} --title 'Hermes Agent v{new_version} ({calver_date})'")
print(f"\n 🎉 Release v{new_version} ({tag_name}) published!") if result and result.returncode == 0:
changelog_file.unlink(missing_ok=True)
print(f" ✓ GitHub release created: {result.stdout.strip()}")
print(f"\n 🎉 Release v{new_version} ({tag_name}) published!")
else:
if result is None:
print(" ✗ GitHub release skipped: `gh` CLI not found.")
else:
print(f" ✗ GitHub release failed: {result.stderr.strip()}")
print(f" Release notes kept at: {changelog_file}")
print(f" 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)}"
)
print(f"\n ✓ Release artifacts prepared for manual publish: v{new_version} ({tag_name})")
else: else:
print(f"\n{'='*60}") print(f"\n{'='*60}")
print(f" Dry run complete. To publish, add --publish") print(f" Dry run complete. To publish, add --publish")

View File

@@ -45,6 +45,17 @@ def _make_runner():
class TestHandleUpdateCommand: class TestHandleUpdateCommand:
"""Tests for GatewayRunner._handle_update_command.""" """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")
result = await runner._handle_update_command(event)
assert "managed by Homebrew" in result
assert "brew upgrade hermes-agent" in result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_git_directory(self, tmp_path): async def test_no_git_directory(self, tmp_path):
"""Returns an error when .git does not exist.""" """Returns an error when .git does not exist."""

View File

@@ -0,0 +1,54 @@
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.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)
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()
monkeypatch.setenv("HERMES_OPTIONAL_SKILLS", str(optional_dir))
source = OptionalSkillSource()
assert source._optional_dir == optional_dir

View File

@@ -0,0 +1,22 @@
from pathlib import Path
import tomllib
REPO_ROOT = Path(__file__).resolve().parents[1]
def test_faster_whisper_is_not_a_base_dependency():
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
deps = data["project"]["dependencies"]
assert not any(dep.startswith("faster-whisper") for dep in deps)
voice_extra = data["project"]["optional-dependencies"]["voice"]
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

View File

@@ -2115,7 +2115,11 @@ class OptionalSkillSource(SkillSource):
""" """
def __init__(self): def __init__(self):
self._optional_dir = Path(__file__).parent.parent / "optional-skills" from hermes_constants import get_optional_skills_dir
self._optional_dir = get_optional_skills_dir(
Path(__file__).parent.parent / "optional-skills"
)
def source_id(self) -> str: def source_id(self) -> str:
return "official" return "official"