Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
+1454
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
# nix/configMergeScript.nix — Deep-merge Nix settings into existing config.yaml
|
||||
#
|
||||
# Used by the NixOS module activation script and by checks.nix tests.
|
||||
# Nix keys override; user-added keys (skills, streaming, etc.) are preserved.
|
||||
{ pkgs }:
|
||||
pkgs.writeScript "hermes-config-merge" ''
|
||||
#!${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3
|
||||
import json, yaml, sys
|
||||
from pathlib import Path
|
||||
|
||||
nix_json, config_path = sys.argv[1], Path(sys.argv[2])
|
||||
|
||||
with open(nix_json) as f:
|
||||
nix = json.load(f)
|
||||
|
||||
existing = {}
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
existing = yaml.safe_load(f) or {}
|
||||
|
||||
def deep_merge(base, override):
|
||||
result = dict(base)
|
||||
for k, v in override.items():
|
||||
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
|
||||
result[k] = deep_merge(result[k], v)
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
merged = deep_merge(existing, nix)
|
||||
with open(config_path, "w") as f:
|
||||
yaml.dump(merged, f, default_flow_style=False, sort_keys=False)
|
||||
''
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
# nix/desktop.nix — Hermes Desktop (Electron) app build + wrapper
|
||||
#
|
||||
# `hermesAgent` is the fully-built `.#default` package — it ships the
|
||||
# `hermes` binary with the venv, runtime PATH, bundled skills/plugins, etc.
|
||||
# already wired up. We point the desktop at it via the existing
|
||||
# `HERMES_DESKTOP_HERMES` override env var, so the desktop's resolver
|
||||
# uses our fully wrapped binary at step 4 ("existing Hermes CLI").
|
||||
# No reimplementation of the agent resolution in this wrapper.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
stdenv,
|
||||
makeWrapper,
|
||||
hermesNpmLib,
|
||||
electron,
|
||||
hermesAgent,
|
||||
python3,
|
||||
# Environment to bake into the launcher. A GUI launcher reads none of the
|
||||
# shell profile, so a variable that an interactive shell exports does not
|
||||
# reach an app that the desktop menu starts. The Home Manager module passes
|
||||
# HERMES_HOME and HERMES_MANAGED here, which gives the app the same state
|
||||
# directory as the services.
|
||||
extraEnv ? { },
|
||||
# Shell lines to run before the app starts. A secret belongs here and never
|
||||
# in extraEnv: makeWrapper writes a --set value into the Nix store, which
|
||||
# all users can read. A --run line reads the value from a runtime path at
|
||||
# each start instead.
|
||||
extraRun ? [ ],
|
||||
...
|
||||
}:
|
||||
let
|
||||
# Each flag goes on its own continued line, and the leading backslash is
|
||||
# inside the generated string. An empty attribute set then adds no text at
|
||||
# all, and cannot leave a backslash above a blank line. That fault ends the
|
||||
# makeWrapper command early, and the next flag runs as a shell command.
|
||||
extraEnvFlags = lib.concatMapStrings (
|
||||
name: " \\\n --set ${name} ${lib.escapeShellArg (toString extraEnv.${name})}"
|
||||
) (lib.attrNames extraEnv);
|
||||
|
||||
extraRunFlags = lib.concatMapStrings (line: " \\\n --run ${lib.escapeShellArg line}") extraRun;
|
||||
|
||||
electronHeaders = pkgs.fetchurl {
|
||||
url = "https://artifacts.electronjs.org/headers/dist/v${electron.version}/node-v${electron.version}-headers.tar.gz";
|
||||
sha256 = "sha256-f8bSbLRmtbP93CJAvEBs+sHWDZ1xP2bcpLhC1EnOmZU=";
|
||||
};
|
||||
|
||||
# node-pty ships no Electron-tagged prebuild we can trust to match this
|
||||
# exact nixpkgs electron version, so it's always compiled from source
|
||||
# against Electron's own headers (not whatever Node ran `npm`).
|
||||
targetPlatform =
|
||||
if stdenv.hostPlatform.isDarwin then
|
||||
"darwin"
|
||||
else if stdenv.hostPlatform.isLinux then
|
||||
"linux"
|
||||
else
|
||||
throw "hermes-desktop: unsupported host platform for node-pty staging";
|
||||
|
||||
targetArch =
|
||||
if stdenv.hostPlatform.isAarch64 then
|
||||
"arm64"
|
||||
else if stdenv.hostPlatform.isx86_64 then
|
||||
"x64"
|
||||
else
|
||||
throw "hermes-desktop: unsupported host arch for node-pty staging";
|
||||
|
||||
# Build the renderer (dist/ + electron/ + package.json).
|
||||
renderer = hermesNpmLib.buildNpmPackage {
|
||||
dirs = [
|
||||
"apps/desktop"
|
||||
"apps/shared"
|
||||
];
|
||||
pname = "hermes-desktop-renderer";
|
||||
|
||||
doCheck = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
mkdir -p apps/desktop/build
|
||||
|
||||
patchShebangs .
|
||||
|
||||
pushd apps/desktop
|
||||
# typecheck :3
|
||||
npm exec -- tsc -b
|
||||
|
||||
# build the renderer bundle
|
||||
# vite's emptyOutDir wipes dist/ on every run
|
||||
# so it has to be first
|
||||
npm exec -- vite build
|
||||
|
||||
# build the electron bundle
|
||||
node scripts/bundle-electron-main.mjs
|
||||
|
||||
# Compile node-pty against Electron's actual ABI (the nixpkgs
|
||||
# `electron` we ship). Headers come from a pinned fetchurl input
|
||||
# since the sandbox has no network here, so node-gyp's
|
||||
# normal --disturl download path can't run.
|
||||
mkdir -p "$TMPDIR/electron-headers"
|
||||
tar -xzf ${electronHeaders} -C "$TMPDIR/electron-headers" --strip-components=1
|
||||
|
||||
${lib.getExe hermesNpmLib.node-gyp} rebuild \
|
||||
--directory=../../node_modules/node-pty \
|
||||
--build-from-source \
|
||||
--runtime=electron \
|
||||
--target=${electron.version} \
|
||||
--nodedir="$TMPDIR/electron-headers" \
|
||||
--disturl="" \
|
||||
--offline
|
||||
|
||||
# Target platform/arch come from stdenv.hostPlatform, not the
|
||||
# build host's own process.platform/arch.
|
||||
node scripts/stage-native-deps.mjs ${targetPlatform} ${targetArch}
|
||||
popd
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
checkPhase = ''
|
||||
runHook preCheck
|
||||
|
||||
pushd apps/desktop
|
||||
|
||||
npm run postbuild
|
||||
|
||||
# validate staged node-pty native binary is present.
|
||||
STAGED_PTY_NODE="./dist/node_modules/node-pty/build/Release/pty.node"
|
||||
|
||||
if [ ! -f "$STAGED_PTY_NODE" ]; then
|
||||
echo "FATAL: Missing staged node-pty native binary at $STAGED_PTY_NODE"
|
||||
echo "node-pty must be compiled natively"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
popd
|
||||
|
||||
runHook postCheck
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out
|
||||
# vite writes to apps/desktop/dist/ (we cd'd there in buildPhase).
|
||||
# stage-native-deps.mjs stages node-pty into dist/node_modules/node-pty,
|
||||
# so copying dist/ wholesale carries the native dep along with the
|
||||
# esbuild bundle that require()s it. apps/desktop/build was created
|
||||
# before the cd.
|
||||
cp -rn apps/desktop/dist $out/
|
||||
|
||||
echo '{"schemaVersion":1,"commit":"nix-dummy-commit","branch":"nix","dirty":false,"source":"nix"}' > $out/install-stamp.json
|
||||
|
||||
cp -n apps/desktop/package.json $out/
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
in
|
||||
|
||||
# Electron wrapper: nixpkgs' electron binary pointed at the renderer dir.
|
||||
stdenv.mkDerivation {
|
||||
pname = "hermes-desktop";
|
||||
inherit (renderer) version;
|
||||
|
||||
dontUnpack = true;
|
||||
dontBuild = true;
|
||||
|
||||
nativeBuildInputs = [
|
||||
makeWrapper
|
||||
python3
|
||||
];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/share/hermes-desktop $out/bin
|
||||
cp -r ${renderer}/* $out/share/hermes-desktop/
|
||||
|
||||
# Standard nixpkgs pattern for electron-builder apps: patch process.resourcesPath
|
||||
# to point to the app's directory. In Nix, unpackaged electron defaults this
|
||||
# to the electron distribution's resources path, breaking extraResources lookups.
|
||||
substituteInPlace $out/share/hermes-desktop/dist/electron-main.mjs \
|
||||
--replace-fail "process.resourcesPath" "'$out/share/hermes-desktop'"
|
||||
|
||||
# Wrap the nixpkgs electron binary to launch our app. Set
|
||||
# HERMES_DESKTOP_HERMES to the absolute path of the nix-built `hermes`
|
||||
# binary so the desktop's resolver step 4 ("existing Hermes CLI on
|
||||
# PATH") uses our fully wrapped binary — venv with all deps,
|
||||
# bundled skills/plugins, runtime PATH (ripgrep/git/ffmpeg/etc).
|
||||
# No reimplementation of the agent resolver in the wrapper.
|
||||
makeWrapper ${lib.getExe electron} $out/bin/hermes-desktop \
|
||||
--add-flags "$out/share/hermes-desktop" \
|
||||
--set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}" \
|
||||
--set ELECTRON_IS_DEV 0${extraEnvFlags}${extraRunFlags}
|
||||
|
||||
# XDG launcher entry
|
||||
mkdir -p $out/share/applications $out/share/icons/hicolor/1024x1024/apps
|
||||
install -m 0644 ${../apps/desktop/assets/icon.png} \
|
||||
$out/share/icons/hicolor/1024x1024/apps/hermes.png
|
||||
export PYTHONPATH=$(mktemp -d)
|
||||
cp ${../hermes_cli/linux_desktop_entry.py} "$PYTHONPATH/linux_desktop_entry.py"
|
||||
export DESKTOP_EXEC="$out/bin/hermes-desktop"
|
||||
export DESKTOP_ICON="$out/share/icons/hicolor/1024x1024/apps/hermes.png"
|
||||
python3 -c 'import os; from linux_desktop_entry import render_desktop_entry; print(render_desktop_entry(os.environ["DESKTOP_EXEC"], os.environ["DESKTOP_ICON"]))' > $out/share/applications/hermes.desktop
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit (renderer.passthru) packageJsonPath;
|
||||
};
|
||||
|
||||
meta = with lib; {
|
||||
description = "Native Electron desktop shell for Hermes Agent";
|
||||
homepage = "https://github.com/NousResearch/hermes-agent";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.unix;
|
||||
mainProgram = "hermes-desktop";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# nix/devShell.nix — Dev shell that delegates setup to each package
|
||||
#
|
||||
# Each npm workspace package exposes passthru.packageJsonPath (e.g.
|
||||
# "ui-tui/package.json"). This file collects them all and passes the
|
||||
# list to mkNpmDevShellHook, which stamps all package.jsons at once,
|
||||
# then runs a single `npm i --package-lock-only` if any changed and
|
||||
# `npm ci` if the lockfile changed.
|
||||
{ ... }:
|
||||
{
|
||||
perSystem =
|
||||
{ pkgs, self', ... }:
|
||||
let
|
||||
packages = builtins.attrValues self'.packages;
|
||||
hermesNpmLib = self'.packages.default.passthru.hermesNpmLib;
|
||||
|
||||
# Collect all packageJsonPath values from npm workspace packages.
|
||||
npmPackageJsonPaths = builtins.filter (p: p != null) (
|
||||
map (p: p.passthru.packageJsonPath or null) packages
|
||||
);
|
||||
|
||||
hermesAgentDevShellHook = self'.packages.default.passthru.devShellHook;
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
(pkgs.runCommand "hermes" { } ''
|
||||
mkdir -p $out/bin
|
||||
install -Dm755 ${../hermes} $out/bin/hermes
|
||||
'')
|
||||
self'.packages.sandbox
|
||||
uv
|
||||
# Headless Wayland compositor for E2E tests (test:e2e:visual).
|
||||
# cage renders a single client with no window management, so
|
||||
# the Electron window opens at a fixed size without tiling.
|
||||
# libglvnd provides libEGL.so.1 that cage needs on NixOS.
|
||||
cage
|
||||
libglvnd
|
||||
# Graphical terminal + Wayland screenshot client for CLI/TUI UI
|
||||
# evidence. `cage -- ghostty ...` keeps captures off the user's
|
||||
# live compositor; grim runs inside that isolated client session.
|
||||
ghostty
|
||||
grim
|
||||
]
|
||||
++ self'.packages.default.passthru.devDeps;
|
||||
shellHook = ''
|
||||
${hermesAgentDevShellHook}
|
||||
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths}
|
||||
|
||||
# Force Node to use Nix's playwright-test binary instead of node_modules/.bin
|
||||
export PATH="${pkgs.playwright-test}/bin:$PATH"
|
||||
|
||||
# for the devshell to pick up the src
|
||||
export HERMES_PYTHON_SRC_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
# Let `uv run --active --no-sync` reuse Nix's provisioned Python
|
||||
# environment instead of creating an empty project .venv.
|
||||
export VIRTUAL_ENV="$(dirname "$(dirname "$(readlink -f "$(command -v python)")")")"
|
||||
|
||||
echo "Hermes Agent dev shell in $HERMES_PYTHON_SRC_ROOT"
|
||||
echo "Ready. Run 'hermes' or 'sandbox hermes' to start."
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
# 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 = [...]; }
|
||||
# pkgs.hermes-agent.override { extraDependencyGroups = [ "hindsight" ]; }
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
makeWrapper,
|
||||
callPackage,
|
||||
python312,
|
||||
electron,
|
||||
ripgrep,
|
||||
git,
|
||||
openssh,
|
||||
ffmpeg,
|
||||
tirith,
|
||||
|
||||
# linux-only deps
|
||||
wl-clipboard,
|
||||
xclip,
|
||||
|
||||
# linux-only dev deps
|
||||
cage,
|
||||
|
||||
# Flake inputs — passed explicitly by packages.nix and overlays.nix
|
||||
uv2nix,
|
||||
pyproject-nix,
|
||||
pyproject-build-systems,
|
||||
npm-lockfile-fix,
|
||||
# Locked git revision of the flake source — embedded so banner.py can
|
||||
# check for updates without needing a local .git directory. Null for
|
||||
# impure / dirty builds where flakes can't determine a rev.
|
||||
rev ? null,
|
||||
# Overridable parameters
|
||||
extraPythonPackages ? [ ],
|
||||
extraDependencyGroups ? [ ],
|
||||
}:
|
||||
let
|
||||
mkHermesVenv =
|
||||
extraDependencyGroups:
|
||||
callPackage ./python.nix {
|
||||
inherit uv2nix pyproject-nix pyproject-build-systems;
|
||||
pythonSrc = hermesNpmLib.pythonSrc;
|
||||
dependency-groups = [ "all" ] ++ extraDependencyGroups;
|
||||
};
|
||||
|
||||
hermesVenv = (mkHermesVenv extraDependencyGroups).venv;
|
||||
|
||||
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) && !(lib.hasInfix "/__pycache__/" path);
|
||||
};
|
||||
|
||||
# Optional skills are NOT in the wheel (pythonSrc excludes them, see
|
||||
# lib.nix) — the wrapper exposes them via HERMES_OPTIONAL_SKILLS, the
|
||||
# same mechanism Homebrew packaging uses.
|
||||
bundledOptionalSkills = lib.cleanSourceWith {
|
||||
src = ../optional-skills;
|
||||
filter = path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
|
||||
};
|
||||
|
||||
# Import bundled plugins (memory, context_engine, platforms/*). Keeping
|
||||
# them out of the Python site-packages keeps import semantics identical
|
||||
# to a dev checkout — the loader reads them from HERMES_BUNDLED_PLUGINS.
|
||||
bundledPlugins = lib.cleanSourceWith {
|
||||
src = ../plugins;
|
||||
filter = path: _type: !(lib.hasInfix "/__pycache__/" path);
|
||||
};
|
||||
|
||||
# i18n locale catalogs (locales/*.yaml). Shipped into the store and pointed
|
||||
# at by HERMES_BUNDLED_LOCALES so the wrapped binary always resolves human
|
||||
# strings instead of raw i18n keys (#23943 / #27632 / #35374).
|
||||
bundledLocales = lib.cleanSource ../locales;
|
||||
|
||||
# Shipped MCP catalog (optional-mcps/<name>/manifest.yaml). Same bare-data-dir
|
||||
# case as locales: not a Python package, so it's symlinked into the store and
|
||||
# exposed via HERMES_OPTIONAL_MCPS.
|
||||
bundledOptionalMcps = lib.cleanSourceWith {
|
||||
src = ../optional-mcps;
|
||||
filter = path: _type: !(lib.hasInfix "/__pycache__/" path);
|
||||
};
|
||||
|
||||
runtimeDeps = [
|
||||
hermesNpmLib.nodejs
|
||||
ripgrep
|
||||
git
|
||||
openssh
|
||||
ffmpeg
|
||||
tirith
|
||||
]
|
||||
++ lib.optionals stdenv.isLinux [
|
||||
wl-clipboard
|
||||
xclip
|
||||
];
|
||||
|
||||
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;
|
||||
|
||||
checkPackageCollisions = ''
|
||||
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.')
|
||||
'';
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "hermes-agent";
|
||||
version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version;
|
||||
|
||||
dontUnpack = true;
|
||||
dontBuild = true;
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Symlinks, not copies: these are all store paths already, and the
|
||||
# wrapper env vars just hold paths. Symlinking keeps this derivation
|
||||
# near-instant when only the venv changed, with an identical closure.
|
||||
mkdir -p $out/share/hermes-agent $out/bin
|
||||
ln -s ${bundledSkills} $out/share/hermes-agent/skills
|
||||
ln -s ${bundledOptionalSkills} $out/share/hermes-agent/optional-skills
|
||||
ln -s ${bundledPlugins} $out/share/hermes-agent/plugins
|
||||
ln -s ${bundledLocales} $out/share/hermes-agent/locales
|
||||
ln -s ${bundledOptionalMcps} $out/share/hermes-agent/optional-mcps
|
||||
ln -s ${hermesWeb} $out/share/hermes-agent/web_dist
|
||||
ln -s ${hermesTui}/lib/hermes-tui $out/ui-tui
|
||||
|
||||
${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_OPTIONAL_SKILLS $out/share/hermes-agent/optional-skills \
|
||||
--set HERMES_BUNDLED_PLUGINS $out/share/hermes-agent/plugins \
|
||||
--set HERMES_BUNDLED_LOCALES $out/share/hermes-agent/locales \
|
||||
--set HERMES_OPTIONAL_MCPS $out/share/hermes-agent/optional-mcps \
|
||||
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
|
||||
--set HERMES_TUI_DIR $out/ui-tui \
|
||||
--set-default HERMES_BIN $out/bin/hermes \
|
||||
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
|
||||
--set HERMES_NODE ${lib.getExe hermesNpmLib.nodejs}${
|
||||
# Fold the line continuation INTO the optionalString: a bare
|
||||
# `\` on the line above an empty expansion would dangle onto a
|
||||
# blank line, ending the makeWrapper command early and running
|
||||
# the next flag as its own shell command (`--suffix: command
|
||||
# not found`). Only reproduces when rev == null (dirty trees).
|
||||
lib.optionalString (rev != null) " \\\n --set HERMES_REVISION ${rev}"
|
||||
}${
|
||||
lib.optionalString (
|
||||
extraPythonPackages != [ ]
|
||||
) " \\\n --suffix PYTHONPATH : \"${pythonPath}\""
|
||||
}
|
||||
'')
|
||||
[
|
||||
"hermes"
|
||||
"hermes-agent"
|
||||
"hermes-acp"
|
||||
]
|
||||
}
|
||||
|
||||
${lib.optionalString (extraPythonPackages != [ ]) ''
|
||||
echo "=== Checking for plugin/core package collisions ==="
|
||||
${hermesVenv}/bin/python3 -c "${checkPackageCollisions}"
|
||||
echo "=== No collisions ==="
|
||||
''}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru =
|
||||
let
|
||||
devPython = (mkHermesVenv (extraDependencyGroups ++ [ "dev" ])).editableVenv;
|
||||
in
|
||||
{
|
||||
inherit
|
||||
hermesTui
|
||||
hermesWeb
|
||||
hermesNpmLib
|
||||
hermesVenv
|
||||
;
|
||||
|
||||
# `hermesDesktop` references `finalAttrs.finalPackage` (this whole
|
||||
# derivation, after all overrides are applied) so the desktop wrapper
|
||||
# can prepend its `/bin` to PATH. The desktop's resolver step 4
|
||||
# ("existing hermes on PATH") then picks up the fully wrapped
|
||||
# `hermes` binary — venv with all deps, bundled skills/plugins,
|
||||
# runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation
|
||||
# of the agent resolution in the desktop wrapper.
|
||||
hermesDesktop = callPackage ./desktop.nix {
|
||||
inherit hermesNpmLib electron;
|
||||
hermesAgent = finalAttrs.finalPackage;
|
||||
};
|
||||
|
||||
devShellHook = ''
|
||||
export HERMES_PYTHON=${devPython}/bin/python3
|
||||
'';
|
||||
|
||||
devDeps =
|
||||
runtimeDeps
|
||||
++ [
|
||||
devPython
|
||||
]
|
||||
++ lib.optionals stdenv.isLinux [
|
||||
cage # for running e2e tests without popping windows
|
||||
];
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
})
|
||||
@@ -0,0 +1,428 @@
|
||||
# nix/homeManagerModules.nix — the Home Manager module for hermes-agent
|
||||
#
|
||||
# This module is the user-level equivalent of nixosModules.default. Hermes is
|
||||
# an agent for one person. The credentials, the memory, the sessions and the
|
||||
# cron jobs all belong to that person. Thus a user-level module is correct on
|
||||
# each distribution, and not only on NixOS.
|
||||
#
|
||||
# `services.hermes-agent` is the same option set on both modules. All of the
|
||||
# options except the system-level ones come from nix/moduleCommon.nix, so an
|
||||
# example from the NixOS documentation works here without a change. Only the
|
||||
# necessary parts are different:
|
||||
#
|
||||
# removed user, group, createUser — Home Manager runs as the user
|
||||
# removed container.* — it needs root and the Docker socket
|
||||
# removed UMask 0007 — that mode shares state with a UNIX
|
||||
# group, but this state has one user
|
||||
# changed systemd.services -> systemd.user.services or
|
||||
# launchd.agents
|
||||
# changed system.activationScripts -> home.activation
|
||||
# changed addToSystemPackages -> programs.hermes-agent.enable and
|
||||
# home.sessionVariables
|
||||
# added programs.hermes-agent the CLI and the desktop application,
|
||||
# because Home Manager separates an
|
||||
# installation from a daemon
|
||||
# changed stateDir (+ "/.hermes") -> hermesHome, set directly
|
||||
#
|
||||
# To use the module:
|
||||
# imports = [ hermes-agent.homeManagerModules.default ];
|
||||
# programs.hermes-agent = {
|
||||
# enable = true; # the hermes CLI on your PATH
|
||||
# desktop.enable = true; # the Electron application and a launcher
|
||||
# };
|
||||
# services.hermes-agent = {
|
||||
# enable = true;
|
||||
# gateway.enable = true;
|
||||
# settings.model.default = "anthropic/claude-sonnet-4";
|
||||
# environmentFiles = [ config.sops.secrets."hermes/env".path ];
|
||||
# };
|
||||
#
|
||||
# CAUTION: Enable linger for the account. Without linger, systemd stops the
|
||||
# user manager at logout, and both units stop with it. Home Manager cannot
|
||||
# run `loginctl enable-linger`. On NixOS, set
|
||||
# users.users.<name>.linger = true;
|
||||
# On other systems, run `loginctl enable-linger <name>` one time.
|
||||
{ inputs, ... }:
|
||||
{
|
||||
flake.homeManagerModules.default =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
options,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.services.hermes-agent;
|
||||
cfgPrograms = config.programs.hermes-agent;
|
||||
common = import ./moduleCommon.nix { inherit lib; };
|
||||
|
||||
effectivePackage = common.effectivePackage cfg;
|
||||
hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
|
||||
inherit (pkgs.stdenv.hostPlatform) isDarwin isLinux;
|
||||
|
||||
processEnvironment = common.processEnvironment {
|
||||
inherit (cfg) hermesHome;
|
||||
# The CLI reads this value and names it when it refuses a
|
||||
# configuration change.
|
||||
managedSystem = "home-manager";
|
||||
};
|
||||
unitPath = lib.makeBinPath (common.processPath { inherit pkgs cfg; });
|
||||
|
||||
# ── The desktop launcher ───────────────────────────────────────────
|
||||
# A GUI launcher reads no shell profile, so home.sessionVariables does
|
||||
# not reach it, and the application would open ~/.hermes while the
|
||||
# services use hermesHome. Thus the launcher carries the value itself.
|
||||
#
|
||||
# HERMES_MANAGED rides along only when the services are enabled. That
|
||||
# variable makes the CLI refuse a configuration change and name the
|
||||
# rebuild command. A person who enables `programs.` alone has no
|
||||
# activation and no managed configuration, so the application must not
|
||||
# claim one and refuse an edit that nothing else owns.
|
||||
desktopEnvironment = {
|
||||
HERMES_HOME = cfg.hermesHome;
|
||||
}
|
||||
// lib.optionalAttrs cfg.enable {
|
||||
inherit (processEnvironment) HERMES_MANAGED;
|
||||
}
|
||||
// lib.optionalAttrs desktopUsesService {
|
||||
HERMES_DESKTOP_REMOTE_URL = "http://${cfg.backend.host}:${toString cfg.backend.port}";
|
||||
};
|
||||
|
||||
# The application reaches the backend of the service only when there is
|
||||
# a backend to reach AND a shared token to present with. Without the
|
||||
# token the desktop resolver throws ("HERMES_DESKTOP_REMOTE_URL is set
|
||||
# but HERMES_DESKTOP_REMOTE_TOKEN is not"), so the two variables travel
|
||||
# together or not at all.
|
||||
desktopUsesService = cfg.enable && cfg.backend.mode != "none" && cfg.backend.sessionTokenFile != null;
|
||||
|
||||
# The token is read at start time and never with `--set`. makeWrapper
|
||||
# writes a --set value into the Nix store, which all users can read.
|
||||
desktopRun = lib.optional desktopUsesService ''
|
||||
if [ -r ${lib.escapeShellArg cfg.backend.sessionTokenFile} ]; then
|
||||
HERMES_DESKTOP_REMOTE_TOKEN="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.backend.sessionTokenFile})"
|
||||
export HERMES_DESKTOP_REMOTE_TOKEN
|
||||
else
|
||||
echo "hermes-desktop: cannot read the session token at ${cfg.backend.sessionTokenFile}." >&2
|
||||
echo "hermes-desktop: the application starts its own backend instead of the one of the service." >&2
|
||||
fi
|
||||
'';
|
||||
|
||||
# `override`, and not `overrideAttrs`: the values go into the wrapper
|
||||
# that the installPhase writes, and not into a derivation attribute.
|
||||
desktopPackage = cfgPrograms.desktop.package.override {
|
||||
extraEnv = desktopEnvironment;
|
||||
extraRun = desktopRun;
|
||||
};
|
||||
|
||||
# The systemd unit that the gateway and the backend both start from.
|
||||
mkUnit =
|
||||
{
|
||||
description,
|
||||
argv,
|
||||
}:
|
||||
{
|
||||
Unit = {
|
||||
Description = description;
|
||||
# Do not use network-online.target here. That is a system target.
|
||||
# A user unit that orders against it has no effect, and systemd
|
||||
# gives no message.
|
||||
After = [ "default.target" ];
|
||||
};
|
||||
Install.WantedBy = [ "default.target" ];
|
||||
Service = {
|
||||
Type = "simple";
|
||||
Environment = (lib.mapAttrsToList (k: v: "${k}=${v}") processEnvironment) ++ [
|
||||
"PATH=${unitPath}"
|
||||
];
|
||||
ExecStart = lib.escapeShellArgs argv;
|
||||
WorkingDirectory = cfg.workingDirectory;
|
||||
Restart = cfg.restart;
|
||||
RestartSec = cfg.restartSec;
|
||||
# This state has one user. Keep it private. The NixOS module uses
|
||||
# 0007 to share the state with a UNIX group.
|
||||
UMask = "0077";
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
};
|
||||
};
|
||||
|
||||
mkAgent =
|
||||
{ argv, logName }:
|
||||
{
|
||||
enable = true;
|
||||
config = {
|
||||
Label = "org.nix-community.home.${logName}";
|
||||
ProgramArguments = argv;
|
||||
EnvironmentVariables = processEnvironment // {
|
||||
PATH = "${unitPath}:/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
};
|
||||
WorkingDirectory = cfg.workingDirectory;
|
||||
RunAtLoad = true;
|
||||
KeepAlive =
|
||||
if cfg.restart == "always" then
|
||||
true
|
||||
else
|
||||
{
|
||||
SuccessfulExit = false;
|
||||
Crashed = true;
|
||||
};
|
||||
ThrottleInterval = cfg.restartSec;
|
||||
StandardOutPath = "${config.home.homeDirectory}/Library/Logs/${logName}.log";
|
||||
StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/${logName}.err.log";
|
||||
ProcessType = "Background";
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
# ── programs.hermes-agent — the installation ───────────────────────
|
||||
# Home Manager separates "install this application for me" from "run
|
||||
# this daemon". Hermes needs both, and a person can want one without
|
||||
# the other: an application with no gateway, or a headless gateway on
|
||||
# a machine with no display.
|
||||
#
|
||||
# `services.hermes-agent` stays the authority for the state and the
|
||||
# configuration. This module reads hermesHome and the backend address
|
||||
# from it, and never the reverse.
|
||||
options.programs.hermes-agent = {
|
||||
enable = lib.mkEnableOption ''
|
||||
the Hermes Agent command line application.
|
||||
|
||||
This adds `hermes` to home.packages, and exports HERMES_HOME with
|
||||
home.sessionVariables. An interactive shell then uses the same
|
||||
state as `services.hermes-agent`
|
||||
'';
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = effectivePackage;
|
||||
defaultText = lib.literalExpression "config.services.hermes-agent.package";
|
||||
description = ''
|
||||
The hermes-agent package to install.
|
||||
|
||||
The default follows `services.hermes-agent.package`, and applies
|
||||
`extraPythonPackages` and `extraDependencyGroups` from that
|
||||
module. Thus the command line and the services are one build,
|
||||
and a plugin that the services can load is a plugin that your
|
||||
shell can load.
|
||||
'';
|
||||
};
|
||||
|
||||
desktop = {
|
||||
enable = lib.mkEnableOption ''
|
||||
the Hermes Desktop application (Electron).
|
||||
|
||||
This adds `hermes-desktop` to home.packages, with an XDG
|
||||
launcher entry on Linux. The launcher starts the same Hermes
|
||||
runtime that `package` gives, and reads the HERMES_HOME of
|
||||
`services.hermes-agent`. Thus the application, the interactive
|
||||
shell and the services share one state directory.
|
||||
|
||||
The Electron application carries its own Hermes runtime with
|
||||
the usual distribution. This module gives it the Nix package
|
||||
instead, with HERMES_DESKTOP_HERMES. It installs no second copy
|
||||
of Hermes, and it downloads nothing on the first start
|
||||
'';
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = cfgPrograms.package.hermesDesktop;
|
||||
defaultText = lib.literalExpression "config.programs.hermes-agent.package.hermesDesktop";
|
||||
description = ''
|
||||
The hermes-desktop package to use.
|
||||
|
||||
The default follows `package`, and thus also
|
||||
`services.hermes-agent.extraPythonPackages` and
|
||||
`extraDependencyGroups`, because the desktop application is a
|
||||
passthru of the agent package. A package that you set here
|
||||
carries its own Hermes runtime, and this module cannot make
|
||||
it agree with the services.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
options.services.hermes-agent =
|
||||
common.sharedOptions {
|
||||
defaultPackage = hermes-agent;
|
||||
defaultPackageText = lib.literalExpression "hermes-agent.packages.\${system}.default";
|
||||
defaultWorkingDirectory = config.home.homeDirectory;
|
||||
defaultWorkingDirectoryText = lib.literalExpression "config.home.homeDirectory";
|
||||
}
|
||||
// {
|
||||
hermesHome = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "${config.home.homeDirectory}/.hermes";
|
||||
defaultText = lib.literalExpression ''"''${config.home.homeDirectory}/.hermes"'';
|
||||
description = ''
|
||||
The value of HERMES_HOME. This state directory holds
|
||||
config.yaml, .env, auth.json, the sessions, the skills, the
|
||||
memory and the cron jobs.
|
||||
|
||||
The NixOS module takes a `stateDir` and adds `/.hermes` to it.
|
||||
This module sets HERMES_HOME directly. Thus an existing
|
||||
~/.hermes continues to work, and you can give the directory any
|
||||
name.
|
||||
'';
|
||||
example = "/home/alice/.hermes-work";
|
||||
};
|
||||
|
||||
# `installPackage` moved to `programs.hermes-agent.enable`. The
|
||||
# option is dead, but it must not be silent: it defaulted to true,
|
||||
# so a person who never named it still got the command line, and a
|
||||
# quiet removal gives them a machine with no `hermes` and no
|
||||
# message. mkOption with an assertion, and not
|
||||
# mkRemovedOptionModule, because the message must name the exact
|
||||
# replacement for the value they set.
|
||||
installPackage = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.bool;
|
||||
default = null;
|
||||
visible = false;
|
||||
description = ''
|
||||
Removed. Use `programs.hermes-agent.enable` instead.
|
||||
'';
|
||||
};
|
||||
|
||||
gateway.enable = lib.mkEnableOption "the messaging gateway service (Telegram, Discord, Slack, ...)";
|
||||
};
|
||||
|
||||
config = lib.mkMerge [
|
||||
|
||||
# ── programs.hermes-agent — the installation ──────────────────────
|
||||
# Outside the `services.enable` guard on purpose. A person can want
|
||||
# the command line or the application on a machine that runs no
|
||||
# daemon at all.
|
||||
(lib.mkIf cfgPrograms.enable {
|
||||
home.packages = [ cfgPrograms.package ];
|
||||
home.sessionVariables.HERMES_HOME = cfg.hermesHome;
|
||||
})
|
||||
|
||||
# A launcher from the desktop menu reads no shell profile, so the
|
||||
# HERMES_HOME that `programs.enable` exports does not reach it. Home
|
||||
# Manager writes only systemd.user.sessionVariables into
|
||||
# environment.d, and this module does not put HERMES_HOME there,
|
||||
# because that file applies to each user unit. Thus the launcher
|
||||
# carries the value itself. See desktopEnvironment above.
|
||||
(lib.mkIf cfgPrograms.desktop.enable {
|
||||
home.packages = [ desktopPackage ];
|
||||
})
|
||||
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
# `installPackage` was removed in favour of the programs/services
|
||||
# split. It defaulted to true, so a quiet removal leaves a person
|
||||
# with no `hermes` on the PATH and no message.
|
||||
assertion = cfg.installPackage == null;
|
||||
message = common.installPackageRemovedMessage cfg.installPackage;
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
(lib.mkIf cfg.enable (
|
||||
lib.mkMerge [
|
||||
|
||||
# ── Merge MCP servers into settings ────────────────────────────
|
||||
(lib.mkIf (cfg.mcpServers != { }) {
|
||||
services.hermes-agent.settings.mcp_servers = common.mcpServersToConfig cfg.mcpServers;
|
||||
})
|
||||
|
||||
{
|
||||
assertions =
|
||||
common.pluginNameAssertions {
|
||||
inherit cfg;
|
||||
optionPath = "services.hermes-agent";
|
||||
}
|
||||
++ common.workspaceFilesAssertions {
|
||||
inherit cfg;
|
||||
opt = options.services.hermes-agent.workingDirectory;
|
||||
optionPath = "services.hermes-agent";
|
||||
}
|
||||
++ common.backendBindAssertions {
|
||||
inherit cfg;
|
||||
optionPath = "services.hermes-agent";
|
||||
}
|
||||
++ [
|
||||
{
|
||||
# The interface poll reads `ip`, which iproute2 supplies on
|
||||
# Linux only.
|
||||
assertion = !isDarwin || cfg.backend.waitFor != "interface";
|
||||
message = "services.hermes-agent.backend.waitFor = \"interface\" works on Linux only. Use \"hostname\" on Darwin.";
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
# The agent runs these tools, so they belong on the PATH of the
|
||||
# person as well as in the unit.
|
||||
(lib.mkIf cfgPrograms.enable {
|
||||
home.packages = cfg.extraPackages;
|
||||
})
|
||||
|
||||
# ── Activation: directories, config, secrets, documents ────────
|
||||
{
|
||||
# The activation runs after writeBoundary, when the home.file
|
||||
# symlinks are in place. It also runs after linkGeneration, when
|
||||
# Home Manager completes the switch. A secret that the activation
|
||||
# entry of sops-nix writes exists at that point.
|
||||
home.activation.hermesAgentSetup =
|
||||
lib.hm.dag.entryAfter
|
||||
[
|
||||
"writeBoundary"
|
||||
"linkGeneration"
|
||||
]
|
||||
(
|
||||
common.mkStateScript {
|
||||
inherit pkgs cfg;
|
||||
inherit (cfg) hermesHome workingDirectory;
|
||||
run = "$DRY_RUN_CMD ";
|
||||
stateDirs = common.stateSubdirs;
|
||||
managedSystem = "home-manager";
|
||||
# This state has one user. No group needs access to it.
|
||||
modes = {
|
||||
config = "0600";
|
||||
env = "0600";
|
||||
managed = "0600";
|
||||
auth = "0600";
|
||||
document = "0600";
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
# ── Linux: systemd user services ───────────────────────────────
|
||||
(lib.mkIf (isLinux && cfg.gateway.enable) {
|
||||
systemd.user.services.hermes-agent = mkUnit {
|
||||
description = "Hermes Agent Gateway";
|
||||
argv = common.gatewayArgv cfg;
|
||||
};
|
||||
})
|
||||
|
||||
(lib.mkIf (isLinux && cfg.backend.mode != "none") {
|
||||
systemd.user.services.hermes-backend = mkUnit {
|
||||
description = common.backendDescription cfg;
|
||||
argv = common.backendArgv { inherit pkgs cfg; };
|
||||
};
|
||||
})
|
||||
|
||||
# ── Darwin: launchd agents ─────────────────────────────────────
|
||||
(lib.mkIf (isDarwin && cfg.gateway.enable) {
|
||||
launchd.agents.hermes-agent = mkAgent {
|
||||
argv = common.gatewayArgv cfg;
|
||||
logName = "hermes-agent";
|
||||
};
|
||||
})
|
||||
|
||||
(lib.mkIf (isDarwin && cfg.backend.mode != "none") {
|
||||
launchd.agents.hermes-backend = mkAgent {
|
||||
argv = common.backendArgv { inherit pkgs cfg; };
|
||||
logName = "hermes-backend";
|
||||
};
|
||||
})
|
||||
]
|
||||
))
|
||||
];
|
||||
};
|
||||
}
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
# nix/lib.nix — Shared helpers for nix stuff
|
||||
#
|
||||
# All npm packages in this repo are workspace members sharing a single
|
||||
# root package-lock.json. mkNpmPassthru provides the shared npmDeps,
|
||||
# npmRoot, and npmConfigHook so individual .nix files don't duplicate them.
|
||||
#
|
||||
# Source filters (pythonSrc, per-package npm srcs) reduce rebuild scope so
|
||||
# that e.g. a .tsx change doesn't trigger a Python venv rebuild, and a .py
|
||||
# change doesn't trigger a TUI/Web/Desktop rebuild. Each derivation gets a
|
||||
# filtered src that only includes files it actually needs, while keeping
|
||||
# the repo-root directory layout intact for buildNpmPackage /
|
||||
# npmConfigHook workspace resolution.
|
||||
#
|
||||
# mkNpmPassthru returns packageJsonPath (e.g. "ui-tui/package.json")
|
||||
# instead of a per-package devShellHook. The root devshell hook
|
||||
# (mkNpmDevShellHook) collects all package.json paths, stamps them,
|
||||
# and if any changed, runs a single `npm i --package-lock-only` from
|
||||
# root to update the lockfile, then `npm ci` if the lockfile changed.
|
||||
{
|
||||
lib,
|
||||
npm-lockfile-fix,
|
||||
importNpmLock,
|
||||
writeShellScriptBin,
|
||||
writeShellScript,
|
||||
coreutils,
|
||||
callPackage,
|
||||
nodejs_26,
|
||||
symlinkJoin,
|
||||
buildNpmPackage,
|
||||
runCommand,
|
||||
}:
|
||||
let
|
||||
repoRoot = ./..;
|
||||
|
||||
npm12 = callPackage ./npm-12-0-2.nix { };
|
||||
node_gyp_11_4_0 = callPackage ./node-gyp-11-4-0.nix { };
|
||||
nodejs_26_npm_12 = symlinkJoin {
|
||||
name = "nodejs-26-npm-12";
|
||||
paths = [
|
||||
npm12
|
||||
nodejs_26
|
||||
];
|
||||
inherit (nodejs_26) meta passthru;
|
||||
};
|
||||
|
||||
nodejs = nodejs_26_npm_12;
|
||||
|
||||
# Patched hook: just a new derivation that copies and patches the script
|
||||
patchedNpmConfigHook = runCommand "npm-config-hook-patched" { } ''
|
||||
mkdir -p $out/nix-support
|
||||
# Copy all support files from the original hook
|
||||
cp -r ${importNpmLock.npmConfigHook}/nix-support/* $out/nix-support/
|
||||
|
||||
# Change the node gyp config var to avoid the warning with npm12
|
||||
# Replace the node-gyp path with the newer one that supports the new config var
|
||||
substituteInPlace $out/nix-support/setup-hook \
|
||||
--replace-fail 'npm_config_nodedir' 'npm_package_config_node_gyp_nodedir' \
|
||||
--replace-fail 'npm_config_node_gyp' 'npm_config_node_gyp=${node_gyp_11_4_0}/bin/node-gyp'
|
||||
'';
|
||||
|
||||
# ── npm workspace discovery ────────────────────────────────────────
|
||||
# Single source of truth: the `workspaces` field of the root
|
||||
# package.json. Everything below (workspace package.json discovery,
|
||||
# the Python source's JS-dir exclusions) is derived from this so the
|
||||
# topology is never duplicated. Add a workspace to package.json and
|
||||
# the nix build picks it up automatically.
|
||||
rootPackageJson = builtins.fromJSON (builtins.readFile (repoRoot + "/package.json"));
|
||||
|
||||
# Expand a workspace glob (e.g. "apps/*") into concrete member dirs
|
||||
# relative to the repo root. Only trailing "*" globs are supported —
|
||||
# that's all npm uses here. Literal patterns (e.g. "ui-tui") pass
|
||||
# through unchanged.
|
||||
expandWorkspace =
|
||||
pattern:
|
||||
let
|
||||
parts = lib.splitString "/" pattern;
|
||||
in
|
||||
if lib.last parts == "*" then
|
||||
let
|
||||
parent = lib.concatStringsSep "/" (lib.init parts);
|
||||
entries = builtins.readDir (repoRoot + "/${parent}");
|
||||
dirs = lib.filterAttrs (_: t: t == "directory") entries;
|
||||
in
|
||||
map (d: "${parent}/${d}") (builtins.attrNames dirs)
|
||||
else
|
||||
[ pattern ];
|
||||
|
||||
# All workspace member directories (relative paths), filtered to those
|
||||
# that actually carry a package.json — a glob like apps/* may match a
|
||||
# dir that isn't really a package.
|
||||
workspaceMemberDirs = builtins.filter (d: builtins.pathExists (repoRoot + "/${d}/package.json")) (
|
||||
lib.concatMap expandWorkspace rootPackageJson.workspaces
|
||||
);
|
||||
|
||||
# Top-level directory of each workspace member, deduplicated. Used to
|
||||
# exclude JS/TS workspace trees from the Python source filter. E.g.
|
||||
# apps/desktop + apps/shared + ui-tui + web → [ "apps" "ui-tui" "web" ].
|
||||
jsWorkspaceTopDirs = lib.unique (
|
||||
map (d: builtins.head (lib.splitString "/" d)) workspaceMemberDirs
|
||||
);
|
||||
|
||||
# ── Source filters for reducing rebuild scope ──────────────────────
|
||||
# Changing a .tsx/.mjs file should NOT trigger a Python venv rebuild,
|
||||
# and changing a .py file should NOT trigger a TUI/Web/Desktop rebuild.
|
||||
|
||||
# Python source: everything except JS/TS/docs/infra directories.
|
||||
pythonSrc = lib.cleanSourceWith {
|
||||
src = repoRoot;
|
||||
name = "hermes-python-source";
|
||||
filter =
|
||||
path: type:
|
||||
let
|
||||
relPath = lib.removePrefix (toString repoRoot + "/") (toString path);
|
||||
components = lib.splitString "/" relPath;
|
||||
topComponent = if components == [ ] then "" else builtins.head components;
|
||||
excludedDirs =
|
||||
# JS/TS workspace directories — derived from the npm workspaces
|
||||
# so a new workspace member is excluded from the Python source
|
||||
# without touching this list.
|
||||
jsWorkspaceTopDirs ++ [
|
||||
# Documentation
|
||||
"docs"
|
||||
"website"
|
||||
# CI/infra
|
||||
"docker"
|
||||
".github"
|
||||
# Content/examples
|
||||
"infographic"
|
||||
"datagen-config-examples"
|
||||
# unused packaging infra
|
||||
"packaging"
|
||||
# Test infrastructure
|
||||
"tests"
|
||||
# Plan/temp files
|
||||
"plans"
|
||||
# Nix build definitions (Python build doesn't need these)
|
||||
"nix"
|
||||
# Skills are shipped via HERMES_BUNDLED_SKILLS /
|
||||
# HERMES_OPTIONAL_SKILLS (see hermes-agent.nix), not via the
|
||||
# wheel's data_files — setup.py's _data_file_tree returns []
|
||||
# for a missing dir, so the wheel builds fine without them.
|
||||
# This keeps SKILL.md edits from rebuilding the Python venv.
|
||||
"skills"
|
||||
"optional-skills"
|
||||
# locales/ and optional-mcps/ are bare data dirs (no
|
||||
# __init__.py) shipped via symlinks + HERMES_BUNDLED_LOCALES
|
||||
# / HERMES_OPTIONAL_MCPS, not via the wheel. Excluding them
|
||||
# keeps catalog edits from rebuilding the Python venv.
|
||||
"locales"
|
||||
"optional-mcps"
|
||||
];
|
||||
excludedFiles = [
|
||||
# JS root manifests
|
||||
"package.json"
|
||||
"package-lock.json"
|
||||
# Docker files
|
||||
"Dockerfile"
|
||||
"docker-compose.yml"
|
||||
"docker-compose.windows.yml"
|
||||
# Nix build definitions — editing the flake shouldn't rebuild
|
||||
# the venv. (Input changes rebuild regardless, via the lock.)
|
||||
"flake.nix"
|
||||
"flake.lock"
|
||||
# Root docs the wheel doesn't consume. README.md and LICENSE
|
||||
# must stay — pyproject.toml references them (readme /
|
||||
# license-files).
|
||||
"AGENTS.md"
|
||||
"CONTRIBUTING.md"
|
||||
"SECURITY.md"
|
||||
"README.zh-CN.md"
|
||||
".gitignore"
|
||||
"setup-hermes.sh"
|
||||
];
|
||||
in
|
||||
if relPath == "" then
|
||||
true
|
||||
else if builtins.elem relPath excludedFiles then
|
||||
false
|
||||
else if builtins.elem topComponent excludedDirs then
|
||||
false
|
||||
else
|
||||
true;
|
||||
};
|
||||
|
||||
# Common npm workspace resolution files needed by all npm builds.
|
||||
# npm ci requires all workspace package.json files to resolve
|
||||
# workspace: protocol dependencies correctly. Discovered from the
|
||||
# root package.json workspaces — root manifests + every member's
|
||||
# package.json.
|
||||
npmWorkspaceFiles = lib.fileset.unions (
|
||||
[
|
||||
(repoRoot + "/package.json")
|
||||
(repoRoot + "/package-lock.json")
|
||||
]
|
||||
++ map (d: repoRoot + "/${d}/package.json") workspaceMemberDirs
|
||||
);
|
||||
|
||||
# npm deps source: just what importNpmLock needs (root manifests +
|
||||
# workspace member package.jsons). Much smaller than the full repo,
|
||||
# so changing source files won't invalidate the npmDeps derivation.
|
||||
npmDepsSrc = lib.fileset.toSource {
|
||||
root = repoRoot;
|
||||
fileset = npmWorkspaceFiles;
|
||||
};
|
||||
|
||||
# npm dependencies for the workspace, shared by all members. importNpmLock
|
||||
# resolves each package from the lockfile's own `integrity` hashes, so the
|
||||
# lockfile is the single source of truth — no separate dependency hash to
|
||||
# keep in sync with it.
|
||||
npmDeps = importNpmLock.importNpmLock {
|
||||
npmRoot = npmDepsSrc;
|
||||
};
|
||||
|
||||
# Build a per-package npm source: workspace resolution files + the
|
||||
# package's own directory tree(s). Source ROOT is always the repo
|
||||
# root, preserving the workspace layout that buildNpmPackage and
|
||||
# npmConfigHook expect. Callers pass the dirs they need (relative to
|
||||
# the repo root), so each package owns its own source scope.
|
||||
testFileFilter = lib.fileset.fileFilter (file: lib.hasInfix ".test." file.name) repoRoot;
|
||||
mkNpmSrc =
|
||||
dirs:
|
||||
lib.fileset.toSource {
|
||||
root = repoRoot;
|
||||
fileset = lib.fileset.difference (lib.fileset.union npmWorkspaceFiles (
|
||||
lib.fileset.unions (map (d: repoRoot + "/${d}") dirs)
|
||||
)) testFileFilter;
|
||||
};
|
||||
|
||||
# Returns a buildNpmPackage-compatible function.
|
||||
|
||||
# `dirs` is the single source of truth for what the package contains:
|
||||
# its first entry is the package's own folder (→ packageJsonPath), and
|
||||
# all entries scope the filtered src. Packages that import source from
|
||||
# another workspace member (file: deps) must list that member's dir too,
|
||||
# e.g. apps/desktop depends on apps/shared.
|
||||
#
|
||||
# Usage:
|
||||
# hermesNpmLib.buildNpmPackage {
|
||||
# dirs = [ "apps/desktop" "apps/shared" ];
|
||||
# buildPhase = '' ... '';
|
||||
# installPhase = '' ... '';
|
||||
# }
|
||||
customBuildNpmPackage =
|
||||
{ dirs, ... }@attrs:
|
||||
let
|
||||
# The package's own folder is the first dir; it carries the
|
||||
# package.json that buildNpmPackage reads.
|
||||
folder = builtins.head dirs;
|
||||
|
||||
# Read package.json from the repo (the filtered src is a store path, but we can read the original)
|
||||
packageJson = builtins.fromJSON (builtins.readFile (repoRoot + "/${folder}/package.json"));
|
||||
defaultPname = packageJson.name or "unknown";
|
||||
defaultVersion = packageJson.version or "0.0.0";
|
||||
|
||||
common = {
|
||||
inherit nodejs npmDeps;
|
||||
# No sourceRoot — the workspace root (with the single package-lock.json)
|
||||
# is auto-detected as sourceRoot by nix. npmRoot stays at "."
|
||||
# so npmConfigHook finds the lockfile there.
|
||||
src = mkNpmSrc dirs;
|
||||
npmConfigHook = patchedNpmConfigHook;
|
||||
npmRoot = ".";
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
|
||||
passthru = {
|
||||
packageJsonPath = "${folder}/package.json";
|
||||
};
|
||||
};
|
||||
|
||||
# Remove `dirs` from the passed attrs (buildNpmPackage doesn't need it)
|
||||
attrsWithoutDirs = removeAttrs attrs [ "dirs" ];
|
||||
|
||||
finalAttrs =
|
||||
common
|
||||
// attrsWithoutDirs
|
||||
// {
|
||||
pname = attrs.pname or defaultPname;
|
||||
version = attrs.version or defaultVersion;
|
||||
};
|
||||
in
|
||||
buildNpmPackage finalAttrs;
|
||||
in
|
||||
{
|
||||
inherit pythonSrc nodejs;
|
||||
node-gyp = node_gyp_11_4_0;
|
||||
|
||||
# Regenerate the shared root lockfile from scratch and verify all npm
|
||||
# packages still build. Exposed as a runnable package — `nix run
|
||||
# .#update-npm-lockfile` — so it's actually usable, unlike a bin buried
|
||||
# in a build sandbox's PATH. All workspace packages share one lockfile,
|
||||
# so there's a single script (not one per package).
|
||||
updateNpmLockfile = writeShellScriptBin "update-npm-lockfile" ''
|
||||
set -euo pipefail
|
||||
# DEBUG=1 nix run .#update-npm-lockfile — trace every command
|
||||
[ -n "''${DEBUG:-}" ] && set -x
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
rm -rf node_modules/
|
||||
${lib.getExe' nodejs "npm"} cache clean --force
|
||||
CI=true ${lib.getExe' nodejs "npm"} install --workspaces
|
||||
${lib.getExe npm-lockfile-fix} ./package-lock.json
|
||||
|
||||
# importNpmLock reads hashes from the lockfile itself — rebuild every
|
||||
# npm package to verify the new lockfile resolves offline.
|
||||
nix build .#tui .#web .#desktop
|
||||
echo "Lockfile updated and all npm packages built."
|
||||
'';
|
||||
|
||||
buildNpmPackage = customBuildNpmPackage;
|
||||
|
||||
# Single devshell hook for all npm workspace packages.
|
||||
#
|
||||
# Takes a list of package.json relative paths (from mkNpmPassthru .passthru.packageJsonPath),
|
||||
# stamps all of them, and if any changed:
|
||||
# 1. Runs `npm i --package-lock-only` from root to update the lockfile
|
||||
# 2. If the lockfile changed, runs `npm ci`
|
||||
mkNpmDevShellHook =
|
||||
packageJsonPaths:
|
||||
writeShellScript "npm-dev-hook" ''
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
|
||||
# Stamp all workspace package.jsons into one file.
|
||||
STAMP_DIR=".nix-stamps"
|
||||
STAMP="$STAMP_DIR/npm-package-jsons"
|
||||
STAMP_VALUE=$(
|
||||
${coreutils}/bin/sha256sum ${
|
||||
lib.concatMapStringsSep " " (p: "\"$REPO_ROOT/${p}\"") packageJsonPaths
|
||||
} 2>/dev/null | ${coreutils}/bin/sort | ${coreutils}/bin/sha256sum | awk '{print $1}'
|
||||
)
|
||||
|
||||
PKG_CHANGED=false
|
||||
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
|
||||
PKG_CHANGED=true
|
||||
echo "npm: package.json changed, updating lockfile..."
|
||||
( cd "$REPO_ROOT" && ${lib.getExe' nodejs "npm"} i --package-lock-only --silent --no-fund --no-audit 2>/dev/null )
|
||||
mkdir -p "$STAMP_DIR"
|
||||
echo "$STAMP_VALUE" > "$STAMP"
|
||||
fi
|
||||
|
||||
# Check if lockfile changed (either from the npm i above or from an
|
||||
# external edit). Runs npm ci if so.
|
||||
LOCK_STAMP="$STAMP_DIR/root-lockfile"
|
||||
LOCK_STAMP_VALUE=$(sha256sum "$REPO_ROOT/package-lock.json" 2>/dev/null | awk '{print $1}')
|
||||
if [ ! -f "$LOCK_STAMP" ] || [ "$(cat "$LOCK_STAMP")" != "$LOCK_STAMP_VALUE" ]; then
|
||||
echo "npm: package-lock.json changed, running npm ci..."
|
||||
( cd "$REPO_ROOT" && CI=true ${lib.getExe' nodejs "npm"} ci --silent --no-fund --no-audit 2>/dev/null )
|
||||
mkdir -p "$STAMP_DIR"
|
||||
echo "$LOCK_STAMP_VALUE" > "$LOCK_STAMP"
|
||||
fi
|
||||
'';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,670 @@
|
||||
# nix/nixosModules.nix — the NixOS module for hermes-agent
|
||||
#
|
||||
# This module shares its options, its renderers for config.yaml, .env and
|
||||
# documents, and its state setup with the Home Manager module
|
||||
# (nix/homeManagerModules.nix). The shared code is in nix/moduleCommon.nix.
|
||||
# This file holds only the parts that need root: the service user, a system
|
||||
# state directory, the system PATH, and container mode.
|
||||
#
|
||||
# Two modes:
|
||||
# container.enable = false (default) → native systemd service
|
||||
# container.enable = true → OCI container (persistent writable layer)
|
||||
#
|
||||
# Container mode: hermes runs from /nix/store bind-mounted read-only into a
|
||||
# plain Ubuntu container. The writable layer (apt/pip/npm installs) persists
|
||||
# across restarts and agent updates. Only image/volume/options changes trigger
|
||||
# container recreation. Environment variables are written to $HERMES_HOME/.env
|
||||
# and read by hermes at startup — no container recreation needed for env changes.
|
||||
#
|
||||
# Tool resolution: the hermes wrapper uses --suffix PATH for nix store tools,
|
||||
# so apt/uv-installed versions take priority. The container entrypoint provisions
|
||||
# extensible tools on first boot: nodejs/npm via apt, uv via curl, and a Python
|
||||
# 3.11 venv (bootstrapped entirely by uv) at ~/.venv with pip seeded. Agents get
|
||||
# writable tool prefixes for npm i -g, pip install, uv tool install, etc.
|
||||
#
|
||||
# Usage:
|
||||
# services.hermes-agent = {
|
||||
# enable = true;
|
||||
# settings.model.default = "anthropic/claude-sonnet-4";
|
||||
# environmentFiles = [ config.sops.secrets."hermes/env".path ];
|
||||
# };
|
||||
#
|
||||
{ inputs, ... }:
|
||||
{
|
||||
flake.nixosModules.default =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
options,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.services.hermes-agent;
|
||||
common = import ./moduleCommon.nix { inherit lib; };
|
||||
|
||||
effectivePackage = common.effectivePackage cfg;
|
||||
hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
|
||||
hermesHome = "${cfg.stateDir}/.hermes";
|
||||
|
||||
# In container mode, the agent uses the mount path in the container.
|
||||
effectiveWorkDir = if cfg.container.enable then containerWorkDir else cfg.workingDirectory;
|
||||
|
||||
# config.yaml mode: group-writable (0660) when interactive users share this
|
||||
# HERMES_HOME via addToSystemPackages, so they can save settings through the
|
||||
# CLI/TUI without hitting EACCES; otherwise group-read-only (0640). Secrets
|
||||
# (.env) stay 0640 regardless.
|
||||
configYamlMode = if cfg.addToSystemPackages then "0660" else "0640";
|
||||
|
||||
containerName = "hermes-agent";
|
||||
containerDataDir = "/data"; # stateDir mount point inside container
|
||||
containerHomeDir = "/home/hermes";
|
||||
|
||||
# ── Container mode helpers ──────────────────────────────────────────
|
||||
containerBin =
|
||||
if cfg.container.backend == "docker" then
|
||||
"${pkgs.docker}/bin/docker"
|
||||
else
|
||||
"${pkgs.podman}/bin/podman";
|
||||
|
||||
# Runs as root inside the container on every start. Provisions the
|
||||
# hermes user + sudo on first boot (writable layer persists), then
|
||||
# drops privileges. Supports arbitrary base images (Debian, Alpine, etc).
|
||||
containerEntrypoint = pkgs.writeShellScript "hermes-container-entrypoint" ''
|
||||
set -eu
|
||||
|
||||
HERMES_UID="''${HERMES_UID:?HERMES_UID must be set}"
|
||||
HERMES_GID="''${HERMES_GID:?HERMES_GID must be set}"
|
||||
|
||||
# ── Group: ensure a group with GID=$HERMES_GID exists ──
|
||||
# Check by GID (not name) to avoid collisions with pre-existing groups
|
||||
# (e.g. GID 100 = "users" on Ubuntu)
|
||||
EXISTING_GROUP=$(getent group "$HERMES_GID" 2>/dev/null | cut -d: -f1 || true)
|
||||
if [ -n "$EXISTING_GROUP" ]; then
|
||||
GROUP_NAME="$EXISTING_GROUP"
|
||||
else
|
||||
GROUP_NAME="hermes"
|
||||
if command -v groupadd >/dev/null 2>&1; then
|
||||
groupadd -g "$HERMES_GID" "$GROUP_NAME"
|
||||
elif command -v addgroup >/dev/null 2>&1; then
|
||||
addgroup -g "$HERMES_GID" "$GROUP_NAME" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── User: ensure a user with UID=$HERMES_UID exists ──
|
||||
PASSWD_ENTRY=$(getent passwd "$HERMES_UID" 2>/dev/null || true)
|
||||
if [ -n "$PASSWD_ENTRY" ]; then
|
||||
TARGET_USER=$(echo "$PASSWD_ENTRY" | cut -d: -f1)
|
||||
TARGET_HOME=$(echo "$PASSWD_ENTRY" | cut -d: -f6)
|
||||
else
|
||||
TARGET_USER="hermes"
|
||||
TARGET_HOME="/home/hermes"
|
||||
if command -v useradd >/dev/null 2>&1; then
|
||||
useradd -u "$HERMES_UID" -g "$HERMES_GID" -m -d "$TARGET_HOME" -s /bin/bash "$TARGET_USER"
|
||||
elif command -v adduser >/dev/null 2>&1; then
|
||||
adduser -u "$HERMES_UID" -D -h "$TARGET_HOME" -s /bin/sh -G "$GROUP_NAME" "$TARGET_USER" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
mkdir -p "$TARGET_HOME"
|
||||
chown "$HERMES_UID:$HERMES_GID" "$TARGET_HOME"
|
||||
chmod 0750 "$TARGET_HOME"
|
||||
|
||||
# Ensure HERMES_HOME is owned by the target user.
|
||||
# Use find instead of chown -R: chown strips the setgid bit (kernel
|
||||
# behavior), destroying the 2770 permissions the NixOS activation
|
||||
# script sets for group access by hostUsers. Only touch files with
|
||||
# wrong ownership so correctly-owned dirs keep their permission bits.
|
||||
if [ -n "''${HERMES_HOME:-}" ] && [ -d "$HERMES_HOME" ]; then
|
||||
find "$HERMES_HOME" \! -user "$HERMES_UID" -exec chown "$HERMES_UID:$HERMES_GID" {} +
|
||||
fi
|
||||
|
||||
# ── Provision apt packages (first boot only, cached in writable layer) ──
|
||||
# sudo: agent self-modification
|
||||
# nodejs/npm: writable node so npm i -g works (nix store copies are read-only)
|
||||
# Node 22 via NodeSource — Ubuntu 24.04 ships Node 18 which is EOL.
|
||||
# curl: needed for uv installer + NodeSource setup
|
||||
if [ ! -f /var/lib/hermes-tools-provisioned ] && command -v apt-get >/dev/null 2>&1; then
|
||||
echo "First boot: provisioning agent tools..."
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq sudo curl ca-certificates gnupg
|
||||
mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
|
||||
> /etc/apt/sources.list.d/nodesource.list
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq nodejs
|
||||
touch /var/lib/hermes-tools-provisioned
|
||||
fi
|
||||
|
||||
if command -v sudo >/dev/null 2>&1 && [ ! -f /etc/sudoers.d/hermes ]; then
|
||||
mkdir -p /etc/sudoers.d
|
||||
echo "$TARGET_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/hermes
|
||||
chmod 0440 /etc/sudoers.d/hermes
|
||||
fi
|
||||
|
||||
# uv (Python manager) — not in Ubuntu repos, retry-safe outside the sentinel
|
||||
if ! command -v uv >/dev/null 2>&1 && [ ! -x "$TARGET_HOME/.local/bin/uv" ] && command -v curl >/dev/null 2>&1; then
|
||||
su -s /bin/sh "$TARGET_USER" -c 'curl -LsSf https://astral.sh/uv/install.sh | sh' || true
|
||||
fi
|
||||
|
||||
# Python 3.12 venv — gives the agent a writable Python with pip.
|
||||
# --seed includes pip/setuptools so bare `pip install` works.
|
||||
_UV_BIN="$TARGET_HOME/.local/bin/uv"
|
||||
if [ ! -d "$TARGET_HOME/.venv" ] && [ -x "$_UV_BIN" ]; then
|
||||
su -s /bin/sh "$TARGET_USER" -c "
|
||||
export PATH=\"\$HOME/.local/bin:\$PATH\"
|
||||
uv python install 3.12
|
||||
uv venv --python 3.12 --seed \"\$HOME/.venv\"
|
||||
" || true
|
||||
fi
|
||||
|
||||
# Put the agent venv first on PATH so python/pip resolve to writable copies
|
||||
if [ -d "$TARGET_HOME/.venv/bin" ]; then
|
||||
export PATH="$TARGET_HOME/.venv/bin:$PATH"
|
||||
fi
|
||||
|
||||
if command -v setpriv >/dev/null 2>&1; then
|
||||
exec setpriv --reuid="$HERMES_UID" --regid="$HERMES_GID" --init-groups "$@"
|
||||
elif command -v su >/dev/null 2>&1; then
|
||||
exec su -s /bin/sh "$TARGET_USER" -c 'exec "$0" "$@"' -- "$@"
|
||||
else
|
||||
echo "WARNING: no privilege-drop tool (setpriv/su), running as root" >&2
|
||||
exec "$@"
|
||||
fi
|
||||
'';
|
||||
|
||||
# Identity hash — only recreate container when structural config changes.
|
||||
# Package and entrypoint use stable symlinks (current-package, current-entrypoint)
|
||||
# so they can update without recreation. Env vars go through $HERMES_HOME/.env.
|
||||
containerIdentity = builtins.hashString "sha256" (
|
||||
builtins.toJSON {
|
||||
schema = 4; # bump when identity inputs change (4: Node 18→22 via NodeSource)
|
||||
image = cfg.container.image;
|
||||
extraVolumes = cfg.container.extraVolumes;
|
||||
extraOptions = cfg.container.extraOptions;
|
||||
}
|
||||
);
|
||||
|
||||
identityFile = "${cfg.stateDir}/.container-identity";
|
||||
|
||||
# The CLI on the host reads this file, in get_container_exec_info. The
|
||||
# file tells the CLI to run in the container and not on the host.
|
||||
containerModeFile = pkgs.writeText "hermes-container-mode" ''
|
||||
# Written by the NixOS activation script. Do not edit manually.
|
||||
backend=${cfg.container.backend}
|
||||
container_name=${containerName}
|
||||
exec_user=${cfg.user}
|
||||
hermes_bin=${containerDataDir}/current-package/bin/hermes
|
||||
'';
|
||||
|
||||
# Default: /var/lib/hermes/workspace → /data/workspace.
|
||||
# Custom paths outside stateDir pass through unchanged (user must add extraVolumes).
|
||||
containerWorkDir =
|
||||
if lib.hasPrefix "${cfg.stateDir}/" cfg.workingDirectory then
|
||||
"${containerDataDir}/${lib.removePrefix "${cfg.stateDir}/" cfg.workingDirectory}"
|
||||
else
|
||||
cfg.workingDirectory;
|
||||
|
||||
# The hardening and the environment that the gateway unit and the
|
||||
# backend unit share.
|
||||
commonServiceConfig = {
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = cfg.workingDirectory;
|
||||
|
||||
Restart = cfg.restart;
|
||||
RestartSec = cfg.restartSec;
|
||||
|
||||
# Shared-state: files created by the service should be group-writable
|
||||
# so interactive users in the hermes group can read/write them.
|
||||
UMask = "0007";
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = false;
|
||||
ReadWritePaths = [
|
||||
cfg.stateDir
|
||||
cfg.workingDirectory
|
||||
];
|
||||
PrivateTmp = true;
|
||||
};
|
||||
|
||||
commonUnitEnvironment = {
|
||||
HOME = cfg.stateDir;
|
||||
}
|
||||
// common.processEnvironment { inherit hermesHome; };
|
||||
|
||||
unitPath = common.processPath { inherit pkgs cfg; };
|
||||
|
||||
in
|
||||
{
|
||||
options.services.hermes-agent =
|
||||
common.sharedOptions {
|
||||
defaultPackage = hermes-agent;
|
||||
defaultPackageText = lib.literalExpression "hermes-agent.packages.\${system}.default";
|
||||
defaultWorkingDirectory = "${cfg.stateDir}/workspace";
|
||||
defaultWorkingDirectoryText = lib.literalExpression ''"''${cfg.stateDir}/workspace"'';
|
||||
}
|
||||
// (
|
||||
with lib;
|
||||
{
|
||||
# ── Service identity ───────────────────────────────────────────
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "hermes";
|
||||
description = "System user running the gateway.";
|
||||
};
|
||||
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = "hermes";
|
||||
description = "System group running the gateway.";
|
||||
};
|
||||
|
||||
createUser = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Create the user/group automatically.";
|
||||
};
|
||||
|
||||
# ── Directories ────────────────────────────────────────────────
|
||||
stateDir = mkOption {
|
||||
type = types.str;
|
||||
default = "/var/lib/hermes";
|
||||
description = "State directory. Contains .hermes/ subdir (HERMES_HOME).";
|
||||
};
|
||||
|
||||
addToSystemPackages = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Add the hermes CLI to environment.systemPackages and export
|
||||
HERMES_HOME system-wide (via environment.variables) so interactive
|
||||
shells share state with the gateway service.
|
||||
'';
|
||||
};
|
||||
|
||||
# ── OCI Container (opt-in) ────────────────────────────────────
|
||||
container = {
|
||||
enable = mkEnableOption "OCI container mode (Ubuntu base, full self-modification support)";
|
||||
|
||||
backend = mkOption {
|
||||
type = types.enum [
|
||||
"docker"
|
||||
"podman"
|
||||
];
|
||||
default = "docker";
|
||||
description = "Container runtime.";
|
||||
};
|
||||
|
||||
extraVolumes = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Extra volume mounts (host:container:mode format).";
|
||||
example = [ "/home/user/projects:/projects:rw" ];
|
||||
};
|
||||
|
||||
extraOptions = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Extra arguments passed to docker/podman run.";
|
||||
};
|
||||
|
||||
image = mkOption {
|
||||
type = types.str;
|
||||
default = "ubuntu:24.04";
|
||||
description = "OCI container image. The container pulls this at runtime via Docker/Podman.";
|
||||
};
|
||||
|
||||
hostUsers = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = ''
|
||||
Interactive users who get a ~/.hermes symlink to the service
|
||||
stateDir. These users are automatically added to the hermes group.
|
||||
'';
|
||||
example = [ "sidbin" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
config = lib.mkIf cfg.enable (
|
||||
lib.mkMerge [
|
||||
|
||||
# ── Merge MCP servers into settings ────────────────────────────────
|
||||
(lib.mkIf (cfg.mcpServers != { }) {
|
||||
services.hermes-agent.settings.mcp_servers = common.mcpServersToConfig cfg.mcpServers;
|
||||
})
|
||||
|
||||
# ── User / group ──────────────────────────────────────────────────
|
||||
(lib.mkIf cfg.createUser {
|
||||
users.groups.${cfg.group} = { };
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.stateDir;
|
||||
createHome = true;
|
||||
shell = pkgs.bashInteractive;
|
||||
};
|
||||
})
|
||||
|
||||
# ── Host CLI ──────────────────────────────────────────────────────
|
||||
# Add the hermes CLI to system PATH and export HERMES_HOME system-wide
|
||||
# so interactive shells share state (sessions, skills, cron) with the
|
||||
# gateway service instead of creating a separate ~/.hermes/.
|
||||
(lib.mkIf cfg.addToSystemPackages {
|
||||
environment.systemPackages = [ effectivePackage ];
|
||||
environment.variables.HERMES_HOME = hermesHome;
|
||||
})
|
||||
|
||||
# ── Host user group membership ─────────────────────────────────────
|
||||
(lib.mkIf (cfg.container.enable && cfg.container.hostUsers != [ ]) {
|
||||
users.users = lib.genAttrs cfg.container.hostUsers (_user: {
|
||||
extraGroups = [ cfg.group ];
|
||||
});
|
||||
})
|
||||
|
||||
# ── Assertions ─────────────────────────────────────────────────────
|
||||
{
|
||||
assertions =
|
||||
common.pluginNameAssertions {
|
||||
inherit cfg;
|
||||
optionPath = "services.hermes-agent";
|
||||
}
|
||||
++ common.workspaceFilesAssertions {
|
||||
inherit cfg;
|
||||
opt = options.services.hermes-agent.workingDirectory;
|
||||
optionPath = "services.hermes-agent";
|
||||
}
|
||||
++ common.backendBindAssertions {
|
||||
inherit cfg;
|
||||
optionPath = "services.hermes-agent";
|
||||
}
|
||||
++ [
|
||||
{
|
||||
# Container mode runs one command in one container. A second
|
||||
# process needs its own container and its own ports. This
|
||||
# module does not do that.
|
||||
assertion = !(cfg.container.enable && cfg.backend.mode != "none");
|
||||
message = "services.hermes-agent: backend.mode is not supported together with container.enable — the container runs the gateway only.";
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
# ── Per-user profile for extraPackages ───────────────────────────
|
||||
# Wire extraPackages into the hermes user's per-user profile so the
|
||||
# login-shell snapshot (which rebuilds PATH from NixOS profiles) sees
|
||||
# them. The systemd service PATH also includes them for direct access.
|
||||
(lib.mkIf (cfg.extraPackages != [ ]) {
|
||||
# listOf options are merged by the NixOS module system — this appends to
|
||||
# any packages the operator assigned to this user externally (e.g. when
|
||||
# createUser = false and the user definition lives elsewhere in the config).
|
||||
users.users.${cfg.user}.packages = cfg.extraPackages;
|
||||
})
|
||||
|
||||
# ── Warnings ──────────────────────────────────────────────────────
|
||||
(lib.mkIf
|
||||
(cfg.container.enable && !cfg.addToSystemPackages && cfg.container.hostUsers != [ ])
|
||||
{
|
||||
warnings = [
|
||||
''
|
||||
services.hermes-agent: container.enable is true and container.hostUsers
|
||||
is set, but addToSystemPackages is false. Without a host-installed hermes
|
||||
binary, container routing will not work for interactive users.
|
||||
Set addToSystemPackages = true or ensure hermes is on PATH.
|
||||
''
|
||||
];
|
||||
}
|
||||
)
|
||||
|
||||
# ── Directories ───────────────────────────────────────────────────
|
||||
{
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.stateDir} 2770 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${hermesHome} 2770 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/home 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.workingDirectory} 2770 ${cfg.user} ${cfg.group} - -"
|
||||
]
|
||||
++ map (d: "d ${hermesHome}/${d} 2770 ${cfg.user} ${cfg.group} - -") common.stateSubdirs;
|
||||
}
|
||||
|
||||
# ── Activation: link config + auth + documents ────────────────────
|
||||
{
|
||||
system.activationScripts."hermes-agent-setup" =
|
||||
lib.stringAfter
|
||||
(
|
||||
[ "users" ] ++ lib.optional (config.system.activationScripts ? setupSecrets) "setupSecrets"
|
||||
)
|
||||
''
|
||||
# Ensure directories exist (activation runs before tmpfiles)
|
||||
mkdir -p ${hermesHome}
|
||||
mkdir -p ${cfg.stateDir}/home
|
||||
mkdir -p ${cfg.workingDirectory}
|
||||
chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${hermesHome} ${cfg.stateDir}/home ${cfg.workingDirectory}
|
||||
chmod 2770 ${cfg.stateDir} ${hermesHome} ${cfg.workingDirectory}
|
||||
chmod 0750 ${cfg.stateDir}/home
|
||||
|
||||
# Create subdirs, set setgid + group-writable, migrate existing files.
|
||||
# Nix-managed .env/.managed stay 0640/0644; config.yaml uses
|
||||
# configYamlMode (0660 under addToSystemPackages, else 0640).
|
||||
find ${hermesHome} -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 ${lib.concatStringsSep " " common.stateSubdirs}; do
|
||||
mkdir -p "${hermesHome}/$_subdir"
|
||||
chown ${cfg.user}:${cfg.group} "${hermesHome}/$_subdir"
|
||||
chmod 2770 "${hermesHome}/$_subdir"
|
||||
find "${hermesHome}/$_subdir" -type f \
|
||||
-exec chmod g+rw {} + 2>/dev/null || true
|
||||
done
|
||||
|
||||
${common.mkStateScript {
|
||||
inherit pkgs cfg hermesHome;
|
||||
workingDirectory = cfg.workingDirectory;
|
||||
configWorkingDirectory = effectiveWorkDir;
|
||||
owner = "${cfg.user}:${cfg.group}";
|
||||
stateDirs = common.stateSubdirs;
|
||||
modes = {
|
||||
config = configYamlMode;
|
||||
env = "0640";
|
||||
managed = "0644";
|
||||
auth = "0600";
|
||||
document = "0640";
|
||||
};
|
||||
}}
|
||||
|
||||
chown -h ${cfg.user}:${cfg.group} ${hermesHome}/plugins/nix-managed-* 2>/dev/null || true
|
||||
|
||||
# Container mode metadata — tells the host CLI to exec into the
|
||||
# container instead of running locally. Removed when container mode
|
||||
# is disabled so the host CLI falls back to native execution.
|
||||
${
|
||||
if cfg.container.enable then
|
||||
''
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0644 ${containerModeFile} ${hermesHome}/.container-mode
|
||||
''
|
||||
else
|
||||
''
|
||||
rm -f ${hermesHome}/.container-mode
|
||||
|
||||
# Remove symlink bridge for hostUsers
|
||||
${lib.concatStringsSep "\n" (
|
||||
map (
|
||||
user:
|
||||
let
|
||||
userHome = config.users.users.${user}.home;
|
||||
symlinkPath = "${userHome}/.hermes";
|
||||
in
|
||||
''
|
||||
if [ -L "${symlinkPath}" ] && [ "$(readlink "${symlinkPath}")" = "${hermesHome}" ]; then
|
||||
rm -f "${symlinkPath}"
|
||||
echo "hermes-agent: removed symlink ${symlinkPath}"
|
||||
fi
|
||||
''
|
||||
) cfg.container.hostUsers
|
||||
)}
|
||||
''
|
||||
}
|
||||
|
||||
# ── Symlink bridge for interactive users ───────────────────────
|
||||
# Create ~/.hermes -> stateDir/.hermes for each hostUser so the
|
||||
# host CLI shares state with the container service.
|
||||
# Only runs when container mode is enabled.
|
||||
${lib.optionalString cfg.container.enable (
|
||||
lib.concatStringsSep "\n" (
|
||||
map (
|
||||
user:
|
||||
let
|
||||
userHome = config.users.users.${user}.home;
|
||||
symlinkPath = "${userHome}/.hermes";
|
||||
in
|
||||
''
|
||||
if [ -d "${symlinkPath}" ] && [ ! -L "${symlinkPath}" ]; then
|
||||
# Real directory — back it up, then create symlink.
|
||||
# (ln -sfn cannot atomically replace a directory.)
|
||||
_backup="${symlinkPath}.bak.$(date +%s)"
|
||||
echo "hermes-agent: backing up existing ${symlinkPath} to $_backup"
|
||||
mv "${symlinkPath}" "$_backup"
|
||||
fi
|
||||
# For everything else (existing symlink, doesn't exist, etc.)
|
||||
# ln -sfn handles it: replaces symlinks, creates new ones.
|
||||
ln -sfn "${hermesHome}" "${symlinkPath}"
|
||||
chown -h ${user}:${cfg.group} "${symlinkPath}"
|
||||
''
|
||||
) cfg.container.hostUsers
|
||||
)
|
||||
)}
|
||||
'';
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# MODE A: Native systemd service (default)
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
(lib.mkIf (!cfg.container.enable) {
|
||||
systemd.services.hermes-agent = {
|
||||
description = "Hermes Agent Gateway";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
|
||||
# cfg.environment and cfg.environmentFiles are written to
|
||||
# $HERMES_HOME/.env by the activation script. load_hermes_dotenv()
|
||||
# reads them at Python startup — no systemd EnvironmentFile needed.
|
||||
environment = commonUnitEnvironment;
|
||||
|
||||
serviceConfig = commonServiceConfig // {
|
||||
ExecStart = lib.escapeShellArgs (common.gatewayArgv cfg);
|
||||
};
|
||||
|
||||
path = unitPath;
|
||||
};
|
||||
})
|
||||
|
||||
# ── The backend: hermes serve or hermes dashboard ─────────────────
|
||||
# This is a different process from the gateway. Both use one
|
||||
# HERMES_HOME.
|
||||
(lib.mkIf (!cfg.container.enable && cfg.backend.mode != "none") {
|
||||
systemd.services.hermes-backend = {
|
||||
description = common.backendDescription cfg;
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
|
||||
environment = commonUnitEnvironment;
|
||||
|
||||
serviceConfig = commonServiceConfig // {
|
||||
ExecStart = lib.escapeShellArgs (common.backendArgv { inherit pkgs cfg; });
|
||||
};
|
||||
|
||||
path = unitPath;
|
||||
};
|
||||
})
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# MODE B: OCI container (persistent writable layer)
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
(lib.mkIf cfg.container.enable {
|
||||
# Ensure the container runtime is available
|
||||
virtualisation.docker.enable = lib.mkDefault (cfg.container.backend == "docker");
|
||||
|
||||
systemd.services.hermes-agent = {
|
||||
description = "Hermes Agent Gateway (container)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [
|
||||
"network-online.target"
|
||||
]
|
||||
++ lib.optional (cfg.container.backend == "docker") "docker.service";
|
||||
wants = [ "network-online.target" ];
|
||||
requires = lib.optional (cfg.container.backend == "docker") "docker.service";
|
||||
|
||||
preStart = ''
|
||||
# Stable symlinks — container references these, not store paths directly
|
||||
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 ${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
|
||||
NEED_CREATE=false
|
||||
if ! ${containerBin} inspect ${containerName} &>/dev/null; then
|
||||
NEED_CREATE=true
|
||||
elif [ ! -f ${identityFile} ] || [ "$(cat ${identityFile})" != "${containerIdentity}" ]; then
|
||||
echo "Container config changed, recreating..."
|
||||
${containerBin} rm -f ${containerName} || true
|
||||
NEED_CREATE=true
|
||||
fi
|
||||
|
||||
if [ "$NEED_CREATE" = "true" ]; then
|
||||
# Resolve numeric UID/GID — passed to entrypoint for in-container user setup
|
||||
HERMES_UID=$(${pkgs.coreutils}/bin/id -u ${cfg.user})
|
||||
HERMES_GID=$(${pkgs.coreutils}/bin/id -g ${cfg.user})
|
||||
|
||||
echo "Creating container..."
|
||||
${containerBin} create \
|
||||
--name ${containerName} \
|
||||
--network=host \
|
||||
--entrypoint ${containerDataDir}/current-entrypoint \
|
||||
--volume /nix/store:/nix/store:ro \
|
||||
--volume ${cfg.stateDir}:${containerDataDir} \
|
||||
--volume ${cfg.stateDir}/home:${containerHomeDir} \
|
||||
${lib.concatStringsSep " " (map (v: "--volume ${v}") cfg.container.extraVolumes)} \
|
||||
--env HERMES_UID="$HERMES_UID" \
|
||||
--env HERMES_GID="$HERMES_GID" \
|
||||
--env HERMES_HOME=${containerDataDir}/.hermes \
|
||||
--env HERMES_MANAGED=true \
|
||||
--env HOME=${containerHomeDir} \
|
||||
${lib.concatStringsSep " " cfg.container.extraOptions} \
|
||||
${cfg.container.image} \
|
||||
${containerDataDir}/current-package/bin/hermes gateway run --replace ${lib.concatStringsSep " " cfg.extraArgs}
|
||||
|
||||
echo "${containerIdentity}" > ${identityFile}
|
||||
fi
|
||||
'';
|
||||
|
||||
script = ''
|
||||
exec ${containerBin} start -a ${containerName}
|
||||
'';
|
||||
|
||||
preStop = ''
|
||||
${containerBin} stop -t 10 ${containerName} || true
|
||||
'';
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
Restart = cfg.restart;
|
||||
RestartSec = cfg.restartSec;
|
||||
TimeoutStopSec = 30;
|
||||
};
|
||||
};
|
||||
})
|
||||
]
|
||||
);
|
||||
};
|
||||
}
|
||||
Generated
+5246
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
{
|
||||
buildNpmPackage,
|
||||
fetchFromGitHub,
|
||||
nodejs,
|
||||
lib,
|
||||
}:
|
||||
|
||||
let
|
||||
node-gyp-11_4_0 = buildNpmPackage rec {
|
||||
pname = "node-gyp";
|
||||
version = "11.4.0";
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "nodejs";
|
||||
repo = "node-gyp";
|
||||
rev = "refs/tags/v${version}";
|
||||
hash = "sha256-VtomUV+0kTp34IuS0D0dR4ZMWpk4Ptpk1CBP8rdW2a4=";
|
||||
};
|
||||
|
||||
postPatch = ''
|
||||
ln -s ${./node-gyp-11-4-0-package-lock.json} package-lock.json
|
||||
'';
|
||||
|
||||
npmDepsHash = "sha256-P25m02VxIXkPD7rYI2Wki9+levrtNg2xk8pU+nEUXsE=";
|
||||
|
||||
npmDepsFetcherVersion = 2;
|
||||
|
||||
dontNpmBuild = true;
|
||||
|
||||
makeWrapperArgs = [ "--set npm_config_nodedir ${nodejs}" ];
|
||||
|
||||
meta = {
|
||||
description = "Node.js native addon build tool";
|
||||
homepage = "https://github.com/nodejs/node-gyp";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "node-gyp";
|
||||
};
|
||||
};
|
||||
in
|
||||
node-gyp-11_4_0
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
stdenv,
|
||||
makeWrapper,
|
||||
fetchurl,
|
||||
nodejs_26,
|
||||
}:
|
||||
stdenv.mkDerivation rec {
|
||||
pname = "npm";
|
||||
version = "12.0.2";
|
||||
|
||||
src = fetchurl {
|
||||
url = "https://registry.npmjs.org/npm/-/npm-${version}.tgz";
|
||||
hash = "sha256-XbuGxx0HoZV/LpBzQJLdali9zZ68LY1ByhxuaiHTZOE=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
dontBuild = true;
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/lib/npm12
|
||||
cp -r . $out/lib/npm12/
|
||||
mkdir -p $out/bin
|
||||
|
||||
makeWrapper ${nodejs_26}/bin/node $out/bin/npm \
|
||||
--add-flags "$out/lib/npm12/bin/npm-cli.js"
|
||||
makeWrapper ${nodejs_26}/bin/node $out/bin/npx \
|
||||
--add-flags "$out/lib/npm12/bin/npx-cli.js"
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# nix/overlays.nix — Expose pkgs.hermes-agent for external NixOS configs
|
||||
#
|
||||
# The overlay is a pure alias for this flake's own package — NOT a
|
||||
# re-instantiation against the consumer's nixpkgs. This guarantees
|
||||
# `pkgs.hermes-agent`, `nix build .#default`, and the NixOS module's
|
||||
# default package are all the exact same locked, tested derivation.
|
||||
# (.override { extraPythonPackages = ...; } still works — callPackage's
|
||||
# makeOverridable travels with the package.)
|
||||
{ inputs, ... }:
|
||||
{
|
||||
flake.overlays.default = final: _: {
|
||||
hermes-agent = inputs.self.packages.${final.stdenv.hostPlatform.system}.default;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# nix/packages.nix — Hermes Agent package built with uv2nix
|
||||
{ inputs, ... }:
|
||||
{
|
||||
perSystem =
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
inputs',
|
||||
...
|
||||
}:
|
||||
let
|
||||
|
||||
sandbox = pkgs.callPackage ./sandbox.nix { };
|
||||
|
||||
minimal = pkgs.callPackage ./hermes-agent.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
|
||||
# Only embed clean revs — dirtyRev doesn't represent any upstream
|
||||
# commit, so comparing it would always claim "update available".
|
||||
rev = inputs.self.rev or null;
|
||||
};
|
||||
|
||||
# All platform-portable optional integrations pre-built.
|
||||
full = minimal.override {
|
||||
extraDependencyGroups = [
|
||||
"anthropic"
|
||||
"azure-identity"
|
||||
"bedrock"
|
||||
"daytona"
|
||||
"dingtalk"
|
||||
"edge-tts"
|
||||
"exa"
|
||||
"fal"
|
||||
"feishu"
|
||||
"firecrawl"
|
||||
"hindsight"
|
||||
"honcho"
|
||||
"messaging"
|
||||
"modal"
|
||||
"parallel-web"
|
||||
"tts-premium"
|
||||
"vercel"
|
||||
"voice"
|
||||
]
|
||||
# matrix is Linux-only (oqs/liboqs lacks aarch64-darwin wheels).
|
||||
++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
|
||||
};
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
node-gyp =
|
||||
(pkgs.callPackage ./lib.nix {
|
||||
inherit (pkgs) npm-lockfile-fix;
|
||||
}).node-gyp;
|
||||
default = full;
|
||||
|
||||
inherit sandbox;
|
||||
|
||||
inherit minimal;
|
||||
|
||||
# Ships discord.py + python-telegram-bot + slack-sdk so a plain
|
||||
# `nix profile install .#messaging` connects to Discord/Telegram/Slack
|
||||
# on first run — lazy-install can't write to the read-only /nix/store.
|
||||
messaging = minimal.override {
|
||||
extraDependencyGroups = [ "messaging" ];
|
||||
};
|
||||
|
||||
tui = full.hermesTui;
|
||||
web = full.hermesWeb;
|
||||
desktop = full.hermesDesktop;
|
||||
|
||||
update-npm-lockfile = full.hermesNpmLib.updateNpmLockfile;
|
||||
};
|
||||
};
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
# nix/python.nix — uv2nix virtual environment builder
|
||||
{
|
||||
python312,
|
||||
lib,
|
||||
callPackage,
|
||||
uv2nix,
|
||||
pyproject-nix,
|
||||
pyproject-build-systems,
|
||||
stdenv,
|
||||
# Filtered Python source (see lib.nix pythonSrc) — keeps JS/docs/skills
|
||||
# edits from invalidating the venv derivation.
|
||||
pythonSrc,
|
||||
dependency-groups ? [ "all" ],
|
||||
}:
|
||||
let
|
||||
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = pythonSrc; };
|
||||
hacks = callPackage pyproject-nix.build.hacks { };
|
||||
|
||||
overlay = workspace.mkPyprojectOverlay {
|
||||
sourcePreference = "wheel";
|
||||
};
|
||||
|
||||
isAarch64Darwin = stdenv.hostPlatform.system == "aarch64-darwin";
|
||||
|
||||
# Keep the workspace locked through uv2nix, but supply the local voice stack
|
||||
# from nixpkgs so wheel-only transitive artifacts do not break evaluation.
|
||||
mkPrebuiltPassthru = dependencies: {
|
||||
inherit dependencies;
|
||||
optional-dependencies = { };
|
||||
dependency-groups = { };
|
||||
};
|
||||
|
||||
mkPrebuiltOverride =
|
||||
final: from: dependencies:
|
||||
hacks.nixpkgsPrebuilt {
|
||||
inherit from;
|
||||
prev = {
|
||||
nativeBuildInputs = [ final.pyprojectHook ];
|
||||
passthru = mkPrebuiltPassthru dependencies;
|
||||
};
|
||||
};
|
||||
|
||||
# Legacy alibabacloud packages ship only sdists with setup.py/setup.cfg
|
||||
# and no pyproject.toml, so setuptools isn't declared as a build dep.
|
||||
buildSystemOverrides =
|
||||
final: prev:
|
||||
builtins.mapAttrs
|
||||
(
|
||||
name: _:
|
||||
prev.${name}.overrideAttrs (old: {
|
||||
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ];
|
||||
})
|
||||
)
|
||||
(
|
||||
lib.genAttrs [
|
||||
"alibabacloud-credentials-api"
|
||||
"alibabacloud-endpoint-util"
|
||||
"alibabacloud-gateway-dingtalk"
|
||||
"alibabacloud-gateway-spi"
|
||||
"alibabacloud-tea"
|
||||
] (_: null)
|
||||
);
|
||||
|
||||
pythonPackageOverrides =
|
||||
final: _prev:
|
||||
if isAarch64Darwin then
|
||||
{
|
||||
numpy = mkPrebuiltOverride final python312.pkgs.numpy { };
|
||||
|
||||
pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { };
|
||||
|
||||
av = mkPrebuiltOverride final python312.pkgs.av { };
|
||||
|
||||
humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { };
|
||||
|
||||
coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs {
|
||||
humanfriendly = [ ];
|
||||
};
|
||||
|
||||
onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime {
|
||||
coloredlogs = [ ];
|
||||
numpy = [ ];
|
||||
packaging = [ ];
|
||||
};
|
||||
|
||||
ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 {
|
||||
numpy = [ ];
|
||||
pyyaml = [ ];
|
||||
};
|
||||
|
||||
faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper {
|
||||
av = [ ];
|
||||
ctranslate2 = [ ];
|
||||
huggingface-hub = [ ];
|
||||
onnxruntime = [ ];
|
||||
tokenizers = [ ];
|
||||
tqdm = [ ];
|
||||
};
|
||||
}
|
||||
else
|
||||
{ };
|
||||
|
||||
pythonSet =
|
||||
(callPackage pyproject-nix.build.packages {
|
||||
python = python312;
|
||||
}).overrideScope
|
||||
(
|
||||
lib.composeManyExtensions [
|
||||
pyproject-build-systems.overlays.default
|
||||
overlay
|
||||
buildSystemOverrides
|
||||
pythonPackageOverrides
|
||||
# ``setup.py`` permits wheel/sdist creation only from the sealed
|
||||
# Hermes derivation. This is deliberately a derivation environment
|
||||
# variable, not a devShell variable: ``nix develop -c uv build``
|
||||
# must remain blocked.
|
||||
(final: prev: {
|
||||
hermes-agent = prev.hermes-agent.overrideAttrs (_old: {
|
||||
HERMES_NIX_BUILD = "1";
|
||||
});
|
||||
})
|
||||
]
|
||||
);
|
||||
|
||||
# The editable venv points at the live checkout, so it uses an
|
||||
# UNFILTERED workspace rooted at a real path — mkEditablePyprojectOverlay
|
||||
# computes relative paths via lib.path.splitRoot, which rejects the
|
||||
# filtered pythonSrc (a cleanSourceWith set, not a path). Filtering
|
||||
# buys nothing here anyway: the editable install reads from
|
||||
# $HERMES_PYTHON_SRC_ROOT at runtime.
|
||||
workspaceRoot = ./..;
|
||||
editableWorkspace = uv2nix.lib.workspace.loadWorkspace { inherit workspaceRoot; };
|
||||
editableOverlay = editableWorkspace.mkEditablePyprojectOverlay {
|
||||
root = "$HERMES_PYTHON_SRC_ROOT"; # resolved at shellHook time
|
||||
};
|
||||
|
||||
editableSet = pythonSet.overrideScope (
|
||||
lib.composeManyExtensions [
|
||||
editableOverlay
|
||||
(final: prev: {
|
||||
hermes-agent = prev.hermes-agent.overrideAttrs (old: {
|
||||
# point straight at the real source instead of the filtered nix store copy
|
||||
src = workspaceRoot;
|
||||
nativeBuildInputs = old.nativeBuildInputs ++ final.resolveBuildSystem { editables = [ ]; };
|
||||
});
|
||||
})
|
||||
]
|
||||
);
|
||||
in
|
||||
{
|
||||
venv = pythonSet.mkVirtualEnv "hermes-agent-env" {
|
||||
hermes-agent = dependency-groups;
|
||||
};
|
||||
editableVenv = editableSet.mkVirtualEnv "hermes-agent-editable-env" {
|
||||
hermes-agent = dependency-groups;
|
||||
};
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
# electron deps
|
||||
alsa-lib,
|
||||
at-spi2-atk,
|
||||
atk,
|
||||
cairo,
|
||||
cups,
|
||||
dbus,
|
||||
expat,
|
||||
fontconfig,
|
||||
freetype,
|
||||
glib,
|
||||
gtk3,
|
||||
libdrm,
|
||||
libgbm,
|
||||
libxkbcommon,
|
||||
mesa,
|
||||
nspr,
|
||||
nss,
|
||||
pango,
|
||||
systemd,
|
||||
libX11,
|
||||
libXcomposite,
|
||||
libXdamage,
|
||||
libXext,
|
||||
libXfixes,
|
||||
libXrandr,
|
||||
libXrender,
|
||||
libXtst,
|
||||
libxcb,
|
||||
|
||||
# sandbox deps
|
||||
bash,
|
||||
bubblewrap,
|
||||
cacert,
|
||||
coreutils,
|
||||
curl,
|
||||
gawk,
|
||||
git,
|
||||
glibc,
|
||||
gnumake,
|
||||
gnugrep,
|
||||
gnused,
|
||||
gzip,
|
||||
nodejs_22,
|
||||
openssl,
|
||||
python3,
|
||||
slirp4netns,
|
||||
stdenv,
|
||||
gnutar,
|
||||
util-linux,
|
||||
|
||||
# etc
|
||||
writeShellApplication,
|
||||
lib,
|
||||
}:
|
||||
let
|
||||
electronRuntime = [
|
||||
alsa-lib
|
||||
at-spi2-atk
|
||||
atk
|
||||
cairo
|
||||
cups
|
||||
dbus
|
||||
expat
|
||||
fontconfig
|
||||
freetype
|
||||
glib
|
||||
gtk3
|
||||
libdrm
|
||||
libgbm
|
||||
libxkbcommon
|
||||
mesa
|
||||
nspr
|
||||
nss
|
||||
pango
|
||||
systemd
|
||||
libX11
|
||||
libXcomposite
|
||||
libXdamage
|
||||
libXext
|
||||
libXfixes
|
||||
libXrandr
|
||||
libXrender
|
||||
libXtst
|
||||
libxcb
|
||||
];
|
||||
in
|
||||
writeShellApplication {
|
||||
name = "sandbox";
|
||||
runtimeInputs = [
|
||||
bash
|
||||
bubblewrap
|
||||
cacert
|
||||
coreutils
|
||||
curl
|
||||
gawk
|
||||
git
|
||||
glibc.bin
|
||||
gnumake
|
||||
gnugrep
|
||||
gnused
|
||||
gzip
|
||||
nodejs_22
|
||||
openssl
|
||||
python3
|
||||
slirp4netns
|
||||
stdenv.cc
|
||||
gnutar
|
||||
util-linux
|
||||
]
|
||||
++ electronRuntime;
|
||||
text = ''
|
||||
export DEV_SANDBOX_REAL_CA_CERT=${cacert}/etc/ssl/certs/ca-bundle.crt
|
||||
export DEV_SANDBOX_DYNAMIC_LINKER=${stdenv.cc.bintools.dynamicLinker}
|
||||
export DEV_SANDBOX_NODE_DIR=${nodejs_22}
|
||||
export DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH=${lib.makeLibraryPath electronRuntime}
|
||||
# The script is imported into the store as a single file, so its own
|
||||
# directory has no scripts/sandbox/ beside it. Point it at the assets
|
||||
# (fake-internet proxy, ssh shim) explicitly.
|
||||
export DEV_SANDBOX_ASSETS=${../scripts/sandbox}
|
||||
exec ${../scripts/dev-sandbox.sh} "$@"
|
||||
'';
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
|
||||
{ hermesNpmLib, ... }:
|
||||
hermesNpmLib.buildNpmPackage {
|
||||
dirs = [
|
||||
"ui-tui"
|
||||
"apps/shared"
|
||||
];
|
||||
|
||||
doCheck = false;
|
||||
|
||||
buildPhase = ''
|
||||
# esbuild bundles everything — no need for tsc or vite.
|
||||
# Run from the workspace root where node_modules/ lives.
|
||||
node ui-tui/scripts/build.mjs
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/lib/hermes-tui
|
||||
# esbuild writes to ui-tui/dist/ from the source root (no cd).
|
||||
cp -r ui-tui/dist $out/lib/hermes-tui/dist
|
||||
|
||||
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
|
||||
cp ui-tui/package.json $out/lib/hermes-tui/
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build
|
||||
{ hermesNpmLib, ... }:
|
||||
hermesNpmLib.buildNpmPackage {
|
||||
dirs = [
|
||||
"web"
|
||||
|
||||
# @hermes/shared ships as a file: workspace dep of web, so its source
|
||||
# must be in the filtered src tree too.
|
||||
"apps/shared"
|
||||
];
|
||||
|
||||
doCheck = false;
|
||||
|
||||
buildPhase = ''
|
||||
# Build from web/ so vite.config.ts and tsconfig resolve correctly.
|
||||
# The workspace root's node_modules/ is at ../node_modules/.
|
||||
cd web
|
||||
node ../node_modules/typescript/bin/tsc -b
|
||||
# outDir in vite.config.ts points to ../hermes_cli/web_dist for the
|
||||
# monorepo layout. Override with --outDir dist for the nix build.
|
||||
node ../node_modules/vite/bin/vite.js build --outDir dist
|
||||
|
||||
# Return to source root so installPhase paths are correct.
|
||||
cd ..
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
# vite writes to web/dist/ (we cd'd there, overrode outDir, then cd'd back).
|
||||
cp -r web/dist $out
|
||||
runHook postInstall
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user