diff --git a/flake.nix b/flake.nix index fcb5eaa619..1c1d0b7892 100644 --- a/flake.nix +++ b/flake.nix @@ -36,6 +36,7 @@ imports = [ ./nix/packages.nix + ./nix/overlays.nix ./nix/nixosModules.nix ./nix/checks.nix ./nix/devShell.nix diff --git a/nix/checks.nix b/nix/checks.nix index 984016a4f4..cf11082b98 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -7,9 +7,7 @@ perSystem = { pkgs, system, lib, ... }: let hermes-agent = inputs.self.packages.${system}.default; - hermesVenv = pkgs.callPackage ./python.nix { - inherit (inputs) uv2nix pyproject-nix pyproject-build-systems; - }; + hermesVenv = hermes-agent.hermesVenv; configMergeScript = pkgs.callPackage ./configMergeScript.nix { }; @@ -193,6 +191,35 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) echo "ok" > $out/result ''; + # Verify extraPythonPackages PYTHONPATH injection + extra-python-packages = let + testPkg = pkgs.python312Packages.pyfiglet; + hermesWithExtra = hermes-agent.override { + extraPythonPackages = [ testPkg ]; + }; + in pkgs.runCommand "hermes-extra-python-packages" { } '' + set -e + echo "=== Checking extraPythonPackages PYTHONPATH injection ===" + + grep -q "PYTHONPATH" ${hermesWithExtra}/bin/hermes || \ + (echo "FAIL: PYTHONPATH not in wrapper"; exit 1) + echo "PASS: PYTHONPATH present in wrapper" + + grep -q "${testPkg}" ${hermesWithExtra}/bin/hermes || \ + (echo "FAIL: test package path not in PYTHONPATH"; exit 1) + echo "PASS: test package path found in wrapper" + + echo "=== Checking base package has no PYTHONPATH ===" + if grep -q "PYTHONPATH" ${hermes-agent}/bin/hermes; then + echo "FAIL: base package should not have PYTHONPATH"; exit 1 + fi + echo "PASS: base package clean" + + echo "=== All extraPythonPackages checks passed ===" + mkdir -p $out + echo "ok" > $out/result + ''; + # ── Config merge + round-trip test ──────────────────────────────── # Tests the merge script (Nix activation behavior) across 7 # scenarios, then verifies Python's load_config() reads correctly. diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix new file mode 100644 index 0000000000..85ba71fb13 --- /dev/null +++ b/nix/hermes-agent.nix @@ -0,0 +1,186 @@ +# nix/hermes-agent.nix — Overridable Hermes Agent package +# +# callPackage auto-wires nixpkgs args; flake inputs are passed explicitly. +# Users override via: pkgs.hermes-agent.override { extraPythonPackages = [...]; } +{ + lib, + stdenv, + makeWrapper, + callPackage, + python312, + nodejs_22, + ripgrep, + git, + openssh, + ffmpeg, + tirith, + # Flake inputs — passed explicitly by packages.nix and overlays.nix + uv2nix, + pyproject-nix, + pyproject-build-systems, + npm-lockfile-fix, + # Overridable parameters + extraPythonPackages ? [ ], +}: +let + hermesVenv = callPackage ./python.nix { + inherit uv2nix pyproject-nix pyproject-build-systems; + }; + + hermesNpmLib = callPackage ./lib.nix { + inherit npm-lockfile-fix; + }; + + hermesTui = callPackage ./tui.nix { + inherit hermesNpmLib; + }; + + hermesWeb = callPackage ./web.nix { + inherit hermesNpmLib; + }; + + bundledSkills = lib.cleanSourceWith { + src = ../skills; + filter = path: _type: !(lib.hasInfix "/index-cache/" path); + }; + + runtimeDeps = [ + nodejs_22 + ripgrep + git + openssh + ffmpeg + tirith + ]; + + runtimePath = lib.makeBinPath runtimeDeps; + + sitePackagesPath = python312.sitePackages; + + # Walk propagatedBuildInputs to include transitive Python deps in PYTHONPATH. + # Without this, a plugin listing e.g. requests as a dep would fail at runtime + # if requests isn't already in the sealed uv2nix venv. + allExtraPythonPackages = python312.pkgs.requiredPythonModules extraPythonPackages; + + pythonPath = lib.makeSearchPath sitePackagesPath allExtraPythonPackages; + + pyprojectHash = builtins.hashString "sha256" (builtins.readFile ../pyproject.toml); + uvLockHash = + if builtins.pathExists ../uv.lock then + builtins.hashString "sha256" (builtins.readFile ../uv.lock) + else + "none"; +in +stdenv.mkDerivation { + pname = "hermes-agent"; + version = (builtins.fromTOML (builtins.readFile ../pyproject.toml)).project.version; + + dontUnpack = true; + dontBuild = true; + nativeBuildInputs = [ makeWrapper ]; + + installPhase = '' + runHook preInstall + + mkdir -p $out/share/hermes-agent $out/bin + cp -r ${bundledSkills} $out/share/hermes-agent/skills + cp -r ${hermesWeb} $out/share/hermes-agent/web_dist + + mkdir -p $out/ui-tui + cp -r ${hermesTui}/lib/hermes-tui/* $out/ui-tui/ + + ${lib.concatMapStringsSep "\n" + (name: '' + makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \ + --suffix PATH : "${runtimePath}" \ + --set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \ + --set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \ + --set HERMES_TUI_DIR $out/ui-tui \ + --set HERMES_PYTHON ${hermesVenv}/bin/python3 \ + --set HERMES_NODE ${nodejs_22}/bin/node \ + ${lib.optionalString (extraPythonPackages != [ ]) ''--suffix PYTHONPATH : "${pythonPath}"''} + '') + [ + "hermes" + "hermes-agent" + "hermes-acp" + ] + } + + ${lib.optionalString (extraPythonPackages != [ ]) '' + echo "=== Checking for plugin/core package collisions ===" + ${hermesVenv}/bin/python3 -c " +import pathlib, sys, re + +def canonical(name): + return re.sub(r'[-_.]+', '-', name).lower() + +# Collect core venv package names +core = set() +venv_sp = pathlib.Path('${hermesVenv}/${sitePackagesPath}') +for di in venv_sp.glob('*.dist-info'): + meta = di / 'METADATA' + if meta.exists(): + for line in meta.read_text().splitlines(): + if line.startswith('Name:'): + core.add(canonical(line.split(':', 1)[1].strip())) + break + +# Check each extra package for collisions +extras_dirs = [${lib.concatMapStringsSep ", " (p: "'${toString p}'") allExtraPythonPackages}] +for edir in extras_dirs: + sp = pathlib.Path(edir) / '${sitePackagesPath}' + if not sp.exists(): + continue + for di in sp.glob('*.dist-info'): + meta = di / 'METADATA' + if not meta.exists(): + continue + for line in meta.read_text().splitlines(): + if line.startswith('Name:'): + pkg = canonical(line.split(':', 1)[1].strip()) + if pkg in core: + print(f'ERROR: plugin package \"{pkg}\" collides with a package in hermes sealed venv', file=sys.stderr) + print(f' from: {di}', file=sys.stderr) + print(f' Remove this dependency from extraPythonPackages.', file=sys.stderr) + sys.exit(1) + break + +print('No collisions found.') + " + echo "=== No collisions ===" + ''} + + runHook postInstall + ''; + + passthru = { + inherit hermesTui hermesWeb hermesNpmLib hermesVenv; + + devShellHook = '' + STAMP=".nix-stamps/hermes-agent" + STAMP_VALUE="${pyprojectHash}:${uvLockHash}" + if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then + echo "hermes-agent: installing Python dependencies..." + uv venv .venv --python ${python312}/bin/python3 2>/dev/null || true + source .venv/bin/activate + uv pip install -e ".[all]" + [ -d mini-swe-agent ] && uv pip install -e ./mini-swe-agent 2>/dev/null || true + [ -d tinker-atropos ] && uv pip install -e ./tinker-atropos 2>/dev/null || true + mkdir -p .nix-stamps + echo "$STAMP_VALUE" > "$STAMP" + else + source .venv/bin/activate + export HERMES_PYTHON=${hermesVenv}/bin/python3 + fi + ''; + }; + + meta = with lib; { + description = "AI agent with advanced tool-calling capabilities"; + homepage = "https://github.com/NousResearch/hermes-agent"; + mainProgram = "hermes"; + license = licenses.mit; + platforms = platforms.unix; + }; +} diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix index d3cb71a395..cc86119dd4 100644 --- a/nix/nixosModules.nix +++ b/nix/nixosModules.nix @@ -28,6 +28,8 @@ let cfg = config.services.hermes-agent; + effectivePackage = if cfg.extraPythonPackages == [ ] then cfg.package + else cfg.package.override { inherit (cfg) extraPythonPackages; }; hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default; # Deep-merge config type (from 0xrsydn/nix-hermes-agent) @@ -456,6 +458,52 @@ description = "Extra packages available on PATH."; }; + extraPlugins = mkOption { + type = types.listOf types.package; + default = [ ]; + description = '' + Directory-based plugin packages to symlink into the hermes plugins + directory. Each package should contain a plugin.yaml and __init__.py + at its root. Hermes discovers these automatically on startup. + ''; + example = literalExpression '' + [ + (pkgs.fetchFromGitHub { + owner = "stephenschoettler"; + repo = "hermes-lcm"; + name = "hermes-lcm"; + rev = "v0.7.0"; + hash = "sha256-..."; + }) + ] + ''; + }; + + extraPythonPackages = mkOption { + type = types.listOf types.package; + default = [ ]; + description = '' + Python packages to add to PYTHONPATH for entry-point plugin discovery. + These are pip-packaged plugins that register via the + hermes_agent.plugins entry-point group. Each package must be built + with the same Python interpreter as hermes (python312). + ''; + example = literalExpression '' + [ + (pkgs.python312Packages.buildPythonPackage { + pname = "rtk-hermes"; + version = "1.0.0"; + src = pkgs.fetchFromGitHub { + owner = "ogallotti"; + repo = "rtk-hermes"; + rev = "main"; + hash = "sha256-..."; + }; + }) + ] + ''; + }; + restart = mkOption { type = types.str; default = "always"; @@ -570,7 +618,7 @@ # so interactive shells share state (sessions, skills, cron) with the # gateway service instead of creating a separate ~/.hermes/. (lib.mkIf cfg.addToSystemPackages { - environment.systemPackages = [ cfg.package ]; + environment.systemPackages = [ effectivePackage ]; environment.variables.HERMES_HOME = "${cfg.stateDir}/.hermes"; }) @@ -581,6 +629,16 @@ }); }) + # ── Assertions ───────────────────────────────────────────────────── + { + assertions = let + names = map lib.getName cfg.extraPlugins; + in [{ + assertion = (lib.length names) == (lib.length (lib.unique names)); + message = "services.hermes-agent.extraPlugins: duplicate plugin names detected: ${toString names}. If using fetchFromGitHub, set name = \"plugin-name\" to disambiguate."; + }]; + } + # ── Warnings ────────────────────────────────────────────────────── (lib.mkIf (cfg.container.enable && !cfg.addToSystemPackages && cfg.container.hostUsers != []) { warnings = [ @@ -602,6 +660,7 @@ "d ${cfg.stateDir}/.hermes/sessions 2770 ${cfg.user} ${cfg.group} - -" "d ${cfg.stateDir}/.hermes/logs 2770 ${cfg.user} ${cfg.group} - -" "d ${cfg.stateDir}/.hermes/memories 2770 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir}/.hermes/plugins 2770 ${cfg.user} ${cfg.group} - -" "d ${cfg.stateDir}/home 0750 ${cfg.user} ${cfg.group} - -" "d ${cfg.workingDirectory} 2770 ${cfg.user} ${cfg.group} - -" ]; @@ -623,7 +682,7 @@ find ${cfg.stateDir}/.hermes -maxdepth 1 \ \( -name "*.db" -o -name "*.db-wal" -o -name "*.db-shm" -o -name "SOUL.md" \) \ -exec chmod g+rw {} + 2>/dev/null || true - for _subdir in cron sessions logs memories; do + for _subdir in cron sessions logs memories plugins; do mkdir -p "${cfg.stateDir}/.hermes/$_subdir" chown ${cfg.user}:${cfg.group} "${cfg.stateDir}/.hermes/$_subdir" chmod 2770 "${cfg.stateDir}/.hermes/$_subdir" @@ -732,6 +791,22 @@ HERMES_NIX_ENV_EOF ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: _value: '' install -o ${cfg.user} -g ${cfg.group} -m 0640 ${documentDerivation}/${name} ${cfg.workingDirectory}/${name} '') cfg.documents)} + + # ── Declarative plugins ───────────────────────────────────────── + # Remove stale managed symlinks (plugins removed from config) + find ${cfg.stateDir}/.hermes/plugins -maxdepth 1 -type l -name 'nix-managed-*' -delete 2>/dev/null || true + + ${lib.concatStringsSep "\n" (map (plugin: + let + name = lib.getName plugin; + in '' + if [ ! -f "${plugin}/plugin.yaml" ]; then + echo "ERROR: extraPlugins entry '${plugin}' has no plugin.yaml" >&2 + exit 1 + fi + ln -sfn ${plugin} ${cfg.stateDir}/.hermes/plugins/nix-managed-${name} + chown -h ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/plugins/nix-managed-${name} + '') cfg.extraPlugins)} ''; } @@ -762,7 +837,7 @@ HERMES_NIX_ENV_EOF # reads them at Python startup — no systemd EnvironmentFile needed. ExecStart = lib.concatStringsSep " " ([ - "${cfg.package}/bin/hermes" + "${effectivePackage}/bin/hermes" "gateway" ] ++ cfg.extraArgs); @@ -785,7 +860,7 @@ HERMES_NIX_ENV_EOF }; path = [ - cfg.package + effectivePackage pkgs.bash pkgs.coreutils pkgs.git @@ -810,11 +885,11 @@ HERMES_NIX_ENV_EOF preStart = '' # Stable symlinks — container references these, not store paths directly - ln -sfn ${cfg.package} ${cfg.stateDir}/current-package + ln -sfn ${effectivePackage} ${cfg.stateDir}/current-package ln -sfn ${containerEntrypoint} ${cfg.stateDir}/current-entrypoint # GC roots so nix-collect-garbage doesn't remove store paths in use - ${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${cfg.package} 2>/dev/null || true + ${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${effectivePackage} 2>/dev/null || true ${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root-entrypoint --indirect -r ${containerEntrypoint} 2>/dev/null || true # Check if container needs (re)creation diff --git a/nix/overlays.nix b/nix/overlays.nix new file mode 100644 index 0000000000..4d7bb2a121 --- /dev/null +++ b/nix/overlays.nix @@ -0,0 +1,10 @@ +# nix/overlays.nix — Expose pkgs.hermes-agent for external NixOS configs +{ inputs, ... }: +{ + flake.overlays.default = final: _: { + hermes-agent = final.callPackage ./hermes-agent.nix { + inherit (inputs) uv2nix pyproject-nix pyproject-build-systems; + npm-lockfile-fix = inputs.npm-lockfile-fix.packages.${final.stdenv.hostPlatform.system}.default; + }; + }; +} diff --git a/nix/packages.nix b/nix/packages.nix index 721546851d..f27c43a75e 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -4,120 +4,19 @@ perSystem = { pkgs, inputs', ... }: let - hermesVenv = pkgs.callPackage ./python.nix { + hermesAgent = pkgs.callPackage ./hermes-agent.nix { inherit (inputs) uv2nix pyproject-nix pyproject-build-systems; - }; - - hermesNpmLib = pkgs.callPackage ./lib.nix { npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default; }; - - hermesTui = pkgs.callPackage ./tui.nix { - inherit hermesNpmLib; - }; - - # Import bundled skills, excluding runtime caches - bundledSkills = pkgs.lib.cleanSourceWith { - src = ../skills; - filter = path: _type: !(pkgs.lib.hasInfix "/index-cache/" path); - }; - - hermesWeb = pkgs.callPackage ./web.nix { - inherit hermesNpmLib; - }; - - runtimeDeps = with pkgs; [ - nodejs_22 - ripgrep - git - openssh - ffmpeg - tirith - ]; - - runtimePath = pkgs.lib.makeBinPath runtimeDeps; - - # Lockfile hashes for dev shell stamps - pyprojectHash = builtins.hashString "sha256" (builtins.readFile ../pyproject.toml); - uvLockHash = - if builtins.pathExists ../uv.lock then - builtins.hashString "sha256" (builtins.readFile ../uv.lock) - else - "none"; in { packages = { - default = pkgs.stdenv.mkDerivation { - pname = "hermes-agent"; - version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version; + default = hermesAgent; + tui = hermesAgent.hermesTui; + web = hermesAgent.hermesWeb; - dontUnpack = true; - dontBuild = true; - nativeBuildInputs = [ pkgs.makeWrapper ]; - - installPhase = '' - runHook preInstall - - mkdir -p $out/share/hermes-agent $out/bin - cp -r ${bundledSkills} $out/share/hermes-agent/skills - cp -r ${hermesWeb} $out/share/hermes-agent/web_dist - - # copy pre-built TUI (same layout as dev: ui-tui/dist/ + node_modules/) - mkdir -p $out/ui-tui - cp -r ${hermesTui}/lib/hermes-tui/* $out/ui-tui/ - - ${pkgs.lib.concatMapStringsSep "\n" - (name: '' - makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \ - --suffix PATH : "${runtimePath}" \ - --set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \ - --set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \ - --set HERMES_TUI_DIR $out/ui-tui \ - --set HERMES_PYTHON ${hermesVenv}/bin/python3 \ - --set HERMES_NODE ${pkgs.nodejs_22}/bin/node - '') - [ - "hermes" - "hermes-agent" - "hermes-acp" - ] - } - - runHook postInstall - ''; - - passthru.devShellHook = '' - STAMP=".nix-stamps/hermes-agent" - STAMP_VALUE="${pyprojectHash}:${uvLockHash}" - if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then - echo "hermes-agent: installing Python dependencies..." - uv venv .venv --python ${pkgs.python312}/bin/python3 2>/dev/null || true - source .venv/bin/activate - uv pip install -e ".[all]" - [ -d mini-swe-agent ] && uv pip install -e ./mini-swe-agent 2>/dev/null || true - [ -d tinker-atropos ] && uv pip install -e ./tinker-atropos 2>/dev/null || true - mkdir -p .nix-stamps - echo "$STAMP_VALUE" > "$STAMP" - else - source .venv/bin/activate - export HERMES_PYTHON=${hermesVenv}/bin/python3 - fi - ''; - - meta = with pkgs.lib; { - description = "AI agent with advanced tool-calling capabilities"; - homepage = "https://github.com/NousResearch/hermes-agent"; - mainProgram = "hermes"; - license = licenses.mit; - platforms = platforms.unix; - }; - }; - - tui = hermesTui; - web = hermesWeb; - - fix-lockfiles = hermesNpmLib.mkFixLockfiles { - packages = [ hermesTui hermesWeb ]; + fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles { + packages = [ hermesAgent.hermesTui hermesAgent.hermesWeb ]; }; }; }; diff --git a/nix/python.nix b/nix/python.nix index 0bcd017e76..16d8eaedad 100644 --- a/nix/python.nix +++ b/nix/python.nix @@ -7,6 +7,7 @@ pyproject-nix, pyproject-build-systems, stdenv, + dependency-groups ? [ "all" ], }: let workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./..; }; @@ -96,5 +97,5 @@ let ]); in pythonSet.mkVirtualEnv "hermes-agent-env" { - hermes-agent = [ "all" ]; + hermes-agent = dependency-groups; } diff --git a/website/docs/getting-started/nix-setup.md b/website/docs/getting-started/nix-setup.md index e2bcd9dd68..4b3ec74918 100644 --- a/website/docs/getting-started/nix-setup.md +++ b/website/docs/getting-started/nix-setup.md @@ -599,6 +599,93 @@ The `preStart` script creates a GC root at `${stateDir}/.gc-root` pointing to th --- +## Plugins + +The NixOS module supports declarative plugin installation — no imperative `hermes plugins install` needed. + +### Directory Plugins (`extraPlugins`) + +For plugins that are just a source tree with `plugin.yaml` + `__init__.py` (e.g., [hermes-lcm](https://github.com/stephenschoettler/hermes-lcm)): + +```nix +services.hermes-agent.extraPlugins = [ + (pkgs.fetchFromGitHub { + owner = "stephenschoettler"; + repo = "hermes-lcm"; + rev = "v0.7.0"; + hash = "sha256-..."; + }) +]; +``` + +Plugins are symlinked into `$HERMES_HOME/plugins/` at activation time. Hermes discovers them via its normal directory scan. Removing a plugin from the list and running `nixos-rebuild switch` removes the symlink. + +### Entry-Point Plugins (`extraPythonPackages`) + +For pip-packaged plugins that register via `[project.entry-points."hermes_agent.plugins"]` (e.g., [rtk-hermes](https://github.com/ogallotti/rtk-hermes)): + +```nix +services.hermes-agent.extraPythonPackages = [ + (pkgs.python312Packages.buildPythonPackage { + pname = "rtk-hermes"; + version = "1.0.0"; + src = pkgs.fetchFromGitHub { + owner = "ogallotti"; + repo = "rtk-hermes"; + rev = "v1.0.0"; + hash = "sha256-..."; + }; + format = "pyproject"; + build-system = [ pkgs.python312Packages.setuptools ]; + }) +]; +``` + +The package's `site-packages` is added to PYTHONPATH in the hermes wrapper. `importlib.metadata` discovers the entry point at session start. + +### Combining Both + +A directory plugin with third-party Python dependencies needs both options: + +```nix +services.hermes-agent = { + extraPlugins = [ my-plugin-src ]; # plugin source + extraPythonPackages = [ pkgs.python312Packages.redis ]; # its Python dep + extraPackages = [ pkgs.redis ]; # system binary it needs +}; +``` + +### Using the Overlay + +External flakes can override the package directly: + +```nix +{ + inputs.hermes-agent.url = "github:NousResearch/hermes-agent"; + outputs = { hermes-agent, nixpkgs, ... }: { + nixpkgs.overlays = [ hermes-agent.overlays.default ]; + # Then: pkgs.hermes-agent.override { extraPythonPackages = [...]; } + }; +} +``` + +### Plugin Configuration + +Plugins still need to be enabled in `config.yaml`. Add them via the declarative settings: + +```nix +services.hermes-agent.settings.plugins.enabled = [ + "hermes-lcm" + "rtk-rewrite" +]; +``` + +:::note +A build-time collision check prevents plugin packages from shadowing core hermes dependencies. If a plugin provides a package already in the sealed venv, `nixos-rebuild` fails with a clear error. +::: + +--- + ## Development ### Dev Shell @@ -721,6 +808,8 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use |---|---|---|---| | `extraArgs` | `listOf str` | `[]` | Extra args for `hermes gateway` | | `extraPackages` | `listOf package` | `[]` | Extra packages on service PATH (native mode only) | +| `extraPlugins` | `listOf package` | `[]` | Directory plugin packages to symlink into `$HERMES_HOME/plugins/`. Each must contain `plugin.yaml` | +| `extraPythonPackages` | `listOf package` | `[]` | Python packages added to PYTHONPATH for entry-point plugin discovery. Build with `python312Packages` | | `restart` | `str` | `"always"` | systemd `Restart=` policy | | `restartSec` | `int` | `5` | systemd `RestartSec=` value | diff --git a/website/docs/guides/build-a-hermes-plugin.md b/website/docs/guides/build-a-hermes-plugin.md index 6a220aba25..0c401033f9 100644 --- a/website/docs/guides/build-a-hermes-plugin.md +++ b/website/docs/guides/build-a-hermes-plugin.md @@ -633,6 +633,43 @@ pip install hermes-plugin-calculator # Plugin auto-discovered on next hermes startup ``` +### Distribute for NixOS + +NixOS users can install your plugin declaratively if you provide a `pyproject.toml` with entry points: + +**Entry-point plugins** (recommended for distribution): +```nix +# User's configuration.nix +services.hermes-agent.extraPythonPackages = [ + (pkgs.python312Packages.buildPythonPackage { + pname = "my-plugin"; + version = "1.0.0"; + src = pkgs.fetchFromGitHub { + owner = "you"; + repo = "hermes-my-plugin"; + rev = "v1.0.0"; + hash = "sha256-..."; # nix-prefetch-url --unpack + }; + format = "pyproject"; + build-system = [ pkgs.python312Packages.setuptools ]; + }) +]; +``` + +**Directory plugins** (no `pyproject.toml` needed): +```nix +services.hermes-agent.extraPlugins = [ + (pkgs.fetchFromGitHub { + owner = "you"; + repo = "hermes-my-plugin"; + rev = "v1.0.0"; + hash = "sha256-..."; + }) +]; +``` + +See the [Nix Setup guide](/docs/getting-started/nix-setup#plugins) for complete documentation including overlay usage and collision checking. + ## Common mistakes **Handler doesn't return JSON string:** diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 32d401f44b..7010ca637f 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -99,6 +99,7 @@ Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable | User | `~/.hermes/plugins/` | Personal plugins | | Project | `.hermes/plugins/` | Project-specific plugins (requires `HERMES_ENABLE_PROJECT_PLUGINS=true`) | | pip | `hermes_agent.plugins` entry_points | Distributed packages | +| Nix | `services.hermes-agent.extraPlugins` / `extraPythonPackages` | NixOS declarative installs — see [Nix Setup](/docs/getting-started/nix-setup#plugins) | Later sources override earlier ones on name collision, so a user plugin with the same name as a bundled plugin replaces it. @@ -155,6 +156,23 @@ Hermes has three kinds of plugins: Memory providers and context engines are **provider plugins** — only one of each type can be active at a time. General plugins can be enabled in any combination. +## NixOS declarative plugins + +On NixOS, plugins can be installed declaratively via the module options — no `hermes plugins install` needed. See the **[Nix Setup guide](/docs/getting-started/nix-setup#plugins)** for full details. + +```nix +services.hermes-agent = { + # Directory plugin (source tree with plugin.yaml) + extraPlugins = [ (pkgs.fetchFromGitHub { ... }) ]; + # Entry-point plugin (pip package) + extraPythonPackages = [ (pkgs.python312Packages.buildPythonPackage { ... }) ]; + # Enable in config + settings.plugins.enabled = [ "my-plugin" ]; +}; +``` + +Declarative plugins are symlinked with a `nix-managed-` prefix — they coexist with manually installed plugins and are cleaned up automatically when removed from the Nix config. + ## Managing plugins ```bash