Date: Thu, 07 May 2026 08:28:04 +0000 From: Olivier Cochard <olivier@FreeBSD.org> To: ports-committers@FreeBSD.org, dev-commits-ports-all@FreeBSD.org, dev-commits-ports-main@FreeBSD.org Subject: git: 5dbeba4008f9 - main - misc/hermes-age=?utf-8?Q?nt:=E2=80=AFNe?=w port Message-ID: <69fc4d14.1a11f.1c4d6998@gitrepo.freebsd.org>
index | next in thread | raw e-mail
The branch main has been updated by olivier: URL: https://cgit.FreeBSD.org/ports/commit/?id=5dbeba4008f9d546c7b4991b8e8cb9534d7614c6 commit 5dbeba4008f9d546c7b4991b8e8cb9534d7614c6 Author: Olivier Cochard <olivier@FreeBSD.org> AuthorDate: 2026-05-06 23:15:53 +0000 Commit: Olivier Cochard <olivier@FreeBSD.org> CommitDate: 2026-05-07 08:26:42 +0000 misc/hermes-agent: New port AI agent with built-in learning loop --- misc/Makefile | 1 + misc/hermes-agent/Makefile | 183 +++++++++++++ misc/hermes-agent/distinfo | 5 + misc/hermes-agent/files/hermes_dashboard.in | 89 +++++++ misc/hermes-agent/files/hermes_gateway.in | 101 ++++++++ .../files/patch-hermes__cli_gateway.py | 284 +++++++++++++++++++++ misc/hermes-agent/files/patch-hermes__cli_main.py | 66 +++++ misc/hermes-agent/files/patch-hermes__cli_setup.py | 95 +++++++ .../files/patch-hermes__cli_uninstall.py | 35 +++ misc/hermes-agent/files/pkg-message.in | 25 ++ misc/hermes-agent/files/wrapper.in | 13 + misc/hermes-agent/pkg-descr | 22 ++ 12 files changed, 919 insertions(+) diff --git a/misc/Makefile b/misc/Makefile index ce54990634cd..8913eeae2c67 100644 --- a/misc/Makefile +++ b/misc/Makefile @@ -206,6 +206,7 @@ SUBDIR += hashdb SUBDIR += hello SUBDIR += help2man + SUBDIR += hermes-agent SUBDIR += heyu2 SUBDIR += hicolor-icon-theme SUBDIR += histring diff --git a/misc/hermes-agent/Makefile b/misc/hermes-agent/Makefile new file mode 100644 index 000000000000..68032e3a5ce6 --- /dev/null +++ b/misc/hermes-agent/Makefile @@ -0,0 +1,183 @@ +PORTNAME= hermes-agent +PORTVERSION= 0.12.0 +CATEGORIES= misc python +MASTER_SITES+= LOCAL/olivier:webcache +DISTFILES+= ${PORTNAME}-web-offline-cache-${PORTVERSION}${EXTRACT_SUFX}:webcache + +MAINTAINER= olivier@FreeBSD.org +COMMENT= AI agent with built-in learning loop +WWW= https://github.com/NousResearch/hermes-agent + +LICENSE= MIT +LICENSE_FILE= ${WRKSRC}/LICENSE + +BUILD_DEPENDS= npm:www/npm +RUN_DEPENDS= ${PYTHON_PKGNAMEPREFIX}anthropic>=0.39.0:misc/py-anthropic@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}croniter>=6.0.0:sysutils/py-croniter@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}edge-tts>=7.2.7:audio/py-edge-tts@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}exa-py>=2.9.0:www/py-exa-py@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}fal-client>=0.13.1:misc/py-fal-client@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}fastapi>=0.104.0:www/py-fastapi@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}fire>=0.7.0:devel/py-fire@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}firecrawl-py>=4.16.0:www/py-firecrawl-py@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}httpx>=0.28.1:www/py-httpx@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}Jinja2>=3.1.5:devel/py-Jinja2@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}openai>=2.21.0:misc/py-openai@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}parallel-web>=0.4.2:www/py-parallel-web@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}prompt-toolkit>=3.0.52:devel/py-prompt-toolkit@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}pydantic2>=2.12.5:devel/py-pydantic2@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}pyjwt>=2.12.0:www/py-pyjwt@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}pysocks>0:net/py-pysocks@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}python-dotenv>=1.2.1:www/py-python-dotenv@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}pyyaml>=6.0.2:devel/py-pyyaml@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}requests>=2.33.0:www/py-requests@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}rich>=14.3.3:textproc/py-rich@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}socksio>0:net/py-socksio@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}tenacity>=9.1.4:devel/py-tenacity@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}uvicorn>=0.24.0:www/py-uvicorn@${PY_FLAVOR} + +USES= python:3.11+,run shebangfix nodejs:lts,build +USE_GITHUB= yes +GH_ACCOUNT= NousResearch +GH_PROJECT= hermes-agent +GH_TAGNAME= v2026.4.30 + +USE_RC_SUBR= hermes_dashboard hermes_gateway + +SUB_FILES= pkg-message + +NO_ARCH= yes + +# Hermes is an application, not a Python library. Upstream's Dockerfile, +# Nix flake, and Homebrew formula all install it into a private directory +# (/opt/hermes, the Nix store, libexec/ respectively) rather than into +# site-packages, because the project ships top-level packages with generic +# names (tools, agent, gateway, plugins, ...) and bare modules (cli.py, +# utils.py, ...) that would collide with other Python packages. We follow +# the same convention: install the source tree under HERMES_LIBDIR and +# create thin wrapper scripts in ${PREFIX}/bin that inject HERMES_LIBDIR +# into sys.path before calling each entry point. +HERMES_LIBDIR= ${PREFIX}/lib/${PORTNAME} + +PLIST_SUB+= HERMES_LIBDIR=${HERMES_LIBDIR:S,^${PREFIX}/,,} + +# Web dashboard SPA (Vite/React) — upstream's release tarball does NOT ship +# a prebuilt web_dist/, only the source under web/. We bring our own npm +# offline mirror as a second distfile (LOCAL/<committer>:webcache) and run +# `npm ci --offline && npm run build` in do-build to produce +# hermes_cli/web_dist/, which the dashboard serves at runtime +# (web_server.py defaults to ${HERMES_LIBDIR}/hermes_cli/web_dist). +# +# How to (re)generate hermes-agent-web-offline-cache-${PORTVERSION}.tar.gz +# on every PORTVERSION bump (run on a connected host with npm 10+ installed): +# +# 1. Extract the upstream source tarball: +# tar xzf ${DISTDIR}/NousResearch-hermes-agent-${PORTVERSION}-${GH_TAGNAME}_GH0.tar.gz +# cd hermes-agent-*/web +# 2. Populate a fresh npm cache from web/package-lock.json: +# rm -rf /tmp/hermes-cache && mkdir -p /tmp/hermes-cache +# HOME=/tmp npm_config_cache=/tmp/hermes-cache \ +# npm ci --no-audit --no-fund --prefer-offline +# 3. Strip non-deterministic bits (logs, last-checked stamps): +# rm -rf /tmp/hermes-cache/_logs /tmp/hermes-cache/_update-notifier-last-checked +# 4. Repackage with a top-level dir whose name matches the distfile: +# mv /tmp/hermes-cache /tmp/${PORTNAME}-web-offline-cache-${PORTVERSION} +# cd /tmp && tar --no-acls --no-xattrs --no-fflags --uid=0 --gid=0 \ +# -czf ${PORTNAME}-web-offline-cache-${PORTVERSION}.tar.gz \ +# ${PORTNAME}-web-offline-cache-${PORTVERSION} +# 5. Upload to LOCAL/<committer>'s distcache directory and drop a copy +# into ${DISTDIR} so `make makesum` picks it up locally. +# 6. cd ${.CURDIR} && make makesum +# +# `npm ci --offline` in do-build refuses any registry call, so a missing +# entry in the mirror fails fast instead of silently going to the network. +# npm reads cacache content from ${npm_config_cache}/_cacache. Point +# npm_config_cache at the *parent* of the _cacache/ tree we shipped — if +# you point it at _cacache/ directly, npm appends _cacache/ a second time +# and silently sees an empty cache (Index entries: 0), then fails every +# install with ENOTCACHED. +WEB_CACHE_DIR= ${WRKDIR}/${PORTNAME}-web-offline-cache-${PORTVERSION} +WEB_NPM_ENV= HOME=${WRKDIR} \ + npm_config_cache=${WEB_CACHE_DIR} \ + npm_config_update_notifier=false \ + npm_config_audit=false \ + npm_config_fund=false + +# Python packages and bare modules that constitute the runtime app. +HERMES_PKGS= acp_adapter agent cron gateway hermes_cli plugins tools tui_gateway +HERMES_MODS= batch_runner.py cli.py hermes_constants.py hermes_logging.py \ + hermes_state.py hermes_time.py model_tools.py rl_cli.py \ + run_agent.py toolset_distributions.py toolsets.py \ + trajectory_compressor.py utils.py + +SHEBANG_FILES= ${HERMES_MODS} + +PORTDOCS= README.md SECURITY.md CONTRIBUTING.md AGENTS.md +OPTIONS_DEFINE= DOCS + +PLIST_FILES= "@(,,0755) bin/hermes" \ + "@(,,0755) bin/hermes-agent" \ + "@(,,0755) bin/hermes-acp" + +# Build the web dashboard SPA from the offline npm mirror. npm reads its +# package cache from npm_config_cache; --offline forbids any network call +# so a missing dep fails fast instead of silently going to the registry. +# The vite config writes the bundle to ../hermes_cli/web_dist (relative to +# web/), which is then picked up by do-install below. +do-build: + cd ${WRKSRC}/web && \ + ${SETENV} ${WEB_NPM_ENV} \ + npm ci --offline --no-audit --no-fund + cd ${WRKSRC}/web && \ + ${SETENV} ${WEB_NPM_ENV} \ + npm run build + +do-install: + ${MKDIR} ${STAGEDIR}${HERMES_LIBDIR} +.for d in ${HERMES_PKGS} + cd ${WRKSRC} && ${COPYTREE_SHARE} ${d} ${STAGEDIR}${HERMES_LIBDIR} \ + "! -name __pycache__ ! -name *.pyc" +.endfor +.for f in ${HERMES_MODS} + ${INSTALL_DATA} ${WRKSRC}/${f} ${STAGEDIR}${HERMES_LIBDIR} +.endfor + ${MKDIR} ${STAGEDIR}${PREFIX}/bin + ${SED} -e 's|%%HERMES_LIBDIR%%|${HERMES_LIBDIR}|g' \ + -e 's|%%PYTHON_CMD%%|${PYTHON_CMD}|g' \ + -e 's|%%ENTRY_MODULE%%|hermes_cli.main|g' \ + -e 's|%%ENTRY_FUNC%%|main|g' \ + ${FILESDIR}/wrapper.in > ${STAGEDIR}${PREFIX}/bin/hermes + ${SED} -e 's|%%HERMES_LIBDIR%%|${HERMES_LIBDIR}|g' \ + -e 's|%%PYTHON_CMD%%|${PYTHON_CMD}|g' \ + -e 's|%%ENTRY_MODULE%%|run_agent|g' \ + -e 's|%%ENTRY_FUNC%%|main|g' \ + ${FILESDIR}/wrapper.in > ${STAGEDIR}${PREFIX}/bin/hermes-agent + ${SED} -e 's|%%HERMES_LIBDIR%%|${HERMES_LIBDIR}|g' \ + -e 's|%%PYTHON_CMD%%|${PYTHON_CMD}|g' \ + -e 's|%%ENTRY_MODULE%%|acp_adapter.entry|g' \ + -e 's|%%ENTRY_FUNC%%|main|g' \ + ${FILESDIR}/wrapper.in > ${STAGEDIR}${PREFIX}/bin/hermes-acp + ${MKDIR} ${STAGEDIR}${DATADIR} + cd ${WRKSRC} && ${COPYTREE_SHARE} skills ${STAGEDIR}${DATADIR} + cd ${WRKSRC} && ${COPYTREE_SHARE} optional-skills ${STAGEDIR}${DATADIR} + +# Walk the staged HERMES_LIBDIR and DATADIR trees and append every file +# (and every directory we created) to the plist. This avoids hand- +# maintaining a 500-line pkg-plist for skill templates that change every +# release. +post-install: + @cd ${STAGEDIR}${PREFIX} && \ + ${FIND} ${HERMES_LIBDIR:S,^${PREFIX}/,,} ${DATADIR:S,^${PREFIX}/,,} \ + -type f >> ${TMPPLIST} + @cd ${STAGEDIR}${PREFIX} && \ + ${FIND} ${HERMES_LIBDIR:S,^${PREFIX}/,,} ${DATADIR:S,^${PREFIX}/,,} \ + -type d -mindepth 1 | ${SORT} -r | \ + ${SED} 's|^|@dir |' >> ${TMPPLIST} + +post-install-DOCS-on: + ${MKDIR} ${STAGEDIR}${DOCSDIR} +.for f in ${PORTDOCS} + ${INSTALL_DATA} ${WRKSRC}/${f} ${STAGEDIR}${DOCSDIR} +.endfor + +.include <bsd.port.mk> diff --git a/misc/hermes-agent/distinfo b/misc/hermes-agent/distinfo new file mode 100644 index 000000000000..9238c06174f8 --- /dev/null +++ b/misc/hermes-agent/distinfo @@ -0,0 +1,5 @@ +TIMESTAMP = 1778071003 +SHA256 (hermes-agent-web-offline-cache-0.12.0.tar.gz) = b176b6ce7de35e7720d6c3363911944cfb9eca2c8e4bd1af621207d45f0ad225 +SIZE (hermes-agent-web-offline-cache-0.12.0.tar.gz) = 50966122 +SHA256 (NousResearch-hermes-agent-0.12.0-v2026.4.30_GH0.tar.gz) = 3743db721cf6c93631f8446bdc8b77fd53e0c439ee8c42ec821ebfd6874c3949 +SIZE (NousResearch-hermes-agent-0.12.0-v2026.4.30_GH0.tar.gz) = 18882013 diff --git a/misc/hermes-agent/files/hermes_dashboard.in b/misc/hermes-agent/files/hermes_dashboard.in new file mode 100644 index 000000000000..bdf958b0b889 --- /dev/null +++ b/misc/hermes-agent/files/hermes_dashboard.in @@ -0,0 +1,89 @@ +#!/bin/sh + +# PROVIDE: hermes_dashboard +# REQUIRE: LOGIN NETWORKING +# KEYWORD: shutdown +# +# Add the following lines to /etc/rc.conf to enable hermes_dashboard: +# +# hermes_dashboard_enable="YES" +# hermes_dashboard_user="hermes" # REQUIRED: account whose ~/.hermes is used +# hermes_dashboard_host="127.0.0.1" # OPTIONAL: bind host (default 127.0.0.1) +# hermes_dashboard_port="9119" # OPTIONAL: bind port (default 9119) +# hermes_dashboard_profile="" # OPTIONAL: -p <profile> +# hermes_dashboard_args="" # OPTIONAL: extra args passed to `hermes dashboard` +# # e.g. "--insecure" (DANGEROUS — exposes API keys) +# # or "--tui" (in-browser chat) +# +# NOTE: do NOT use ${name}_flags for extra args. rc.subr reserves *_flags +# for the *command* (i.e. daemon(8)) and will inject them ahead of daemon's +# own options, which causes daemon to fail with "unrecognized option". +# Use hermes_dashboard_args instead — it is forwarded to `hermes dashboard`. +# +# WARNING: --insecure binds the dashboard to all interfaces and exposes +# the configured provider API keys on the network. Only use behind a +# trusted reverse proxy with auth. Default binding is 127.0.0.1 only. + +. /etc/rc.subr + +name="hermes_dashboard" +rcvar="hermes_dashboard_enable" + +load_rc_config $name + +: ${hermes_dashboard_enable:="NO"} +: ${hermes_dashboard_user:=""} +: ${hermes_dashboard_host:="127.0.0.1"} +: ${hermes_dashboard_port:="9119"} +: ${hermes_dashboard_profile:=""} +: ${hermes_dashboard_args:=""} + +# Back-compat: if someone set the legacy *_flags var, honor it but warn. +if [ -n "${hermes_dashboard_flags}" ] && [ -z "${hermes_dashboard_args}" ]; then + warn "hermes_dashboard_flags is deprecated (it collides with rc.subr); use hermes_dashboard_args" + hermes_dashboard_args="${hermes_dashboard_flags}" +fi +# Suppress rc.subr's automatic *_flags injection — we route extras via +# hermes_dashboard_args into the inner command instead. +hermes_dashboard_flags="" + +if [ -n "${hermes_dashboard_user}" ]; then + hermes_dashboard_home=$(getent passwd "${hermes_dashboard_user}" 2>/dev/null | cut -d: -f6) + if [ -z "${hermes_dashboard_home}" ]; then + hermes_dashboard_home=$(eval echo "~${hermes_dashboard_user}") + fi +else + hermes_dashboard_home="" +fi + +piddir="/var/run/${name}" +pidfile="${piddir}/${name}.pid" + +command="/usr/sbin/daemon" +# Note: do NOT pass `-u ${hermes_dashboard_user}` to daemon. rc.subr already +# drops privileges to ${name}_user via su(1) before exec, so adding -u here +# causes daemon to call initgroups() as a non-root user → EPERM and a tight +# restart loop with `-r`. +command_args="-f -r -P ${pidfile} -S -T ${name} \ + /usr/bin/env HOME=${hermes_dashboard_home} \ + %%PREFIX%%/bin/hermes ${hermes_dashboard_profile:+-p ${hermes_dashboard_profile}} \ + dashboard --no-open --host ${hermes_dashboard_host} --port ${hermes_dashboard_port} \ + ${hermes_dashboard_args}" + +required_files="%%PREFIX%%/bin/hermes" + +start_precmd="hermes_dashboard_prestart" +hermes_dashboard_prestart() +{ + if [ -z "${hermes_dashboard_user}" ]; then + err 1 "hermes_dashboard_user is not set in rc.conf — refusing to start" + fi + if [ -z "${hermes_dashboard_home}" ] || [ ! -d "${hermes_dashboard_home}" ]; then + err 1 "home directory for user '${hermes_dashboard_user}' not found" + fi + # piddir must be writable by the unprivileged user since daemon(8) + # drops privileges (-u) before writing the supervisor pidfile (-P). + install -d -m 0755 -o "${hermes_dashboard_user}" "${piddir}" +} + +run_rc_command "$1" diff --git a/misc/hermes-agent/files/hermes_gateway.in b/misc/hermes-agent/files/hermes_gateway.in new file mode 100644 index 000000000000..06d23d772091 --- /dev/null +++ b/misc/hermes-agent/files/hermes_gateway.in @@ -0,0 +1,101 @@ +#!/bin/sh + +# PROVIDE: hermes_gateway +# REQUIRE: LOGIN NETWORKING +# KEYWORD: shutdown +# +# Add the following lines to /etc/rc.conf to enable hermes_gateway: +# +# hermes_gateway_enable="YES" +# hermes_gateway_user="hermes" # REQUIRED: account whose ~/.hermes is used +# hermes_gateway_profile="" # OPTIONAL: -p <profile> for multi-profile setups +# hermes_gateway_args="" # OPTIONAL: extra args for `hermes gateway run` +# # e.g. "-v" for INFO logs, "-vv" for DEBUG +# +# NOTE: do NOT use ${name}_flags for extra args. rc.subr reserves *_flags +# for the *command* (i.e. daemon(8)) and will inject them ahead of daemon's +# own options, which causes daemon to fail with "invalid option". +# Use hermes_gateway_args instead — it is forwarded to `hermes gateway run`. +# +# Notes: +# * Hermes stores all state under $HOME/.hermes (config, sessions, logs, +# credentials). This script runs the gateway as ${hermes_gateway_user} +# so ~/.hermes resolves to that user's home directory. There is no +# sane "root" default — running as root would target /root/.hermes +# which is almost never what the operator wants. +# * The gateway logs to ${HOME}/.hermes/agent.log via Hermes' own logging. +# daemon(8)'s syslog redirection captures any stray stderr to +# /var/log/messages with tag "hermes_gateway". +# * Crash recovery: daemon(8) -r restarts on non-zero exit. Hermes' own +# drain-restart (exit code 75) is handled the same way. + +. /etc/rc.subr + +name="hermes_gateway" +rcvar="hermes_gateway_enable" + +load_rc_config $name + +: ${hermes_gateway_enable:="NO"} +: ${hermes_gateway_user:=""} +: ${hermes_gateway_profile:=""} +: ${hermes_gateway_args:=""} + +# Back-compat: if someone set the legacy *_flags var, honor it but warn. +if [ -n "${hermes_gateway_flags}" ] && [ -z "${hermes_gateway_args}" ]; then + warn "hermes_gateway_flags is deprecated (it collides with rc.subr); use hermes_gateway_args" + hermes_gateway_args="${hermes_gateway_flags}" +fi +# Suppress rc.subr's automatic *_flags injection — we route extras via +# hermes_gateway_args into the inner command instead. +hermes_gateway_flags="" + +# Resolve home directory of the configured user so ~/.hermes works. +if [ -n "${hermes_gateway_user}" ]; then + hermes_gateway_home=$(getent passwd "${hermes_gateway_user}" 2>/dev/null | cut -d: -f6) + if [ -z "${hermes_gateway_home}" ]; then + hermes_gateway_home=$(eval echo "~${hermes_gateway_user}") + fi +else + hermes_gateway_home="" +fi + +piddir="/var/run/${name}" +pidfile="${piddir}/${name}.pid" + +# We launch via daemon(8): +# -f fully detach +# -r restart on non-zero exit (handles Hermes' SIGUSR1 drain-restart, code 75) +# -P pid pidfile owned by daemon(8) for proper PID tracking + signaling +# -S send stray stderr to syslog +# -T tag syslog tag +# HOME=... so Hermes finds ~/.hermes for the right user +# +# Note: do NOT pass `-u ${hermes_gateway_user}` to daemon. rc.subr already +# drops privileges to ${name}_user via su(1) before exec, so adding -u here +# would cause daemon to call initgroups() as a non-root user → EPERM and a +# tight restart loop with `-r`. +command="/usr/sbin/daemon" +command_args="-f -r -P ${pidfile} -S -T ${name} \ + /usr/bin/env HOME=${hermes_gateway_home} \ + %%PREFIX%%/bin/hermes ${hermes_gateway_profile:+-p ${hermes_gateway_profile}} \ + gateway run --replace ${hermes_gateway_args}" + +required_files="%%PREFIX%%/bin/hermes" + +start_precmd="hermes_gateway_prestart" +hermes_gateway_prestart() +{ + if [ -z "${hermes_gateway_user}" ]; then + err 1 "hermes_gateway_user is not set in rc.conf — refusing to start" + fi + if [ -z "${hermes_gateway_home}" ] || [ ! -d "${hermes_gateway_home}" ]; then + err 1 "home directory for user '${hermes_gateway_user}' not found" + fi + # piddir must be writable by the unprivileged user since rc.subr drops + # privileges (via ${name}_user) before daemon(8) writes the supervisor + # pidfile (-P). Always (re-)assert ownership in case of stale state. + install -d -m 0755 -o "${hermes_gateway_user}" "${piddir}" +} + +run_rc_command "$1" diff --git a/misc/hermes-agent/files/patch-hermes__cli_gateway.py b/misc/hermes-agent/files/patch-hermes__cli_gateway.py new file mode 100644 index 000000000000..cc8791634d1a --- /dev/null +++ b/misc/hermes-agent/files/patch-hermes__cli_gateway.py @@ -0,0 +1,284 @@ +--- hermes_cli/gateway.py.orig 2026-05-06 08:29:55 UTC ++++ hermes_cli/gateway.py +@@ -733,7 +733,175 @@ def is_windows() -> bool: + return sys.platform == 'win32' + + ++def is_freebsd() -> bool: ++ return sys.platform.startswith('freebsd') ++ ++ + # ============================================================================= ++# FreeBSD rc.d service helpers ++# ============================================================================= ++# ++# On FreeBSD the rc.d scripts (hermes_gateway, hermes_dashboard) are shipped by ++# the FreeBSD port at /usr/local/etc/rc.d/. Hermes does NOT generate or write ++# them at runtime — there is no per-user scope on FreeBSD, only the system one, ++# and editing rc.d / rc.conf requires root. These helpers therefore: ++# * detect whether the rc script is present (port installed) ++# * shell out to service(8) for start/stop/restart/status ++# * shell out to sysrc(8) to flip hermes_gateway_enable=YES / -x in rc.conf ++# * print exact sudo commands when running as a non-root user ++# This mirrors the systemd_*/launchd_* command shape so the dispatcher in ++# gateway_command() can branch on platform without duplicating logic. ++ ++FREEBSD_RC_SCRIPT_NAME = "hermes_gateway" ++FREEBSD_RC_SCRIPT_PATH = Path("/usr/local/etc/rc.d") / FREEBSD_RC_SCRIPT_NAME ++FREEBSD_RC_VAR = "hermes_gateway_enable" ++ ++ ++def supports_freebsd_rc() -> bool: ++ """Return True when running on FreeBSD with the hermes_gateway rc.d script ++ installed by the port. We do NOT try to install the rc script ourselves — ++ that's the package manager's job.""" ++ if not is_freebsd(): ++ return False ++ if shutil.which("service") is None: ++ return False ++ return FREEBSD_RC_SCRIPT_PATH.exists() ++ ++ ++def _freebsd_is_root() -> bool: ++ try: ++ return os.geteuid() == 0 ++ except AttributeError: ++ return False ++ ++ ++def _freebsd_sudo_prefix() -> list[str]: ++ """Return [] if already root, ['sudo'] otherwise.""" ++ return [] if _freebsd_is_root() else ["sudo"] ++ ++ ++def _freebsd_run_or_print(cmd: list[str], *, action: str) -> bool: ++ """Run *cmd* (prepending sudo when needed). If sudo is required and ++ unavailable in batch mode, print the exact command for the user to run. ++ Returns True on success, False otherwise. *action* is a short verb used ++ for log messages (e.g. 'start', 'enable').""" ++ if _freebsd_is_root(): ++ try: ++ subprocess.run(cmd, check=True) ++ return True ++ except subprocess.CalledProcessError as e: ++ print(f"✗ Failed to {action} hermes_gateway: exit {e.returncode}") ++ return False ++ ++ if shutil.which("sudo") is None: ++ print(f" Run as root: {' '.join(cmd)}") ++ return False ++ ++ sudo_cmd = ["sudo"] + cmd ++ try: ++ subprocess.run(sudo_cmd, check=True) ++ return True ++ except subprocess.CalledProcessError as e: ++ print(f"✗ Failed to {action} hermes_gateway: exit {e.returncode}") ++ return False ++ ++ ++def freebsd_rc_install(force: bool = False, system: bool = False, run_as_user: str | None = None): ++ """Enable hermes_gateway in /etc/rc.conf and start it. ++ ++ The rc.d script itself is provided by the FreeBSD port; this function only ++ flips the rcvar and (re)starts the service. --system is accepted for ++ parity with the systemd dispatcher but is a no-op on FreeBSD (rc.d only ++ has system scope). --run-as-user is also a no-op: the rc script reads ++ hermes_gateway_user from rc.conf, set it there with sysrc. ++ """ ++ del force, system # noqa: F841 — accepted for dispatcher parity ++ ++ if not FREEBSD_RC_SCRIPT_PATH.exists(): ++ print(f"✗ {FREEBSD_RC_SCRIPT_PATH} not found.") ++ print(" The hermes_gateway rc.d script is shipped by the FreeBSD port.") ++ print(" Install with: sudo pkg install hermes-agent") ++ sys.exit(1) ++ ++ import getpass ++ target_user = run_as_user or getpass.getuser() ++ ++ print(f"Enabling {FREEBSD_RC_VAR}=YES in /etc/rc.conf...") ++ if not _freebsd_run_or_print( ++ ["sysrc", f"{FREEBSD_RC_VAR}=YES", f"hermes_gateway_user={target_user}"], ++ action="enable", ++ ): ++ return ++ print(f"Starting {FREEBSD_RC_SCRIPT_NAME}...") ++ _freebsd_run_or_print( ++ ["service", FREEBSD_RC_SCRIPT_NAME, "start"], ++ action="start", ++ ) ++ ++ ++def freebsd_rc_uninstall(system: bool = False): ++ """Stop hermes_gateway and remove its rcvar from /etc/rc.conf. Does NOT ++ delete the rc.d script itself — that belongs to the FreeBSD package.""" ++ del system # noqa: F841 ++ print(f"Stopping {FREEBSD_RC_SCRIPT_NAME}...") ++ _freebsd_run_or_print( ++ ["service", FREEBSD_RC_SCRIPT_NAME, "stop"], ++ action="stop", ++ ) ++ print(f"Removing {FREEBSD_RC_VAR} from /etc/rc.conf...") ++ _freebsd_run_or_print( ++ ["sysrc", "-x", FREEBSD_RC_VAR], ++ action="disable", ++ ) ++ print(f" (The rc.d script {FREEBSD_RC_SCRIPT_PATH} is owned by the package") ++ print(" manager — use 'pkg delete hermes-agent' to remove it.)") ++ ++ ++def freebsd_rc_start(system: bool = False): ++ del system ++ _freebsd_run_or_print( ++ ["service", FREEBSD_RC_SCRIPT_NAME, "start"], ++ action="start", ++ ) ++ ++ ++def freebsd_rc_stop(system: bool = False): ++ del system ++ _freebsd_run_or_print( ++ ["service", FREEBSD_RC_SCRIPT_NAME, "stop"], ++ action="stop", ++ ) ++ ++ ++def freebsd_rc_restart(system: bool = False): ++ del system ++ _freebsd_run_or_print( ++ ["service", FREEBSD_RC_SCRIPT_NAME, "restart"], ++ action="restart", ++ ) ++ ++ ++def freebsd_rc_status(deep: bool = False, system: bool = False, full: bool = False): ++ del deep, system, full ++ # `service X status` doesn't need root; run directly without sudo. ++ try: ++ result = subprocess.run( ++ ["service", FREEBSD_RC_SCRIPT_NAME, "status"], ++ check=False, ++ ) ++ if result.returncode != 0: ++ print() ++ print("To start the gateway:") ++ if _freebsd_is_root(): ++ print(" hermes gateway start") ++ else: ++ print(f" sudo service {FREEBSD_RC_SCRIPT_NAME} start") ++ print(f" sudo sysrc {FREEBSD_RC_VAR}=YES # start at boot") ++ except FileNotFoundError: ++ print("✗ service(8) not found — is this really FreeBSD?") ++ ++ ++# ============================================================================= + # Service Configuration + # ============================================================================= + +@@ -4083,6 +4251,8 @@ def _gateway_command_inner(args): + print_info(" Or use tmux/screen for persistence: tmux new -s hermes 'hermes gateway run'") + print() + systemd_install(force=force, system=system, run_as_user=run_as_user) ++ elif supports_freebsd_rc(): ++ freebsd_rc_install(force=force, system=system, run_as_user=run_as_user) + elif is_macos(): + launchd_install(force) + elif is_wsl(): +@@ -4119,6 +4289,8 @@ def _gateway_command_inner(args): + sys.exit(1) + if supports_systemd_services(): + systemd_uninstall(system=system) ++ elif supports_freebsd_rc(): ++ freebsd_rc_uninstall(system=system) + elif is_macos(): + launchd_uninstall() + elif is_container(): +@@ -4149,6 +4321,8 @@ def _gateway_command_inner(args): + sys.exit(1) + if supports_systemd_services(): + systemd_start(system=system) ++ elif supports_freebsd_rc(): ++ freebsd_rc_start(system=system) + elif is_macos(): + launchd_start() + elif is_wsl(): +@@ -4187,6 +4361,12 @@ def _gateway_command_inner(args): + service_available = True + except subprocess.CalledProcessError: + pass ++ elif supports_freebsd_rc(): ++ try: ++ freebsd_rc_stop(system=system) ++ service_available = True ++ except subprocess.CalledProcessError: ++ pass + elif is_macos() and get_launchd_plist_path().exists(): + try: + launchd_stop() +@@ -4208,6 +4388,12 @@ def _gateway_command_inner(args): + service_available = True + except subprocess.CalledProcessError: + pass ++ elif supports_freebsd_rc(): ++ try: ++ freebsd_rc_stop(system=system) ++ service_available = True ++ except subprocess.CalledProcessError: ++ pass + elif is_macos() and get_launchd_plist_path().exists(): + try: + launchd_stop() +@@ -4240,6 +4426,12 @@ def _gateway_command_inner(args): + service_stopped = True + except subprocess.CalledProcessError: + pass ++ elif supports_freebsd_rc(): ++ try: ++ freebsd_rc_stop(system=system) ++ service_stopped = True ++ except subprocess.CalledProcessError: ++ pass + elif is_macos() and get_launchd_plist_path().exists(): + try: + launchd_stop() +@@ -4256,12 +4448,14 @@ def _gateway_command_inner(args): + print("Starting gateway...") + if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + systemd_start(system=system) ++ elif supports_freebsd_rc(): ++ freebsd_rc_start(system=system) + elif is_macos() and get_launchd_plist_path().exists(): + launchd_start() + else: + run_gateway(verbose=0) + return +- ++ + if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + service_configured = True + try: +@@ -4269,6 +4463,13 @@ def _gateway_command_inner(args): + service_available = True + except subprocess.CalledProcessError: + pass ++ elif supports_freebsd_rc(): ++ service_configured = True ++ try: ++ freebsd_rc_restart(system=system) ++ service_available = True ++ except subprocess.CalledProcessError: ++ pass + elif is_macos() and get_launchd_plist_path().exists(): + service_configured = True + try: +@@ -4320,6 +4521,9 @@ def _gateway_command_inner(args): + # Check for service first + if supports_systemd_services() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()): + systemd_status(deep, system=system, full=full) ++ _print_gateway_process_mismatch(snapshot) ++ elif supports_freebsd_rc(): ++ freebsd_rc_status(deep=deep, system=system, full=full) + _print_gateway_process_mismatch(snapshot) + elif is_macos() and get_launchd_plist_path().exists(): + launchd_status(deep) diff --git a/misc/hermes-agent/files/patch-hermes__cli_main.py b/misc/hermes-agent/files/patch-hermes__cli_main.py new file mode 100644 index 000000000000..557c100f3aff --- /dev/null +++ b/misc/hermes-agent/files/patch-hermes__cli_main.py @@ -0,0 +1,66 @@ +--- hermes_cli/main.py.orig 2026-05-06 08:29:55 UTC ++++ hermes_cli/main.py +@@ -8118,7 +8118,7 @@ def main(): + gateway_start.add_argument( + "--system", + action="store_true", +- help="Target the Linux system-level gateway service", ++ help="Target the system-level gateway service (Linux only; ignored on FreeBSD/macOS)", + ) + gateway_start.add_argument( + "--all", +@@ -8131,7 +8131,7 @@ def main(): + gateway_stop.add_argument( + "--system", + action="store_true", +- help="Target the Linux system-level gateway service", ++ help="Target the system-level gateway service (Linux only; ignored on FreeBSD/macOS)", + ) + gateway_stop.add_argument( + "--all", +@@ -8146,7 +8146,7 @@ def main(): + gateway_restart.add_argument( + "--system", + action="store_true", +- help="Target the Linux system-level gateway service", ++ help="Target the system-level gateway service (Linux only; ignored on FreeBSD/macOS)", + ) + gateway_restart.add_argument( + "--all", +@@ -8166,23 +8166,23 @@ def main(): + gateway_status.add_argument( + "--system", + action="store_true", +- help="Target the Linux system-level gateway service", ++ help="Target the system-level gateway service (Linux only; ignored on FreeBSD/macOS)", + ) + + # gateway install + gateway_install = gateway_subparsers.add_parser( +- "install", help="Install gateway as a systemd/launchd background service" ++ "install", help="Install gateway as a systemd/launchd/rc.d background service" + ) + gateway_install.add_argument("--force", action="store_true", help="Force reinstall") + gateway_install.add_argument( + "--system", + action="store_true", +- help="Install as a Linux system-level service (starts at boot)", ++ help="Install as a system-level service (Linux only; FreeBSD rc.d is always system-scoped)", + ) + gateway_install.add_argument( + "--run-as-user", + dest="run_as_user", +- help="User account the Linux system service should run as", ++ help="User account the service should run as (Linux systemd / FreeBSD rc.d)", + ) + + # gateway uninstall +@@ -8192,7 +8192,7 @@ def main(): + gateway_uninstall.add_argument( + "--system", + action="store_true", +- help="Target the Linux system-level gateway service", ++ help="Target the system-level gateway service (Linux only; ignored on FreeBSD/macOS)", + ) + + # gateway setup diff --git a/misc/hermes-agent/files/patch-hermes__cli_setup.py b/misc/hermes-agent/files/patch-hermes__cli_setup.py new file mode 100644 index 000000000000..b3543dae6ca5 --- /dev/null +++ b/misc/hermes-agent/files/patch-hermes__cli_setup.py @@ -0,0 +1,95 @@ +--- hermes_cli/setup.py.orig 2026-05-06 08:29:55 UTC ++++ hermes_cli/setup.py +@@ -2409,11 +2409,13 @@ def setup_gateway(config: dict): + + _is_linux = _platform.system() == "Linux" + _is_macos = _platform.system() == "Darwin" ++ _is_freebsd = _platform.system() == "FreeBSD" + + from hermes_cli.gateway import ( + _is_service_installed, + _is_service_running, + supports_systemd_services, ++ supports_freebsd_rc, + has_conflicting_systemd_units, + has_legacy_hermes_units, + install_linux_gateway_from_setup, +@@ -2424,13 +2426,17 @@ def setup_gateway(config: dict): + launchd_install, + launchd_start, + launchd_restart, ++ freebsd_rc_install, ++ freebsd_rc_start, ++ freebsd_rc_restart, + UserSystemdUnavailableError, + ) + + service_installed = _is_service_installed() + service_running = _is_service_running() + supports_systemd = supports_systemd_services() +- supports_service_manager = supports_systemd or _is_macos ++ supports_freebsd = supports_freebsd_rc() ++ supports_service_manager = supports_systemd or _is_macos or supports_freebsd + + print() + if supports_systemd and has_conflicting_systemd_units(): +@@ -2446,6 +2452,8 @@ def setup_gateway(config: dict): + try: + if supports_systemd: + systemd_restart() ++ elif supports_freebsd: ++ freebsd_rc_restart() + elif _is_macos: + launchd_restart() + except UserSystemdUnavailableError as e: +@@ -2459,6 +2467,8 @@ def setup_gateway(config: dict): + try: + if supports_systemd: + systemd_start() ++ elif supports_freebsd: ++ freebsd_rc_start() + elif _is_macos: + launchd_start() + except UserSystemdUnavailableError as e: +@@ -2468,7 +2478,12 @@ def setup_gateway(config: dict): + except Exception as e: + print_error(f" Start failed: {e}") + elif supports_service_manager: +- svc_name = "systemd" if supports_systemd else "launchd" ++ if supports_systemd: ++ svc_name = "systemd" ++ elif supports_freebsd: ++ svc_name = "FreeBSD rc.d" ++ else: ++ svc_name = "launchd" + if prompt_yes_no( + f" Install the gateway as a {svc_name} service? (runs in background, starts on boot)", + True, +@@ -2478,6 +2493,9 @@ def setup_gateway(config: dict): + did_install = False + if supports_systemd: + installed_scope, did_install = install_linux_gateway_from_setup(force=False) ++ elif supports_freebsd: ++ freebsd_rc_install(force=False) ++ did_install = True + else: + launchd_install(force=False) + did_install = True +@@ -2486,6 +2504,8 @@ def setup_gateway(config: dict): + try: + if supports_systemd: + systemd_start(system=installed_scope == "system") ++ elif supports_freebsd: ++ freebsd_rc_start() + elif _is_macos: + launchd_start() + except UserSystemdUnavailableError as e: +@@ -2501,6 +2521,8 @@ def setup_gateway(config: dict): + print_info(" You can install later: hermes gateway install") + if supports_systemd: + print_info(" Or as a boot-time service: sudo hermes gateway install --system") ++ elif supports_freebsd: ++ print_info(" (FreeBSD rc.d install runs sysrc + service start with sudo.)") + print_info(" Or run in foreground: hermes gateway") + else: + from hermes_constants import is_container diff --git a/misc/hermes-agent/files/patch-hermes__cli_uninstall.py b/misc/hermes-agent/files/patch-hermes__cli_uninstall.py new file mode 100644 index 000000000000..c971198c7ae1 --- /dev/null +++ b/misc/hermes-agent/files/patch-hermes__cli_uninstall.py @@ -0,0 +1,35 @@ +--- hermes_cli/uninstall.py.orig 2026-05-06 08:29:55 UTC ++++ hermes_cli/uninstall.py +@@ -201,6 +201,32 @@ def uninstall_gateway_service(): + except Exception as e: + log_warn(f"Could not remove launchd gateway service: {e}") + ++ # 4. FreeBSD: stop the rc.d service and remove its rcvar from rc.conf. ++ # The rc script itself (/usr/local/etc/rc.d/hermes_gateway) is owned ++ # by the FreeBSD package — `pkg delete hermes-agent` removes it. ++ elif system == "FreeBSD": ++ try: ++ from hermes_cli.gateway import ( ++ supports_freebsd_rc, ++ FREEBSD_RC_SCRIPT_NAME, ++ FREEBSD_RC_VAR, ++ ) ++ if supports_freebsd_rc(): ++ sudo = [] if os.geteuid() == 0 else (["sudo"] if shutil.which("sudo") else None) ++ if sudo is None: ++ log_warn(f" Need root to disable {FREEBSD_RC_SCRIPT_NAME}. " ++ f"Run: sudo service {FREEBSD_RC_SCRIPT_NAME} stop && " ++ f"sudo sysrc -x {FREEBSD_RC_VAR}") ++ else: ++ subprocess.run(sudo + ["service", FREEBSD_RC_SCRIPT_NAME, "stop"], ++ capture_output=True, check=False) ++ subprocess.run(sudo + ["sysrc", "-x", FREEBSD_RC_VAR], ++ capture_output=True, check=False) ++ log_success(f"Stopped and disabled {FREEBSD_RC_SCRIPT_NAME}") ++ stopped_something = True ++ except Exception as e: ++ log_warn(f"Could not disable FreeBSD gateway service: {e}") ++ + return stopped_something + + diff --git a/misc/hermes-agent/files/pkg-message.in b/misc/hermes-agent/files/pkg-message.in new file mode 100644 index 000000000000..2ccb9172ae10 --- /dev/null +++ b/misc/hermes-agent/files/pkg-message.in @@ -0,0 +1,25 @@ +[ +{ type: install + message: <<EOM +Hermes stores its working set under ${HOME}/.hermes/. Before the first +run, bootstrap that directory and configure API keys with: + + hermes setup + +The bundled skill templates live under %%DATADIR%%/skills/ and +%%DATADIR%%/optional-skills/; Hermes copies the ones it needs into +${HOME}/.hermes/skills/ on demand. + +To run Hermes as a service, enable one or both of the rc(8) scripts: + +sysrc hermes_gateway_enable="YES" +sysrc hermes_gateway_user="<account whose ~/.hermes is used>" + +sysrc hermes_dashboard_enable="YES" +sysrc hermes_dashboard_user="<account whose ~/.hermes is used>" + +See %%PREFIX%%/etc/rc.d/hermes_gateway and hermes_dashboard for all +tunables (host, port, profile, extra flags). +EOM +} +] diff --git a/misc/hermes-agent/files/wrapper.in b/misc/hermes-agent/files/wrapper.in new file mode 100644 index 000000000000..79affb1bd613 --- /dev/null +++ b/misc/hermes-agent/files/wrapper.in @@ -0,0 +1,13 @@ +#!%%PYTHON_CMD%% +# Wrapper installed by FreeBSD ports — see misc/hermes-agent. +# Hermes is installed under %%HERMES_LIBDIR%% (not site-packages) because +# upstream ships top-level packages with generic names (tools, agent, *** 37 LINES SKIPPED ***home | help
Want to link to this message? Use this
URL: <https://mail-archive.FreeBSD.org/cgi/mid.cgi?69fc4d14.1a11f.1c4d6998>
