From e64b047663a0ff95753a1bf930036e6ccca43bd2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:34:43 -0700 Subject: [PATCH] chore: prepare Hermes for Homebrew packaging (#4099) Co-authored-by: Yabuku-xD <78594762+Yabuku-xD@users.noreply.github.com> --- MANIFEST.in | 4 + gateway/run.py | 8 +- hermes_cli/banner.py | 3 +- hermes_cli/claw.py | 4 +- hermes_cli/config.py | 82 +++++++++-- hermes_cli/main.py | 11 +- hermes_cli/plugins_cmd.py | 3 +- hermes_cli/setup.py | 5 +- hermes_constants.py | 14 ++ packaging/homebrew/README.md | 14 ++ packaging/homebrew/hermes-agent.rb | 48 +++++++ pyproject.toml | 9 +- scripts/release.py | 158 +++++++++++++++++----- tests/gateway/test_update_command.py | 11 ++ tests/hermes_cli/test_managed_installs.py | 54 ++++++++ tests/test_packaging_metadata.py | 22 +++ tools/skills_hub.py | 6 +- 17 files changed, 400 insertions(+), 56 deletions(-) create mode 100644 MANIFEST.in create mode 100644 packaging/homebrew/README.md create mode 100644 packaging/homebrew/hermes-agent.rb create mode 100644 tests/hermes_cli/test_managed_installs.py create mode 100644 tests/test_packaging_metadata.py diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..876aeeb7d --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +graft skills +graft optional-skills +global-exclude __pycache__ +global-exclude *.py[cod] diff --git a/gateway/run.py b/gateway/run.py index c42510709..0b5e3a1b4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -325,9 +325,9 @@ def _check_unavailable_skill(command_name: str) -> str | None: ) # 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 - optional_dir = repo_root / "optional-skills" + optional_dir = get_optional_skills_dir(repo_root / "optional-skills") if optional_dir.exists(): for skill_md in optional_dir.rglob("SKILL.md"): name = skill_md.parent.name.lower().replace("_", "-") @@ -4695,6 +4695,10 @@ class GatewayRunner: import shutil import subprocess 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() git_dir = project_root / '.git' diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 5ecc94acf..7435750bc 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -432,10 +432,11 @@ def build_welcome_banner(console: Console, model: str, cwd: str, try: behind = get_update_result(timeout=0.5) if behind and behind > 0: + from hermes_cli.config import recommended_update_command commits_word = "commit" if behind == 1 else "commits" right_lines.append( 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: pass # Never break the banner over an update check diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index 014a2abeb..b3b624dc5 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -12,6 +12,7 @@ import sys from pathlib import Path 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 ( Colors, color, @@ -27,8 +28,7 @@ logger = logging.getLogger(__name__) PROJECT_ROOT = Path(__file__).parent.parent.resolve() _OPENCLAW_SCRIPT = ( - PROJECT_ROOT - / "optional-skills" + get_optional_skills_dir(PROJECT_ROOT / "optional-skills") / "migration" / "openclaw-migration" / "scripts" diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 56d102692..f7ae4239d 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -52,26 +52,86 @@ from hermes_cli.default_soul import DEFAULT_SOUL_MD # 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: - """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), or a .managed marker file in HERMES_HOME (set by the NixOS activation script, so interactive shells also see it). """ - if os.getenv("HERMES_MANAGED", "").lower() in ("true", "1", "yes"): - return True - managed_marker = get_hermes_home() / ".managed" - return managed_marker.exists() + return get_managed_system() is not None + + +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"): """Print user-friendly error for managed mode.""" - print( - 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, - ) + print(format_managed_message(action), file=sys.stderr) # ============================================================================= diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f6d7d7c71..64fc455cd 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2467,10 +2467,14 @@ def cmd_version(args): # Show update status (synchronous — acceptable since user asked for version info) try: from hermes_cli.banner import check_for_updates + from hermes_cli.config import recommended_update_command behind = check_for_updates() if behind and behind > 0: 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: print("Up to date") except Exception: @@ -2821,6 +2825,11 @@ def _invalidate_update_cache(): def cmd_update(args): """Update Hermes Agent to the latest version.""" 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() diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index e53f5c94b..c3717bfa3 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -265,10 +265,11 @@ def cmd_install(identifier: str, force: bool = False) -> None: ) sys.exit(1) if mv_int > _SUPPORTED_MANIFEST_VERSION: + from hermes_cli.config import recommended_update_command console.print( f"[red]Error:[/red] Plugin '{plugin_name}' requires manifest_version " 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) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 304f34f56..503c2bcde 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -18,6 +18,8 @@ import sys from pathlib import Path from typing import Optional, Dict, Any +from hermes_constants import get_optional_skills_dir + logger = logging.getLogger(__name__) PROJECT_ROOT = Path(__file__).parent.parent.resolve() @@ -3121,8 +3123,7 @@ def _skip_configured_section( _OPENCLAW_SCRIPT = ( - PROJECT_ROOT - / "optional-skills" + get_optional_skills_dir(PROJECT_ROOT / "optional-skills") / "migration" / "openclaw-migration" / "scripts" diff --git a/hermes_constants.py b/hermes_constants.py index 2bfc0a8c7..c28f6dc8f 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -17,6 +17,20 @@ def get_hermes_home() -> Path: 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: """Resolve a Hermes subdirectory with backward compatibility. diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md new file mode 100644 index 000000000..e53d3fd0b --- /dev/null +++ b/packaging/homebrew/README.md @@ -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`. diff --git a/packaging/homebrew/hermes-agent.rb b/packaging/homebrew/hermes-agent.rb new file mode 100644 index 000000000..7c00fc6ac --- /dev/null +++ b/packaging/homebrew/hermes-agent.rb @@ -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 "" + 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/pyproject.toml b/pyproject.toml index c3154d1ae..3cf339845 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,6 @@ dependencies = [ "fal-client>=0.13.1,<1", # Text-to-speech (Edge TTS is free, no API key needed) "edge-tts>=7.2.7,<8", - "faster-whisper>=1.0.0,<2", # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) "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"] cli = ["simple-term-menu>=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 = [ "ptyprocess>=0.7.0,<1; sys_platform != 'win32'", "pywinpty>=2.0.0,<3; sys_platform == 'win32'", diff --git a/scripts/release.py b/scripts/release.py index cafb30321..cfe360064 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -24,6 +24,7 @@ import argparse import json import os import re +import shutil import subprocess import sys from collections import defaultdict @@ -128,6 +129,16 @@ def git(*args, cwd=None): 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(): """Get the most recent CalVer tag.""" tags = git("tag", "--list", "v20*", "--sort=-v:refname") @@ -136,6 +147,18 @@ def get_last_tag(): 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(): """Read current semver from __init__.py.""" content = VERSION_FILE.read_text() @@ -192,6 +215,41 @@ def update_version_files(semver: str, calver_date: str): 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: """Resolve a git author to a GitHub @mention.""" # Try email lookup first @@ -424,18 +482,10 @@ def main(): now = datetime.now() calver_date = f"{now.year}.{now.month}.{now.day}" - tag_name = f"v{calver_date}" - - # Check for existing tag with same date - existing = git("tag", "--list", 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}") + base_tag = f"v{calver_date}" + tag_name, calver_date = next_available_tag(base_tag) + if tag_name != base_tag: + print(f"Note: Tag {base_tag} already exists, using {tag_name}") # Determine semver current_version = get_current_version() @@ -494,41 +544,83 @@ def main(): print(f" ✓ Updated version files to v{new_version} ({calver_date})") # Commit version bump - git("add", str(VERSION_FILE), str(PYPROJECT_FILE)) - git("commit", "-m", f"chore: bump version to v{new_version} ({calver_date})") + add_result = git_result("add", str(VERSION_FILE), str(PYPROJECT_FILE)) + 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") # Create annotated tag - git("tag", "-a", tag_name, "-m", - f"Hermes Agent v{new_version} ({calver_date})\n\nWeekly release") + tag_result = git_result( + "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}") # Push - push_result = git("push", "origin", "HEAD", "--tags") - print(f" ✓ Pushed to origin") + push_result = git_result("push", "origin", "HEAD", "--tags") + 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 changelog_file = REPO_ROOT / ".release_notes.md" changelog_file.write_text(changelog) - result = subprocess.run( - ["gh", "release", "create", tag_name, - "--title", f"Hermes Agent v{new_version} ({calver_date})", - "--notes-file", str(changelog_file)], - capture_output=True, text=True, - cwd=str(REPO_ROOT), - ) + gh_cmd = [ + "gh", "release", "create", tag_name, + "--title", f"Hermes Agent v{new_version} ({calver_date})", + "--notes-file", str(changelog_file), + ] + gh_cmd.extend(str(path) for path in artifacts) - changelog_file.unlink(missing_ok=True) - - if result.returncode == 0: - print(f" ✓ GitHub release created: {result.stdout.strip()}") + gh_bin = shutil.which("gh") + if gh_bin: + result = subprocess.run( + gh_cmd, + capture_output=True, text=True, + cwd=str(REPO_ROOT), + ) else: - print(f" ✗ GitHub release failed: {result.stderr}") - print(f" Tag was created. Create the release manually:") - print(f" gh release create {tag_name} --title 'Hermes Agent v{new_version} ({calver_date})'") + result = None - 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: print(f"\n{'='*60}") print(f" Dry run complete. To publish, add --publish") diff --git a/tests/gateway/test_update_command.py b/tests/gateway/test_update_command.py index ac9beac1b..e8fb3ddc1 100644 --- a/tests/gateway/test_update_command.py +++ b/tests/gateway/test_update_command.py @@ -45,6 +45,17 @@ 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") + + result = await runner._handle_update_command(event) + + assert "managed by Homebrew" in result + assert "brew upgrade hermes-agent" in result + @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_managed_installs.py b/tests/hermes_cli/test_managed_installs.py new file mode 100644 index 000000000..c6b5d792c --- /dev/null +++ b/tests/hermes_cli/test_managed_installs.py @@ -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 diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py new file mode 100644 index 000000000..ce6d4793f --- /dev/null +++ b/tests/test_packaging_metadata.py @@ -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 diff --git a/tools/skills_hub.py b/tools/skills_hub.py index a824c3e3b..c818261d7 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -2115,7 +2115,11 @@ class OptionalSkillSource(SkillSource): """ 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: return "official"