mirror of
https://github.com/0xrsydn/nix-hermes-agent.git
synced 2026-08-07 00:53:52 +00:00
feat: add declarative Hermes skills phase 1
This commit is contained in:
parent
c2966d2515
commit
a1b9295c8e
4 changed files with 276 additions and 1 deletions
241
module.nix
241
module.nix
|
|
@ -9,6 +9,7 @@ self:
|
|||
let
|
||||
cfg = config.services.hermes-agent;
|
||||
inherit (self.packages.${pkgs.system}) hermes-agent;
|
||||
hermesUpstreamSrc = cfg.package.upstreamSrc or hermes-agent.upstreamSrc;
|
||||
|
||||
# Deep-merge config type (same pattern as nix-openclaw)
|
||||
deepConfigType = lib.types.mkOptionType {
|
||||
|
|
@ -18,6 +19,136 @@ let
|
|||
merge = _loc: defs: lib.foldl' lib.recursiveUpdate { } (map (d: d.value) defs);
|
||||
};
|
||||
|
||||
customSkillType = lib.types.submodule {
|
||||
options = {
|
||||
category = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Category directory under skills/. If null, installs as a top-level skill.";
|
||||
};
|
||||
|
||||
source = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Path to a local skill directory containing SKILL.md and any linked files.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
customSkillSpecs = lib.mapAttrsToList (name: value: {
|
||||
inherit name;
|
||||
category = value.category;
|
||||
source = toString value.source;
|
||||
}) cfg.skills.custom;
|
||||
|
||||
skillsManaged = cfg.skills.bundled.enable || cfg.skills.optional != [ ] || cfg.skills.custom != { };
|
||||
|
||||
managedSkillsTree =
|
||||
pkgs.runCommand "hermes-managed-skills"
|
||||
{
|
||||
bundledEnabled = if cfg.skills.bundled.enable then "1" else "0";
|
||||
optionalSkillsJson = builtins.toJSON cfg.skills.optional;
|
||||
customSkillsJson = builtins.toJSON customSkillSpecs;
|
||||
src = hermesUpstreamSrc;
|
||||
}
|
||||
''
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p "$out"
|
||||
|
||||
${pkgs.python3}/bin/python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
out = Path(os.environ["out"])
|
||||
src = Path(os.environ["src"])
|
||||
bundled_enabled = os.environ["bundledEnabled"] == "1"
|
||||
optional_skills = json.loads(os.environ["optionalSkillsJson"])
|
||||
custom_skills = json.loads(os.environ["customSkillsJson"])
|
||||
managed = []
|
||||
|
||||
|
||||
def copy_tree(src_dir: Path, dst_dir: Path):
|
||||
if not src_dir.exists():
|
||||
raise SystemExit(f"missing source path: {src_dir}")
|
||||
if not src_dir.is_dir():
|
||||
raise SystemExit(f"expected directory, got: {src_dir}")
|
||||
if dst_dir.exists():
|
||||
shutil.rmtree(dst_dir)
|
||||
dst_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(src_dir, dst_dir)
|
||||
|
||||
|
||||
def skill_dirs_under(root: Path):
|
||||
if not root.exists():
|
||||
return []
|
||||
dirs = []
|
||||
for skill_md in root.rglob("SKILL.md"):
|
||||
rel = skill_md.parent.relative_to(root)
|
||||
if rel.parts and rel.parts[0].startswith('.'):
|
||||
continue
|
||||
dirs.append(rel)
|
||||
return sorted(set(dirs), key=lambda p: tuple(p.parts))
|
||||
|
||||
|
||||
if bundled_enabled:
|
||||
bundled_root = src / "skills"
|
||||
if not bundled_root.exists():
|
||||
raise SystemExit(f"bundled skills directory missing: {bundled_root}")
|
||||
|
||||
for skill_rel in skill_dirs_under(bundled_root):
|
||||
copy_tree(bundled_root / skill_rel, out / skill_rel)
|
||||
managed.append(str(skill_rel))
|
||||
|
||||
for desc in bundled_root.rglob("DESCRIPTION.md"):
|
||||
rel = desc.relative_to(bundled_root)
|
||||
target = out / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(desc, target)
|
||||
|
||||
for rel_str in optional_skills:
|
||||
rel = Path(rel_str)
|
||||
if rel.is_absolute() or ".." in rel.parts or len(rel.parts) == 0:
|
||||
raise SystemExit(f"invalid optional skill path: {rel_str}")
|
||||
|
||||
src_dir = src / "optional-skills" / rel
|
||||
if not src_dir.exists():
|
||||
raise SystemExit(f"optional skill path not found: {rel_str}")
|
||||
if not (src_dir / "SKILL.md").exists():
|
||||
raise SystemExit(f"optional skill missing SKILL.md: {rel_str}")
|
||||
|
||||
copy_tree(src_dir, out / rel)
|
||||
managed.append(str(rel))
|
||||
|
||||
if len(rel.parts) > 1:
|
||||
desc_src = src / "optional-skills" / rel.parts[0] / "DESCRIPTION.md"
|
||||
if desc_src.exists():
|
||||
desc_dst = out / rel.parts[0] / "DESCRIPTION.md"
|
||||
desc_dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(desc_src, desc_dst)
|
||||
|
||||
for spec in custom_skills:
|
||||
name = spec["name"]
|
||||
category = spec.get("category")
|
||||
src_dir = Path(spec["source"])
|
||||
rel = Path(category) / name if category else Path(name)
|
||||
|
||||
if not src_dir.exists():
|
||||
raise SystemExit(f"custom skill source not found: {src_dir}")
|
||||
if not (src_dir / "SKILL.md").exists():
|
||||
raise SystemExit(f"custom skill missing SKILL.md: {src_dir}")
|
||||
|
||||
copy_tree(src_dir, out / rel)
|
||||
managed.append(str(rel))
|
||||
|
||||
(out / ".nix-managed-skills.json").write_text(
|
||||
json.dumps(sorted(set(managed)), indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
'';
|
||||
|
||||
# Convert Nix attrset → YAML via JSON intermediary
|
||||
# (Hermes reads YAML but YAML is a superset of JSON, so JSON works)
|
||||
configJson = builtins.toJSON cfg.config;
|
||||
|
|
@ -136,6 +267,57 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
# ── Skills (declarative materialization) ─────────────────────────────
|
||||
skills = mkOption {
|
||||
default = { };
|
||||
description = ''
|
||||
Declarative Hermes skills materialized into `${cfg.stateDir}/.hermes/skills`.
|
||||
Phase 1 supports bundled upstream skills, selected optional skills,
|
||||
and custom local skills.
|
||||
'';
|
||||
type = types.submodule {
|
||||
options = {
|
||||
bundled.enable = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Install upstream bundled skills declaratively into HERMES_HOME/skills.
|
||||
'';
|
||||
};
|
||||
|
||||
optional = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
example = [
|
||||
"creative/storytelling"
|
||||
"research/deep-research"
|
||||
];
|
||||
description = ''
|
||||
Relative paths under upstream `optional-skills/` to install.
|
||||
Example: `creative/some-skill`.
|
||||
'';
|
||||
};
|
||||
|
||||
custom = mkOption {
|
||||
type = types.attrsOf customSkillType;
|
||||
default = { };
|
||||
description = ''
|
||||
Custom local skills keyed by installed skill name. Each entry points
|
||||
to a directory containing `SKILL.md` and any linked files.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
{
|
||||
repo-watch = {
|
||||
category = "research";
|
||||
source = ./skills/repo-watch;
|
||||
};
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# ── Secrets / environment ────────────────────────────────────────────
|
||||
environmentFiles = mkOption {
|
||||
type = types.listOf types.str;
|
||||
|
|
@ -314,12 +496,15 @@ in
|
|||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.hermes 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.hermes/skills 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.workingDirectory} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${builtins.dirOf cfg.logPath} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
# ── Activation: link config + documents into state dir ───────────────
|
||||
# ── Activation: link config + documents + managed skills into state dir ──
|
||||
system.activationScripts."hermes-agent-setup" = lib.stringAfter [ "users" ] ''
|
||||
set -euo pipefail
|
||||
|
||||
# Link config file
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0640 -D ${configFile} ${cfg.stateDir}/.hermes/cli-config.yaml
|
||||
|
||||
|
|
@ -345,6 +530,60 @@ in
|
|||
install -o ${cfg.user} -g ${cfg.group} -m 0644 ${documentDerivation}/${name} ${cfg.workingDirectory}/${name}
|
||||
'') cfg.documents
|
||||
)}
|
||||
|
||||
# Reconcile Nix-managed skills without touching hub/runtime-managed state
|
||||
${lib.optionalString skillsManaged ''
|
||||
skills_dir=${cfg.stateDir}/.hermes/skills
|
||||
managed_tree=${managedSkillsTree}
|
||||
state_file="$skills_dir/.nix-managed-skills.json"
|
||||
desired_file="$managed_tree/.nix-managed-skills.json"
|
||||
|
||||
install -d -o ${cfg.user} -g ${cfg.group} -m 0750 "$skills_dir"
|
||||
|
||||
skills_dir="$skills_dir" \
|
||||
managed_tree="$managed_tree" \
|
||||
state_file="$state_file" \
|
||||
desired_file="$desired_file" \
|
||||
${pkgs.python3}/bin/python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
skills_dir = Path(os.environ["skills_dir"])
|
||||
managed_tree = Path(os.environ["managed_tree"])
|
||||
state_file = Path(os.environ["state_file"])
|
||||
desired_file = Path(os.environ["desired_file"])
|
||||
|
||||
old = []
|
||||
if state_file.exists():
|
||||
old = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
new = json.loads(desired_file.read_text(encoding="utf-8"))
|
||||
|
||||
for rel in sorted(set(old) - set(new), reverse=True):
|
||||
target = skills_dir / rel
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
|
||||
for rel in new:
|
||||
src = managed_tree / rel
|
||||
dst = skills_dir / rel
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(src, dst)
|
||||
|
||||
for desc in managed_tree.rglob("DESCRIPTION.md"):
|
||||
rel = desc.relative_to(managed_tree)
|
||||
dst = skills_dir / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(desc, dst)
|
||||
|
||||
state_file.write_text(json.dumps(new, indent=2) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
chown -R ${cfg.user}:${cfg.group} "$skills_dir"
|
||||
''}
|
||||
'';
|
||||
|
||||
# ── systemd service ──────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue