From c2966d25152961c064db6abd116abb786b534b01 Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:23:53 +0000 Subject: [PATCH 1/6] docs: add declarative skills PRD --- docs/SKILLS-DECLARATIVE-PRD.md | 776 +++++++++++++++++++++++++++++++++ 1 file changed, 776 insertions(+) create mode 100644 docs/SKILLS-DECLARATIVE-PRD.md diff --git a/docs/SKILLS-DECLARATIVE-PRD.md b/docs/SKILLS-DECLARATIVE-PRD.md new file mode 100644 index 0000000..ad4e437 --- /dev/null +++ b/docs/SKILLS-DECLARATIVE-PRD.md @@ -0,0 +1,776 @@ +# Declarative Skills for nix-hermes-agent + +Status: Draft +Owner: 0xrsydn / Ciphercat +Branch: `feat/skills` + +## TL;DR + +`nix-hermes-agent` already makes Hermes config, documents, auth seeding, and service wiring declarative. +The next maturity step is to make **skills** first-class declarative state too. + +This document proposes a practical, upstream-aware design that: + +1. preserves Hermes' filesystem-based skill model, +2. respects upstream bundled/optional/hub distinctions, +3. keeps Nix-managed state reproducible, +4. avoids fragile runtime-only snowflake setup, +5. leaves room for future hub/external skills without making the first version messy. + +The recommended rollout is: + +- **Phase 1:** declarative bundled + optional + local custom skills +- **Phase 2:** declarative enable/disable controls per platform +- **Phase 3:** optional support for declarative external/hub skills +- **Phase 4:** profile abstractions on top + +--- + +## Problem + +Today `nix-hermes-agent` can declaratively manage: + +- package version +- service wiring +- `HERMES_HOME` +- generated config +- documents like `SOUL.md`, `AGENTS.md`, `USER.md` +- auth seed file +- env files +- MCP servers + +But skills remain effectively under-managed. + +That creates several problems: + +### 1. Reproducibility gap + +Useful Hermes deployments are shaped heavily by available skills. +Right now those are not modeled as deployable state in the Nix module. + +### 2. Snowflake drift + +A machine can gain skills interactively via Hermes CLI, but the resulting state is not cleanly captured in Nix. +Rebuilds and reprovisioning become less trustworthy. + +### 3. Poor fleet ergonomics + +If we want Hermes to live as: + +- a gateway agent, +- a CLI/TUI agent over SSH, +- an ACP-connected IDE agent, +- a research/creative/coding sandbox on a dedicated VM, + +then the skill substrate needs to be reproducible and composable. + +### 4. Missing product maturity + +A declarative agent deployment layer that cannot declaratively express skills is not finished. + +--- + +## Goal + +Make `nix-hermes-agent` mature enough that a user can declare Hermes skill state in Nix with the same confidence they already declare config and documents. + +Success means a user can express: + +- which built-in/bundled skills exist, +- which upstream optional skills are installed, +- which local custom skills are present, +- which skills are disabled globally or per platform, +- eventually, which external/hub skills are desired, + +and rebuild a host into the same functional Hermes deployment. + +--- + +## Non-goals + +### Not a full rewrite of upstream skill management + +We should not fork or replace Hermes' skill architecture. +We should layer on top of real upstream semantics. + +### Not immediate perfect support for every hub workflow + +Hub-managed skills involve remote registries, quarantine, audit, taps, lock files, and provenance. +We should not block the entire feature set on solving every edge case in v1. + +### Not soul/prompt redesign + +This effort is about capability substrate and deployment maturity, not voice tuning. + +--- + +## Upstream architecture findings + +This section captures the relevant upstream seams that matter for Nix integration. + +## 1. Single runtime skill root + +Hermes treats this as the runtime source of truth: + +- `HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))` +- `SKILLS_DIR = HERMES_HOME / "skills"` + +Relevant upstream files: + +- `tools/skills_tool.py` +- `tools/skills_hub.py` +- `tools/skills_sync.py` + +Implication: + +**Nix should target `${HERMES_HOME}/skills` as the canonical runtime location.** +Any declarative solution that ignores this will fight upstream. + +## 2. Filesystem-based discovery + +Skills are discovered by scanning `SKILLS_DIR` recursively for `SKILL.md`. + +Relevant upstream behavior: + +- `tools/skills_tool.py:_find_all_skills()` +- category derived from relative path under `SKILLS_DIR` +- category descriptions can come from `DESCRIPTION.md` + +Implication: + +**Declarative skills do not need a custom registry format.** +They mainly need correct directory materialization. + +## 3. Bundled skills are synced via a manifest + +Upstream has a sync layer for bundled skills: + +- `tools/skills_sync.py` +- manifest file: `~/.hermes/skills/.bundled_manifest` + +Behavior summary: + +- bundled skills are synced into `~/.hermes/skills/` +- manifest tracks origin hashes +- user modifications/deletions are handled intentionally +- removed bundled skills are cleaned from manifest + +Implication: + +**Bundled skills are not just copied blindly.** +If we want to align with upstream behavior, we should respect that bundled skills have their own lifecycle semantics. + +## 4. Optional skills are shipped in repo, but not bundled into runtime by default + +Upstream has `optional-skills/` and exposes them through hub-style install/search flows. + +Relevant upstream behavior: + +- `tools/skills_hub.py: OptionalSkillsSource` +- source path: repo `optional-skills/` +- install identifiers look like `official/category/skill` or `official/skill` + +Implication: + +Optional skills are a clean fit for declarative install because they are effectively pinned to the package revision already. + +## 5. Skill enable/disable is config-driven + +Skill disabling lives in Hermes config, not the filesystem. + +Relevant upstream behavior: + +- `hermes_cli/skills_config.py` +- `tools/skills_tool.py:_get_disabled_skill_names()` +- config keys: + +```yaml +skills: + disabled: [] + platform_disabled: {} +``` + +Implication: + +**Installation and enablement are separate concerns.** +The Nix module should model both. + +## 6. Hub/external skills carry provenance state + +Hub state lives under: + +- `~/.hermes/skills/.hub/lock.json` +- `~/.hermes/skills/.hub/taps.json` +- plus quarantine/audit state + +Relevant upstream behavior: + +- `tools/skills_hub.py:HubLockFile` +- `tools/skills_hub.py:TapsManager` +- install flow records source/trust/provenance/install_path/hash + +Implication: + +External skill support has real statefulness and should be treated as a separate phase. + +--- + +## Product principles + +## 1. Reproducibility first + +The primary value of `nix-hermes-agent` is that Hermes deployment stops being snowflake state. +Skills must follow the same principle. + +## 2. Align with upstream instead of papering over it + +Where upstream already has a lifecycle concept: + +- bundled manifest, +- optional skill source, +- hub lock file, +- disabled config, + +we should design with those semantics in mind. + +## 3. Prefer pinned/local skill materialization over runtime network installs + +For Nix-managed deployments, local/pinned skills are cleaner than runtime remote fetches. + +## 4. Separate “present on disk” from “enabled for runtime” + +This mirrors upstream and keeps the module more expressive. + +## 5. Make the first version boring and reliable + +Copy/sync into runtime state first. +Do not over-optimize for immutable symlink purity if it breaks upstream assumptions. + +--- + +## Proposed module design + +## Phase 1: Declarative local skill materialization + +### New option group: `services.hermes-agent.skills` + +Suggested top-level shape: + +```nix +services.hermes-agent.skills = { + bundled.enable = true; + + optional = [ + "research/foo" + "creative/bar" + ]; + + custom = { + my-playbook = { + category = "research"; + source = ./skills/my-playbook; + }; + }; + + disabled = [ "foo" ]; + + platformDisabled = { + telegram = [ "shell-heavy-skill" ]; + cli = [ ]; + }; +}; +``` + +### Sub-feature A: bundled skills + +#### Goal +Allow `nix-hermes-agent` to ensure upstream bundled skills are present declaratively. + +#### Why +This makes package upgrades and fresh machines deterministic. + +#### Design options + +##### Option A — let upstream sync bundled skills at runtime +Pros: +- closest to upstream lifecycle +- manifest semantics stay upstream-owned + +Cons: +- requires that sync path is guaranteed to run in the packaged flow +- harder to reason about from Nix module alone + +##### Option B — module-managed bundled sync/materialization +Pros: +- explicit and controllable from module +- can be tested from Nix side + +Cons: +- risks partially duplicating upstream logic unless done carefully + +#### Recommendation +Use **module-managed materialization** initially, but preserve upstream-compatible structure and manifest awareness. +If we later find an upstream-supported sync entrypoint that is stable, we can switch implementation without changing user-facing options. + +### Sub-feature B: optional skills + +#### Goal +Install upstream `optional-skills/` declaratively by package revision. + +#### Why +These are the cleanest next-step skills because they are already version-pinned by the packaged source tree. + +#### Recommendation +Expose a list of relative skill paths from upstream optional-skills. +Example: + +```nix +services.hermes-agent.skills.optional = [ + "research/deep-research" + "creative/story-ideation" +]; +``` + +The module activation step should materialize these into `${HERMES_HOME}/skills/...` preserving category layout. + +### Sub-feature C: custom local skills + +#### Goal +Allow users to declaratively ship their own skills. + +#### Recommendation +Support both: + +- `source = ./path/to/skill-dir` +- later maybe `text = '' ... SKILL.md ... ''` for convenience + +Suggested shape: + +```nix +services.hermes-agent.skills.custom = { + repo-watch = { + category = "research"; + source = ./skills/repo-watch; + }; +}; +``` + +This should build a derivation containing normalized skill trees, then copy/sync them into runtime state. + +--- + +## Phase 2: Declarative enable/disable controls + +### Goal +Manage the upstream config shape declaratively. + +Suggested mapping: + +```nix +services.hermes-agent.skills.disabled = [ "foo" ]; +services.hermes-agent.skills.platformDisabled.telegram = [ "bar" ]; +``` + +Module implementation should merge this into generated Hermes config: + +```nix +config.skills = { + disabled = ...; + platform_disabled = ...; +}; +``` + +### Why separate from installation? +Because upstream separates them, and it enables useful patterns: + +- skill present but disabled on messaging platforms +- skill enabled only in CLI/ACP contexts +- same deployment with different platform affordances + +This matters a lot if Hermes is used across: + +- gateway, +- SSH CLI/TUI, +- ACP/IDE connection. + +--- + +## Phase 3: Declarative external / hub skills + +This should be treated as a separate feature set, not bundled into v1. + +### Problem +Hub skills are not just files. +They have upstream lifecycle semantics: + +- provenance +- trust level +- lock entries +- taps +- audit log +- quarantine/security scanning + +### Two viable models + +#### Model A — runtime installation via Hermes CLI +Declare desired hub skills in Nix, then reconcile at activation/runtime using Hermes itself: + +```nix +services.hermes-agent.skills.hub = [ + { + identifier = "owner/repo/path/to/skill"; + source = "github"; + } +]; +``` + +Pros: +- preserves upstream lock/audit/scan behavior + +Cons: +- network-dependent +- less reproducible +- slower/more stateful + +#### Model B — pin external skill sources in Nix and treat them as local skills +Use `fetchFromGitHub` or flake inputs, then place them under `custom`/local managed skills. + +Pros: +- reproducible +- reviewable +- pinned by hash +- easier to reason about in infra repos + +Cons: +- bypasses hub provenance model unless explicitly emulated + +### Recommendation +For `nix-hermes-agent`, **prefer Model B philosophically**. +Support Model A later as an escape hatch for people who want native Hermes Hub behavior. + +--- + +## Phase 4: Profiles + +Once the substrate exists, we can define higher-level role profiles. +Examples: + +- `researcher` +- `creative-lab` +- `ops-lite` +- `coding-explorer` + +These profiles would be syntactic sugar over: + +- installed skills +- disabled skills +- model/tool config +- maybe documents + +This should come after raw primitives are solid. + +--- + +## Proposed option schema + +This is a proposed user-facing module API, not final code. + +```nix +services.hermes-agent.skills = { + enable = lib.mkEnableOption "declarative Hermes skills"; + + bundled = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + }; + }; + + optional = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "research/deep-research" ]; + }; + + custom = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule ({ ... }: { + options = { + category = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + }; + source = lib.mkOption { + type = lib.types.path; + }; + }; + })); + default = { }; + }; + + disabled = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + }; + + platformDisabled = lib.mkOption { + type = lib.types.attrsOf (lib.types.listOf lib.types.str); + default = { }; + }; + + hub = lib.mkOption { + type = lib.types.listOf (lib.types.submodule ({ ... }: { + options = { + identifier = lib.mkOption { type = lib.types.str; }; + source = lib.mkOption { + type = lib.types.enum [ "github" "official" "well-known" "skills-sh" ]; + default = "github"; + }; + }; + })); + default = [ ]; + }; +}; +``` + +--- + +## Implementation plan + +## Milestone 1 — groundwork and packaging introspection + +### Deliverables +- confirm packaged source paths for bundled skills and optional-skills +- add tests or at least build-time assertions that those paths exist in the package output +- document path assumptions + +### Notes +Current package already fetches upstream source with submodules and builds Hermes as a Python app. +We need a stable way to refer to upstream skills trees from the installed package or derivation source. + +### Risks +- packaged output may not expose source trees exactly where activation expects them +- upstream package layout may shift across releases + +### Mitigation +Add explicit path probes in checks/docs and keep implementation centralized. + +--- + +## Milestone 2 — declarative bundled + optional + custom skill materialization + +### Deliverables +- module options for bundled/optional/custom skills +- activation step that creates `${HERMES_HOME}/skills` +- copy/sync logic preserving category layout +- initial tests covering presence of selected skills in state dir + +### Implementation guidance +Use a generated derivation to normalize all Nix-managed skill content into one tree, then reconcile that tree into runtime state. + +Example conceptual pipeline: + +1. build a Nix store tree containing: + - selected bundled skills + - selected optional skills + - custom local skills +2. activation script syncs that tree into `${cfg.stateDir}/.hermes/skills` +3. preserve non-Nix-managed hub state unless explicitly managing it + +### Important constraint +Do **not** clobber `.hub/` blindly. +Do **not** wipe user-managed runtime state unless explicitly requested. + +--- + +## Milestone 3 — declarative disabled/platformDisabled wiring + +### Deliverables +- module options +- merge into generated Hermes config +- tests that rendered config contains expected shape + +### Why this milestone is low risk +This is a direct mapping to upstream config semantics and does not require custom lifecycle logic. + +--- + +## Milestone 4 — docs and examples + +### Deliverables +- README examples for: + - basic bundled skill deployment + - optional skills + - custom local skills + - platform-specific disabling +- migration guidance for users already managing skills interactively + +--- + +## Milestone 5 — optional hub skill support + +### Deliverables +- design decision: native runtime install vs pinned-local strategy +- if implemented, add explicit caveats about reproducibility and network dependency +- preserve upstream lock/audit semantics + +### Recommendation +Do not block the core feature on this milestone. + +--- + +## State reconciliation strategy + +This is the most important implementation choice. + +## Recommended approach: managed subtree reconciliation + +Use a managed subset of `${HERMES_HOME}/skills` while leaving upstream-owned dynamic state alone. + +### Desired behavior +Nix should manage: + +- bundled skills selected by module policy +- optional skills selected by module policy +- custom local skills selected by module policy + +Nix should avoid trampling: + +- `.hub/` +- maybe `.bundled_manifest` unless we intentionally integrate with it +- runtime-installed non-managed skills unless user opts into strict mode + +### Possible implementation pattern +Maintain a managed marker file or managed manifest under Hermes home, e.g.: + +- `${HERMES_HOME}/skills/.nix-managed-manifest.json` + +Track which installed paths belong to Nix-managed state. +On activation: + +- create/update managed paths +- remove managed paths no longer desired +- leave unmanaged paths untouched + +This avoids destructive full-directory replacement and plays better with upstream hub installs. + +--- + +## Testing strategy + +## 1. Evaluation tests +Ensure module options evaluate and merge correctly. + +## 2. Render tests +Validate generated config contains expected skill disable keys. + +## 3. Activation tests +On a NixOS test VM or shell-based checks: + +- deploy with custom skill +- assert `SKILL.md` lands under expected path +- deploy with optional skill +- assert category structure preserved +- rebuild with skill removed +- assert only managed skill removed +- assert `.hub/` untouched + +## 4. Regression tests +Check package path assumptions for upstream bundled and optional-skills trees. + +--- + +## Open questions + +## 1. Should bundled skills be managed by module or left to upstream sync? +Current recommendation: module-managed initially, but keep implementation swappable. + +## 2. Should Nix-managed skills be copied or symlinked into runtime? +Current recommendation: **copy/sync first**. +This is more boring but more compatible with any upstream expectations around writable trees and file operations. + +## 3. Should hub skills be in scope for the first PR series? +Current recommendation: **no**. +Document the plan, but ship local/offline-managed skills first. + +## 4. Should we support inline `SKILL.md` text for custom skills? +Probably yes later, but path-based custom skills are enough for v1. + +--- + +## Recommended PR breakdown + +## PR 1 — groundwork +- package path assertions +- module option scaffolding +- docs stub + +## PR 2 — declarative local skills +- bundled/optional/custom skill options +- managed reconciliation logic +- tests + +## PR 3 — declarative disable controls +- `disabled` +- `platformDisabled` +- config wiring +- tests + +## PR 4 — docs/examples +- README updates +- migration examples +- profile examples + +## PR 5 — optional hub support (if still desired) +- separate decision doc +- explicit caveats + +--- + +## Recommendation + +The repo should focus on **declarative skills maturity now**, not soul tuning. + +The best next step is to implement a boring, reliable, upstream-aware layer for: + +- bundled skills, +- optional skills, +- custom local skills, +- disabled/platform-disabled config. + +That will make `nix-hermes-agent` meaningfully more mature and unlock better Hermes roles across: + +- gateway usage, +- SSH CLI/TUI usage, +- ACP/IDE connections, +- dedicated research/creative sandbox VMs. + +--- + +## Appendix: Upstream files worth watching + +For future maintainers, these upstream files are the primary integration seams: + +- `tools/skills_tool.py` + - `SKILLS_DIR` + - `_find_all_skills()` + - `_get_disabled_skill_names()` + - category/path discovery + +- `tools/skills_sync.py` + - bundled skill sync behavior + - `.bundled_manifest` + +- `tools/skills_hub.py` + - `OptionalSkillsSource` + - `HubLockFile` + - `TapsManager` + - install/uninstall semantics + +- `hermes_cli/skills_config.py` + - user-facing config model for disabled/platform-disabled skills + +- `hermes_cli/config.py` + - `HERMES_HOME` + - merged config semantics + +These are the places likely to matter most when upstream changes skill behavior. From a1b9295c8ef9c362b031aefba474c81eb0cd078f Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:58:14 +0000 Subject: [PATCH 2/6] feat: add declarative Hermes skills phase 1 --- README.md | 15 ++++ checks.nix | 17 ++++ module.nix | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++- package.nix | 4 + 4 files changed, 276 insertions(+), 1 deletion(-) 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"; From 481c62c56842f947d3b515f1f1ae560f8879d73f Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:21:11 +0000 Subject: [PATCH 3/6] docs: clarify declarative and native skills coexistence --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/README.md b/README.md index bce7774..349799e 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,77 @@ You (Telegram/Discord/WhatsApp/Slack) → Gateway → Tools → Machine does thi └── gateway.log # Service log ``` +## Declarative Skills vs Native Hermes Skills + +The `skills` option is designed to **augment Hermes**, not replace Hermes' native skill workflow. + +Both approaches compose into the same runtime directory: + +- `${stateDir}/.hermes/skills/` + +That means you can use both: + +- **declarative skills** from Nix +- **interactive/runtime skills** from `hermes skills install` + +### Ownership model + +#### Nix-managed +Skills declared via: + +- `services.hermes-agent.skills.bundled` +- `services.hermes-agent.skills.optional` +- `services.hermes-agent.skills.custom` + +are reconciled by the module and tracked in: + +- `.nix-managed-skills.json` + +These paths are considered **owned by Nix**. + +#### Hermes-managed +Skills installed later via Hermes CLI, plus hub metadata under: + +- `.hermes/skills/.hub/` + +are left alone by the module **unless they collide with a Nix-managed path**. + +### Collision rule + +If a Hermes CLI install and a declarative Nix skill target the same installed path, +**the declarative Nix version wins on the next activation/rebuild**. + +Example: + +- Nix declares `creative/blender-mcp` +- user later installs another `creative/blender-mcp` via Hermes CLI + +On the next rebuild, the Nix-declared version is restored. + +### Recommended workflow + +Use **Hermes CLI** for: + +- experimentation +- hub/community skill discovery +- temporary installs +- trying before keeping + +Use **Nix declarative skills** for: + +- stable/reproducible deployments +- bundled upstream skills you always want +- selected optional skills you want pinned to the package revision +- local custom house skills stored in git + +A good pattern is: + +1. install/try a skill interactively, +2. decide it is worth keeping, +3. promote it into Nix config if you want it reproducible. + +This keeps `nix-hermes-agent` useful without interfering with the native Hermes experience. + ## Module Options | Option | Type | Default | Description | From 1a1f06aaa5af2648ed364d361cb08ddec640f3b0 Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:48:13 +0000 Subject: [PATCH 4/6] test: add skills coexistence e2e coverage --- flake.nix | 14 +++++-- module.nix | 7 ++++ tests/fixtures/custom-skill/SKILL.md | 8 ++++ tests/skills-coexistence.nix | 58 ++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/custom-skill/SKILL.md create mode 100644 tests/skills-coexistence.nix diff --git a/flake.nix b/flake.nix index 5d15287..828c89a 100644 --- a/flake.nix +++ b/flake.nix @@ -23,10 +23,16 @@ default = self.packages.${system}.hermes-agent; }; - checks = import ./checks.nix { - inherit pkgs; - inherit (self.packages.${system}) hermes-agent; - }; + checks = + (import ./checks.nix { + inherit pkgs; + inherit (self.packages.${system}) hermes-agent; + }) + // { + skills-coexistence = import ./tests/skills-coexistence.nix { + inherit self nixpkgs system; + }; + }; devShells.default = pkgs.mkShell { packages = [ self.packages.${system}.hermes-agent ]; diff --git a/module.nix b/module.nix index c332a9c..df9e682 100644 --- a/module.nix +++ b/module.nix @@ -505,6 +505,13 @@ in system.activationScripts."hermes-agent-setup" = lib.stringAfter [ "users" ] '' set -euo pipefail + # Ensure required directories exist during activation (do not rely on tmpfiles ordering) + install -d -o ${cfg.user} -g ${cfg.group} -m 0750 ${cfg.stateDir} + install -d -o ${cfg.user} -g ${cfg.group} -m 0750 ${cfg.stateDir}/.hermes + install -d -o ${cfg.user} -g ${cfg.group} -m 0750 ${cfg.stateDir}/.hermes/skills + install -d -o ${cfg.user} -g ${cfg.group} -m 0750 ${cfg.workingDirectory} + install -d -o ${cfg.user} -g ${cfg.group} -m 0750 ${builtins.dirOf cfg.logPath} + # Link config file install -o ${cfg.user} -g ${cfg.group} -m 0640 -D ${configFile} ${cfg.stateDir}/.hermes/cli-config.yaml diff --git a/tests/fixtures/custom-skill/SKILL.md b/tests/fixtures/custom-skill/SKILL.md new file mode 100644 index 0000000..c42e462 --- /dev/null +++ b/tests/fixtures/custom-skill/SKILL.md @@ -0,0 +1,8 @@ +--- +name: repo-watch +description: Test custom skill for nix-hermes-agent module validation. +--- + +# repo-watch + +Test skill body. diff --git a/tests/skills-coexistence.nix b/tests/skills-coexistence.nix new file mode 100644 index 0000000..e7c9e1c --- /dev/null +++ b/tests/skills-coexistence.nix @@ -0,0 +1,58 @@ +{ self, nixpkgs, system }: + +let + pkgs = import nixpkgs { inherit system; }; + testSkill = builtins.path { + path = ./fixtures/custom-skill; + name = "hermes-test-custom-skill"; + }; +in +pkgs.testers.runNixOSTest { + name = "hermes-skills-coexistence"; + + nodes.machine = { ... }: { + imports = [ self.nixosModules.hermes-agent ]; + + services.hermes-agent = { + enable = true; + package = self.packages.${system}.hermes-agent; + skills = { + bundled.enable = false; + custom.repo-watch = { + category = "research"; + source = testSkill; + }; + }; + documents = { + "SOUL.md" = "# SOUL.md\nTest soul\n"; + "AGENTS.md" = "# AGENTS.md\nTest agents\n"; + "USER.md" = "# USER.md\nTest user\n"; + }; + config = { + toolsets = [ "all" ]; + model = { + default = "moonshotai/kimi-k2.5"; + provider = "openrouter"; + }; + }; + }; + + system.stateVersion = "25.05"; + }; + + testScript = '' + machine.wait_for_unit("multi-user.target") + + machine.succeed("test -f /var/lib/hermes/.hermes/skills/research/repo-watch/SKILL.md") + machine.succeed("grep -F 'research/repo-watch' /var/lib/hermes/.hermes/skills/.nix-managed-skills.json") + + machine.succeed("mkdir -p /var/lib/hermes/.hermes/skills/manual-test") + machine.succeed("cat > /var/lib/hermes/.hermes/skills/manual-test/SKILL.md <<'EOF'\n---\nname: manual-test\ndescription: unmanaged test skill\n---\n\n# manual-test\nEOF") + machine.succeed("chown -R hermes:hermes /var/lib/hermes/.hermes/skills/manual-test") + + machine.succeed("/run/current-system/activate") + + machine.succeed("test -f /var/lib/hermes/.hermes/skills/research/repo-watch/SKILL.md") + machine.succeed("test -f /var/lib/hermes/.hermes/skills/manual-test/SKILL.md") + ''; +} From 0a03c43f903ae8a7112c573da3ab77dcacbd2a15 Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:51:03 +0000 Subject: [PATCH 5/6] docs: remove superseded skills PRD --- docs/SKILLS-DECLARATIVE-PRD.md | 776 --------------------------------- module.nix | 2 +- tests/skills-coexistence.nix | 56 +-- 3 files changed, 32 insertions(+), 802 deletions(-) delete mode 100644 docs/SKILLS-DECLARATIVE-PRD.md diff --git a/docs/SKILLS-DECLARATIVE-PRD.md b/docs/SKILLS-DECLARATIVE-PRD.md deleted file mode 100644 index ad4e437..0000000 --- a/docs/SKILLS-DECLARATIVE-PRD.md +++ /dev/null @@ -1,776 +0,0 @@ -# Declarative Skills for nix-hermes-agent - -Status: Draft -Owner: 0xrsydn / Ciphercat -Branch: `feat/skills` - -## TL;DR - -`nix-hermes-agent` already makes Hermes config, documents, auth seeding, and service wiring declarative. -The next maturity step is to make **skills** first-class declarative state too. - -This document proposes a practical, upstream-aware design that: - -1. preserves Hermes' filesystem-based skill model, -2. respects upstream bundled/optional/hub distinctions, -3. keeps Nix-managed state reproducible, -4. avoids fragile runtime-only snowflake setup, -5. leaves room for future hub/external skills without making the first version messy. - -The recommended rollout is: - -- **Phase 1:** declarative bundled + optional + local custom skills -- **Phase 2:** declarative enable/disable controls per platform -- **Phase 3:** optional support for declarative external/hub skills -- **Phase 4:** profile abstractions on top - ---- - -## Problem - -Today `nix-hermes-agent` can declaratively manage: - -- package version -- service wiring -- `HERMES_HOME` -- generated config -- documents like `SOUL.md`, `AGENTS.md`, `USER.md` -- auth seed file -- env files -- MCP servers - -But skills remain effectively under-managed. - -That creates several problems: - -### 1. Reproducibility gap - -Useful Hermes deployments are shaped heavily by available skills. -Right now those are not modeled as deployable state in the Nix module. - -### 2. Snowflake drift - -A machine can gain skills interactively via Hermes CLI, but the resulting state is not cleanly captured in Nix. -Rebuilds and reprovisioning become less trustworthy. - -### 3. Poor fleet ergonomics - -If we want Hermes to live as: - -- a gateway agent, -- a CLI/TUI agent over SSH, -- an ACP-connected IDE agent, -- a research/creative/coding sandbox on a dedicated VM, - -then the skill substrate needs to be reproducible and composable. - -### 4. Missing product maturity - -A declarative agent deployment layer that cannot declaratively express skills is not finished. - ---- - -## Goal - -Make `nix-hermes-agent` mature enough that a user can declare Hermes skill state in Nix with the same confidence they already declare config and documents. - -Success means a user can express: - -- which built-in/bundled skills exist, -- which upstream optional skills are installed, -- which local custom skills are present, -- which skills are disabled globally or per platform, -- eventually, which external/hub skills are desired, - -and rebuild a host into the same functional Hermes deployment. - ---- - -## Non-goals - -### Not a full rewrite of upstream skill management - -We should not fork or replace Hermes' skill architecture. -We should layer on top of real upstream semantics. - -### Not immediate perfect support for every hub workflow - -Hub-managed skills involve remote registries, quarantine, audit, taps, lock files, and provenance. -We should not block the entire feature set on solving every edge case in v1. - -### Not soul/prompt redesign - -This effort is about capability substrate and deployment maturity, not voice tuning. - ---- - -## Upstream architecture findings - -This section captures the relevant upstream seams that matter for Nix integration. - -## 1. Single runtime skill root - -Hermes treats this as the runtime source of truth: - -- `HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))` -- `SKILLS_DIR = HERMES_HOME / "skills"` - -Relevant upstream files: - -- `tools/skills_tool.py` -- `tools/skills_hub.py` -- `tools/skills_sync.py` - -Implication: - -**Nix should target `${HERMES_HOME}/skills` as the canonical runtime location.** -Any declarative solution that ignores this will fight upstream. - -## 2. Filesystem-based discovery - -Skills are discovered by scanning `SKILLS_DIR` recursively for `SKILL.md`. - -Relevant upstream behavior: - -- `tools/skills_tool.py:_find_all_skills()` -- category derived from relative path under `SKILLS_DIR` -- category descriptions can come from `DESCRIPTION.md` - -Implication: - -**Declarative skills do not need a custom registry format.** -They mainly need correct directory materialization. - -## 3. Bundled skills are synced via a manifest - -Upstream has a sync layer for bundled skills: - -- `tools/skills_sync.py` -- manifest file: `~/.hermes/skills/.bundled_manifest` - -Behavior summary: - -- bundled skills are synced into `~/.hermes/skills/` -- manifest tracks origin hashes -- user modifications/deletions are handled intentionally -- removed bundled skills are cleaned from manifest - -Implication: - -**Bundled skills are not just copied blindly.** -If we want to align with upstream behavior, we should respect that bundled skills have their own lifecycle semantics. - -## 4. Optional skills are shipped in repo, but not bundled into runtime by default - -Upstream has `optional-skills/` and exposes them through hub-style install/search flows. - -Relevant upstream behavior: - -- `tools/skills_hub.py: OptionalSkillsSource` -- source path: repo `optional-skills/` -- install identifiers look like `official/category/skill` or `official/skill` - -Implication: - -Optional skills are a clean fit for declarative install because they are effectively pinned to the package revision already. - -## 5. Skill enable/disable is config-driven - -Skill disabling lives in Hermes config, not the filesystem. - -Relevant upstream behavior: - -- `hermes_cli/skills_config.py` -- `tools/skills_tool.py:_get_disabled_skill_names()` -- config keys: - -```yaml -skills: - disabled: [] - platform_disabled: {} -``` - -Implication: - -**Installation and enablement are separate concerns.** -The Nix module should model both. - -## 6. Hub/external skills carry provenance state - -Hub state lives under: - -- `~/.hermes/skills/.hub/lock.json` -- `~/.hermes/skills/.hub/taps.json` -- plus quarantine/audit state - -Relevant upstream behavior: - -- `tools/skills_hub.py:HubLockFile` -- `tools/skills_hub.py:TapsManager` -- install flow records source/trust/provenance/install_path/hash - -Implication: - -External skill support has real statefulness and should be treated as a separate phase. - ---- - -## Product principles - -## 1. Reproducibility first - -The primary value of `nix-hermes-agent` is that Hermes deployment stops being snowflake state. -Skills must follow the same principle. - -## 2. Align with upstream instead of papering over it - -Where upstream already has a lifecycle concept: - -- bundled manifest, -- optional skill source, -- hub lock file, -- disabled config, - -we should design with those semantics in mind. - -## 3. Prefer pinned/local skill materialization over runtime network installs - -For Nix-managed deployments, local/pinned skills are cleaner than runtime remote fetches. - -## 4. Separate “present on disk” from “enabled for runtime” - -This mirrors upstream and keeps the module more expressive. - -## 5. Make the first version boring and reliable - -Copy/sync into runtime state first. -Do not over-optimize for immutable symlink purity if it breaks upstream assumptions. - ---- - -## Proposed module design - -## Phase 1: Declarative local skill materialization - -### New option group: `services.hermes-agent.skills` - -Suggested top-level shape: - -```nix -services.hermes-agent.skills = { - bundled.enable = true; - - optional = [ - "research/foo" - "creative/bar" - ]; - - custom = { - my-playbook = { - category = "research"; - source = ./skills/my-playbook; - }; - }; - - disabled = [ "foo" ]; - - platformDisabled = { - telegram = [ "shell-heavy-skill" ]; - cli = [ ]; - }; -}; -``` - -### Sub-feature A: bundled skills - -#### Goal -Allow `nix-hermes-agent` to ensure upstream bundled skills are present declaratively. - -#### Why -This makes package upgrades and fresh machines deterministic. - -#### Design options - -##### Option A — let upstream sync bundled skills at runtime -Pros: -- closest to upstream lifecycle -- manifest semantics stay upstream-owned - -Cons: -- requires that sync path is guaranteed to run in the packaged flow -- harder to reason about from Nix module alone - -##### Option B — module-managed bundled sync/materialization -Pros: -- explicit and controllable from module -- can be tested from Nix side - -Cons: -- risks partially duplicating upstream logic unless done carefully - -#### Recommendation -Use **module-managed materialization** initially, but preserve upstream-compatible structure and manifest awareness. -If we later find an upstream-supported sync entrypoint that is stable, we can switch implementation without changing user-facing options. - -### Sub-feature B: optional skills - -#### Goal -Install upstream `optional-skills/` declaratively by package revision. - -#### Why -These are the cleanest next-step skills because they are already version-pinned by the packaged source tree. - -#### Recommendation -Expose a list of relative skill paths from upstream optional-skills. -Example: - -```nix -services.hermes-agent.skills.optional = [ - "research/deep-research" - "creative/story-ideation" -]; -``` - -The module activation step should materialize these into `${HERMES_HOME}/skills/...` preserving category layout. - -### Sub-feature C: custom local skills - -#### Goal -Allow users to declaratively ship their own skills. - -#### Recommendation -Support both: - -- `source = ./path/to/skill-dir` -- later maybe `text = '' ... SKILL.md ... ''` for convenience - -Suggested shape: - -```nix -services.hermes-agent.skills.custom = { - repo-watch = { - category = "research"; - source = ./skills/repo-watch; - }; -}; -``` - -This should build a derivation containing normalized skill trees, then copy/sync them into runtime state. - ---- - -## Phase 2: Declarative enable/disable controls - -### Goal -Manage the upstream config shape declaratively. - -Suggested mapping: - -```nix -services.hermes-agent.skills.disabled = [ "foo" ]; -services.hermes-agent.skills.platformDisabled.telegram = [ "bar" ]; -``` - -Module implementation should merge this into generated Hermes config: - -```nix -config.skills = { - disabled = ...; - platform_disabled = ...; -}; -``` - -### Why separate from installation? -Because upstream separates them, and it enables useful patterns: - -- skill present but disabled on messaging platforms -- skill enabled only in CLI/ACP contexts -- same deployment with different platform affordances - -This matters a lot if Hermes is used across: - -- gateway, -- SSH CLI/TUI, -- ACP/IDE connection. - ---- - -## Phase 3: Declarative external / hub skills - -This should be treated as a separate feature set, not bundled into v1. - -### Problem -Hub skills are not just files. -They have upstream lifecycle semantics: - -- provenance -- trust level -- lock entries -- taps -- audit log -- quarantine/security scanning - -### Two viable models - -#### Model A — runtime installation via Hermes CLI -Declare desired hub skills in Nix, then reconcile at activation/runtime using Hermes itself: - -```nix -services.hermes-agent.skills.hub = [ - { - identifier = "owner/repo/path/to/skill"; - source = "github"; - } -]; -``` - -Pros: -- preserves upstream lock/audit/scan behavior - -Cons: -- network-dependent -- less reproducible -- slower/more stateful - -#### Model B — pin external skill sources in Nix and treat them as local skills -Use `fetchFromGitHub` or flake inputs, then place them under `custom`/local managed skills. - -Pros: -- reproducible -- reviewable -- pinned by hash -- easier to reason about in infra repos - -Cons: -- bypasses hub provenance model unless explicitly emulated - -### Recommendation -For `nix-hermes-agent`, **prefer Model B philosophically**. -Support Model A later as an escape hatch for people who want native Hermes Hub behavior. - ---- - -## Phase 4: Profiles - -Once the substrate exists, we can define higher-level role profiles. -Examples: - -- `researcher` -- `creative-lab` -- `ops-lite` -- `coding-explorer` - -These profiles would be syntactic sugar over: - -- installed skills -- disabled skills -- model/tool config -- maybe documents - -This should come after raw primitives are solid. - ---- - -## Proposed option schema - -This is a proposed user-facing module API, not final code. - -```nix -services.hermes-agent.skills = { - enable = lib.mkEnableOption "declarative Hermes skills"; - - bundled = { - enable = lib.mkOption { - type = lib.types.bool; - default = true; - }; - }; - - optional = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - example = [ "research/deep-research" ]; - }; - - custom = lib.mkOption { - type = lib.types.attrsOf (lib.types.submodule ({ ... }: { - options = { - category = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - }; - source = lib.mkOption { - type = lib.types.path; - }; - }; - })); - default = { }; - }; - - disabled = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - }; - - platformDisabled = lib.mkOption { - type = lib.types.attrsOf (lib.types.listOf lib.types.str); - default = { }; - }; - - hub = lib.mkOption { - type = lib.types.listOf (lib.types.submodule ({ ... }: { - options = { - identifier = lib.mkOption { type = lib.types.str; }; - source = lib.mkOption { - type = lib.types.enum [ "github" "official" "well-known" "skills-sh" ]; - default = "github"; - }; - }; - })); - default = [ ]; - }; -}; -``` - ---- - -## Implementation plan - -## Milestone 1 — groundwork and packaging introspection - -### Deliverables -- confirm packaged source paths for bundled skills and optional-skills -- add tests or at least build-time assertions that those paths exist in the package output -- document path assumptions - -### Notes -Current package already fetches upstream source with submodules and builds Hermes as a Python app. -We need a stable way to refer to upstream skills trees from the installed package or derivation source. - -### Risks -- packaged output may not expose source trees exactly where activation expects them -- upstream package layout may shift across releases - -### Mitigation -Add explicit path probes in checks/docs and keep implementation centralized. - ---- - -## Milestone 2 — declarative bundled + optional + custom skill materialization - -### Deliverables -- module options for bundled/optional/custom skills -- activation step that creates `${HERMES_HOME}/skills` -- copy/sync logic preserving category layout -- initial tests covering presence of selected skills in state dir - -### Implementation guidance -Use a generated derivation to normalize all Nix-managed skill content into one tree, then reconcile that tree into runtime state. - -Example conceptual pipeline: - -1. build a Nix store tree containing: - - selected bundled skills - - selected optional skills - - custom local skills -2. activation script syncs that tree into `${cfg.stateDir}/.hermes/skills` -3. preserve non-Nix-managed hub state unless explicitly managing it - -### Important constraint -Do **not** clobber `.hub/` blindly. -Do **not** wipe user-managed runtime state unless explicitly requested. - ---- - -## Milestone 3 — declarative disabled/platformDisabled wiring - -### Deliverables -- module options -- merge into generated Hermes config -- tests that rendered config contains expected shape - -### Why this milestone is low risk -This is a direct mapping to upstream config semantics and does not require custom lifecycle logic. - ---- - -## Milestone 4 — docs and examples - -### Deliverables -- README examples for: - - basic bundled skill deployment - - optional skills - - custom local skills - - platform-specific disabling -- migration guidance for users already managing skills interactively - ---- - -## Milestone 5 — optional hub skill support - -### Deliverables -- design decision: native runtime install vs pinned-local strategy -- if implemented, add explicit caveats about reproducibility and network dependency -- preserve upstream lock/audit semantics - -### Recommendation -Do not block the core feature on this milestone. - ---- - -## State reconciliation strategy - -This is the most important implementation choice. - -## Recommended approach: managed subtree reconciliation - -Use a managed subset of `${HERMES_HOME}/skills` while leaving upstream-owned dynamic state alone. - -### Desired behavior -Nix should manage: - -- bundled skills selected by module policy -- optional skills selected by module policy -- custom local skills selected by module policy - -Nix should avoid trampling: - -- `.hub/` -- maybe `.bundled_manifest` unless we intentionally integrate with it -- runtime-installed non-managed skills unless user opts into strict mode - -### Possible implementation pattern -Maintain a managed marker file or managed manifest under Hermes home, e.g.: - -- `${HERMES_HOME}/skills/.nix-managed-manifest.json` - -Track which installed paths belong to Nix-managed state. -On activation: - -- create/update managed paths -- remove managed paths no longer desired -- leave unmanaged paths untouched - -This avoids destructive full-directory replacement and plays better with upstream hub installs. - ---- - -## Testing strategy - -## 1. Evaluation tests -Ensure module options evaluate and merge correctly. - -## 2. Render tests -Validate generated config contains expected skill disable keys. - -## 3. Activation tests -On a NixOS test VM or shell-based checks: - -- deploy with custom skill -- assert `SKILL.md` lands under expected path -- deploy with optional skill -- assert category structure preserved -- rebuild with skill removed -- assert only managed skill removed -- assert `.hub/` untouched - -## 4. Regression tests -Check package path assumptions for upstream bundled and optional-skills trees. - ---- - -## Open questions - -## 1. Should bundled skills be managed by module or left to upstream sync? -Current recommendation: module-managed initially, but keep implementation swappable. - -## 2. Should Nix-managed skills be copied or symlinked into runtime? -Current recommendation: **copy/sync first**. -This is more boring but more compatible with any upstream expectations around writable trees and file operations. - -## 3. Should hub skills be in scope for the first PR series? -Current recommendation: **no**. -Document the plan, but ship local/offline-managed skills first. - -## 4. Should we support inline `SKILL.md` text for custom skills? -Probably yes later, but path-based custom skills are enough for v1. - ---- - -## Recommended PR breakdown - -## PR 1 — groundwork -- package path assertions -- module option scaffolding -- docs stub - -## PR 2 — declarative local skills -- bundled/optional/custom skill options -- managed reconciliation logic -- tests - -## PR 3 — declarative disable controls -- `disabled` -- `platformDisabled` -- config wiring -- tests - -## PR 4 — docs/examples -- README updates -- migration examples -- profile examples - -## PR 5 — optional hub support (if still desired) -- separate decision doc -- explicit caveats - ---- - -## Recommendation - -The repo should focus on **declarative skills maturity now**, not soul tuning. - -The best next step is to implement a boring, reliable, upstream-aware layer for: - -- bundled skills, -- optional skills, -- custom local skills, -- disabled/platform-disabled config. - -That will make `nix-hermes-agent` meaningfully more mature and unlock better Hermes roles across: - -- gateway usage, -- SSH CLI/TUI usage, -- ACP/IDE connections, -- dedicated research/creative sandbox VMs. - ---- - -## Appendix: Upstream files worth watching - -For future maintainers, these upstream files are the primary integration seams: - -- `tools/skills_tool.py` - - `SKILLS_DIR` - - `_find_all_skills()` - - `_get_disabled_skill_names()` - - category/path discovery - -- `tools/skills_sync.py` - - bundled skill sync behavior - - `.bundled_manifest` - -- `tools/skills_hub.py` - - `OptionalSkillsSource` - - `HubLockFile` - - `TapsManager` - - install/uninstall semantics - -- `hermes_cli/skills_config.py` - - user-facing config model for disabled/platform-disabled skills - -- `hermes_cli/config.py` - - `HERMES_HOME` - - merged config semantics - -These are the places likely to matter most when upstream changes skill behavior. diff --git a/module.nix b/module.nix index df9e682..4f7c24e 100644 --- a/module.nix +++ b/module.nix @@ -36,7 +36,7 @@ let customSkillSpecs = lib.mapAttrsToList (name: value: { inherit name; - category = value.category; + inherit (value) category; source = toString value.source; }) cfg.skills.custom; diff --git a/tests/skills-coexistence.nix b/tests/skills-coexistence.nix index e7c9e1c..97a4427 100644 --- a/tests/skills-coexistence.nix +++ b/tests/skills-coexistence.nix @@ -1,4 +1,8 @@ -{ self, nixpkgs, system }: +{ + self, + nixpkgs, + system, +}: let pkgs = import nixpkgs { inherit system; }; @@ -10,36 +14,38 @@ in pkgs.testers.runNixOSTest { name = "hermes-skills-coexistence"; - nodes.machine = { ... }: { - imports = [ self.nixosModules.hermes-agent ]; + nodes.machine = + { ... }: + { + imports = [ self.nixosModules.hermes-agent ]; - services.hermes-agent = { - enable = true; - package = self.packages.${system}.hermes-agent; - skills = { - bundled.enable = false; - custom.repo-watch = { - category = "research"; - source = testSkill; + services.hermes-agent = { + enable = true; + package = self.packages.${system}.hermes-agent; + skills = { + bundled.enable = false; + custom.repo-watch = { + category = "research"; + source = testSkill; + }; }; - }; - documents = { - "SOUL.md" = "# SOUL.md\nTest soul\n"; - "AGENTS.md" = "# AGENTS.md\nTest agents\n"; - "USER.md" = "# USER.md\nTest user\n"; - }; - config = { - toolsets = [ "all" ]; - model = { - default = "moonshotai/kimi-k2.5"; - provider = "openrouter"; + documents = { + "SOUL.md" = "# SOUL.md\nTest soul\n"; + "AGENTS.md" = "# AGENTS.md\nTest agents\n"; + "USER.md" = "# USER.md\nTest user\n"; + }; + config = { + toolsets = [ "all" ]; + model = { + default = "moonshotai/kimi-k2.5"; + provider = "openrouter"; + }; }; }; + + system.stateVersion = "25.05"; }; - system.stateVersion = "25.05"; - }; - testScript = '' machine.wait_for_unit("multi-user.target") From 0512a81eb92d3701b686d18000c28b91e27548d3 Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Wed, 18 Mar 2026 02:57:13 +0000 Subject: [PATCH 6/6] fix: disable sanic test suite to unbreak CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sanic 25.12.0 in nixpkgs has a flaky test_keep_alive_client_timeout that fails in sandbox builds. Override the python package set to skip sanic's tests — they're upstream's problem, not ours. Cascade was: sanic → sanic-testing → slack-bolt → hermes-agent → all checks. --- package.nix | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/package.nix b/package.nix index 79be02d..ca889b7 100644 --- a/package.nix +++ b/package.nix @@ -12,8 +12,16 @@ }: let - python = python312; - pythonPackages = python312Packages; + # Override python package set to fix broken upstream tests + python = python312.override { + packageOverrides = _final: prev: { + sanic = prev.sanic.overridePythonAttrs (_old: { + # sanic 25.12.0 has a flaky test_keep_alive_client_timeout in nixpkgs sandbox + doCheck = false; + }); + }; + }; + pythonPackages = python.pkgs; # --- Missing PyPI packages ---