diff --git a/README.md b/README.md index 85e753c..bce7774 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,20 @@ Everything is configured in Nix. Config, documents, secrets, service — one `ni # "SOUL.md" = ./documents/SOUL.md; }; + # ── Declarative skills (phase 1) ── + skills = { + bundled.enable = true; + optional = [ + "creative/blender-mcp" + ]; + custom = { + repo-watch = { + category = "research"; + source = ./skills/repo-watch; + }; + }; + }; + # ── MCP servers ── mcpServers = { context7 = { @@ -209,6 +223,7 @@ You (Telegram/Discord/WhatsApp/Slack) → Gateway → Tools → Machine does thi | `config` | attrset | `{}` | Declarative config (→ cli-config.yaml) | | `configFile` | path | `null` | Use existing config file (overrides `config`) | | `documents` | attrset | `{}` | Workspace files (string or path values) | +| `skills` | attrset | `{}` | Declarative Hermes skills (bundled, optional, custom local) | | `environmentFiles` | list | `[]` | Secret env files (systemd EnvironmentFile) | | `environment` | attrset | `{}` | Non-secret env vars | | `authFile` | path | `null` | OAuth credentials file (auth.json) | diff --git a/checks.nix b/checks.nix index 6720e16..923faf5 100644 --- a/checks.nix +++ b/checks.nix @@ -95,4 +95,21 @@ mkdir -p $out echo "ok" > $out/result ''; + + # Verify upstream source exposed to the module contains skills trees + skills-source-layout = pkgs.runCommand "hermes-skills-source-layout" { } '' + set -e + + SRC=${hermes-agent.upstreamSrc} + + echo "=== Checking upstream skills trees ===" + test -d "$SRC/skills" || (echo "FAIL: missing skills/ in upstream source"; exit 1) + test -d "$SRC/optional-skills" || (echo "FAIL: missing optional-skills/ in upstream source"; exit 1) + find "$SRC/skills" -name SKILL.md -print -quit | grep -q . || (echo "FAIL: no bundled SKILL.md found"; exit 1) + find "$SRC/optional-skills" -name SKILL.md -print -quit | grep -q . || (echo "FAIL: no optional SKILL.md found"; exit 1) + echo "PASS: upstream skills source layout present" + + mkdir -p $out + echo "ok" > $out/result + ''; } diff --git a/module.nix b/module.nix index 8cd198f..c332a9c 100644 --- a/module.nix +++ b/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 ────────────────────────────────────────────────── diff --git a/package.nix b/package.nix index 52b1470..79be02d 100644 --- a/package.nix +++ b/package.nix @@ -185,6 +185,10 @@ pythonPackages.buildPythonApplication { done ''; + passthru = { + upstreamSrc = src; + }; + meta = with lib; { description = "The self-improving AI agent by Nous Research"; homepage = "https://github.com/NousResearch/hermes-agent";