My Hermes Agent instance has accumulated a collection of skills over time — markdown documents that encode how to operate my homelab: how to talk to my Kubernetes cluster, how to restore a database, how to clean up WordPress comment spam, and so on. The agent creates and edits these skills as it learns, and they live on disk in the profile’s skills/ directory.
That arrangement has two problems:
- No version control. The skills directory is just files on a (container) filesystem. If the pod is recreated, or the agent makes a bad edit, there’s no history and no easy rollback.
- No portability. I run more than one machine that could use these skills. I wanted a way to install the same curated set elsewhere — ideally using Hermes’ native skill tap mechanism rather than some homegrown sync script of my own.
The end state I wanted: a private GitHub repository that (a) is a real git repo with history, and (b) is directly consumable as a skill tap via hermes skills tap add. No server, no registry, no release pipeline — just a directory of SKILL.md files.
What a skill tap is
A tap is any GitHub repo (public or private — private taps need a GITHUB_TOKEN) laid out like this:
<owner>/hermes-skills
├── skills/ # default tap path
│ ├── my-workflow/
│ │ ├── SKILL.md # required
│ │ ├── references/ # optional supporting files
│ │ ├── scripts/
│ │ └── templates/
│ └── another-skill/
│ └── SKILL.md
└── README.md
Each skill lives in its own directory under skills/; the directory name becomes the install slug. Anyone (or any other machine of mine) can then do:
hermes skills tap add <owner>/hermes-skills
hermes skills search deploy
hermes skills install <owner>/hermes-skills/my-workflow
The mismatch: live layout vs. tap layout
Here’s the wrinkle that made this more than a git push. The live skills directory on the agent is organized by category:
skills/
│ ├── devops/
│ │ └── my-workflow/SKILL.md
│ └── homelab/
│ └── db-restore/SKILL.md
└── npr/SKILL.md # some skills sit at the top level
But a tap wants a flat layout — every skill directly under skills/<name>/. So the sync had to be a transforming mirror, not a copy: flatten by basename, with a hard check that basenames are unique across categories (two skills with the same name in different categories would be ambiguous, so the sync aborts rather than guessing).
The sync mechanism
The whole thing is a small Python script, itself documented as a skill (yes, the sync procedure is a skill the agent can load). Each run:
- Mirrors every live skill into the repo, flattened — each skill directory is overwritten wholesale, so stale files inside a skill are dropped (true mirror, not a merge).
- Removes repo skills whose basename no longer exists in the live directory (deleted/archived skills leave the tap).
- Prunes nested skill dirs — any subdirectory of a repo skill that contains its own
SKILL.mdis redundant with the flat copy and invisible to tap discovery, so it gets dropped. - Commits and pushes with a summary message like
skills sync: +2 ~2 -0.
Two operational details I care about:
- Dry-run first.
sync.py --dry-runprints the add/update/remove report without touching anything. The agent is instructed to always preview, review, then run for real. - A daily cron watchdog. A scheduled job runs the sync once a day. It mirrors, commits, and pushes — but stays silent when the repo is already in sync, and only messages me when it actually synced a change or when it failed. That’s the classic watchdog pattern: no noise on the happy path, an alert when something’s wrong.
Because the agent itself creates and edits skills as a side effect of doing work, the cron means the repo drifts at most a day behind reality without me doing anything. And because it’s git, I get the history, diffs, and rollback that the bare directory never had.
Pitfalls worth knowing
- Basename collisions abort the sync. If
devops/fooandcreative/fooboth exist, the flatten is ambiguous — rename one first. - Archived skills are hidden directories (e.g.
.archive/) in the live dir; the sync skips them, which is exactly what you want — archiving a skill removes it from the tap. - Git identity must be set in the repo or the commit step fails with a confusing error. Set
user.name/user.emailonce and the script verifies it. - Push auth is separate from commit. If the push fails (token expired, etc.), the commit still lands locally — fix auth and push manually. The next sync picks up from there.
- Private taps need a token on the consuming side:
GITHUB_TOKENin the environment of whichever Hermes instance is installing from the tap.
The files
Three files make up the whole mechanism. The skill documents the procedure
(the agent loads it on demand); sync.py does the work;
cron_sync.py is the silent watchdog wrapper. Paths are
environment-driven (SKILLS_REPO_SYNC_LIVE /
SKILLS_REPO_SYNC_REPO) so the same scripts work on any machine.
SKILL.md — the skill that documents the procedure:
---
name: skills-repo-sync
description: Sync live skills dir to the flat private tap repo and push.
version: 1.0.0
author: Example
license: MIT
platforms: [linux, macos]
metadata:
hermes:
tags: [skills, git, tap, sync, repo]
category: devops
---
# Skills Repo Sync
Mirror the live Hermes skills directory into a flat tap repo, then commit and
push. Use after creating/editing skills, after a curator run, or on a schedule
to keep your skills repo current.
## When to Use
- You (or the agent / a curator pass) created or modified skills and want them in the repo.
- A curator pass archived skills and you want the repo to reflect that.
- Periodic sync (cron) to keep the tap repo in lockstep with the live dir.
## Layout mapping (important)
- **Live dir** (`$SKILLS_REPO_SYNC_LIVE`): category-nested — skills live at
`<live>/<category>/<name>/SKILL.md` (or top-level `<live>/<name>/SKILL.md`).
- **Repo** (`$SKILLS_REPO_SYNC_REPO`): **flat** — every skill at
`<repo>/skills/<name>/SKILL.md`.
- Mapping is **flatten-by-basename**: each live `SKILL.md` →
`repo/skills/<basename>/`. Basenames must be unique across the live dir
(the script verifies this and aborts on collision).
## Procedure
1. Run the sync script (dry-run first to preview):
```bash
python3 <skill_dir>/scripts/sync.py --dry-run
```
2. Review the report (to add / to update / to remove / nested dupes to prune).
If it looks right, run for real:
```bash
python3 <skill_dir>/scripts/sync.py # mirror + commit + push
python3 <skill_dir>/scripts/sync.py --no-push # mirror + commit, no push
```
3. Verify: `git -C <repo> log --oneline -1` and `git -C <repo> status`
(should be clean).
Resolve `<skill_dir>` as the directory containing this SKILL.md, e.g.
`find <live> -name sync.py -path '*skills-repo-sync*'`.
## What the script does
- Mirrors every live skill (flattened) into the repo, overwriting each skill
dir wholesale (true mirror — stale files are dropped).
- Removes repo skills whose basename is no longer in the live dir.
- Prunes **nested skill dirs** (any subdir of a repo skill that contains its
own `SKILL.md` and has a flat top-level twin) — redundant with the flat
copies and invisible to `hermes skills tap` discovery. Nested-only skills
(no flat twin) are kept and surfaced for a human decision.
- Commits with a summary message and pushes (unless `--no-push` / `--dry-run`).
## Scheduled sync (cron)
A daily cron job (`no_agent` watchdog) runs `scripts/cron_sync.py`. It mirrors
+ commits + pushes, and stays **silent when the repo is already in sync** —
it only reports when it synced a change or when it failed. Delivery target:
all connected channels. Manage with the `cronjob` tool
(list / pause / resume / remove / run).
## Pitfalls
- **Basename collision:** if two live skills share a basename
(e.g. `devops/foo` and `creative/foo`), flatten-by-basename is ambiguous.
The script aborts with an error — rename one before syncing.
- **Nested skill dirs are dropped on purpose** when a flat twin exists. If a
skill package ever legitimately bundles a subdir with its own `SKILL.md`,
the sync removes it from the repo copy. Not a supported pattern today.
- **Push requires git auth** to the repo. If push fails, the commit still
lands locally — fix auth and `git push` manually.
- **Git identity must be set** in the repo
(`git -C <repo> config user.name/user.email`) or the commit fails. The
script checks and exits with a clear message.
- **The repo is the source of truth for the tap.** After syncing, other
devices update via `git pull` (external_dirs) or a `hermes skills tap`
re-fetch.
- **Curator archives** live in `<live>/.archive/` (hidden) and are NOT
mirrored — archived skills are removed from the repo, which is the intended
behavior.
## Verification
- `git -C <repo> status` → clean.
- `git -C <repo> log --oneline -1` → shows the sync commit with +/~/- counts.
- `ls <repo>/skills/<new-skill>/SKILL.md` → exists for any skill you just added.
sync.py — the transforming mirror + git commit/push:
#!/usr/bin/env python3
"""Mirror the live Hermes skills dir (category-nested) into the flat private tap repo,
then commit and push.
Mapping: flatten-by-basename. Every live SKILL.md at <live>/<...>/<name>/SKILL.md
becomes <repo>/skills/<name>/SKILL.md. Basenames must be unique (verified).
Modes:
--dry-run report what would change, touch nothing
--no-push mirror + commit, skip git push
(default) mirror + commit + push
"""
import argparse
import os
import shutil
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
LIVE = os.path.expanduser(os.environ.get("SKILLS_REPO_SYNC_LIVE", "~/skills"))
REPO = os.path.expanduser(os.environ.get("SKILLS_REPO_SYNC_REPO", "~/hermes-skills"))
REPO_SKILLS = os.path.join(REPO, "skills")
# Hidden dirs to skip when walking the live tree (curator archive, backups, etc.)
SKIP_DIRS = {".archive", ".curator_backups", ".git", "__pycache__"}
def find_live_skills(live: str):
"""Return {basename: Path(skill_dir)} for every SKILL.md under live."""
skills = {}
collisions = defaultdict(list)
for root, dirs, files in os.walk(live):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
if "SKILL.md" in files:
name = os.path.basename(root)
collisions[name].append(Path(root))
for name, paths in collisions.items():
if len(paths) > 1:
print(f"ERROR: basename collision for '{name}':")
for p in paths:
print(f" {p}")
print("Rename one before syncing (flatten-by-basename is ambiguous).")
sys.exit(2)
for name, paths in collisions.items():
skills[name] = paths[0]
return skills
def ignore_nested_skills(dirpath, names):
"""For copytree: drop hidden/skip dirs AND any subdir that is itself a skill
(contains SKILL.md). Nested skills are mirrored separately as flat skills, so
a category/umbrella skill must not drag its children into its own copy."""
ignore_dirs = []
for d in names:
if d in SKIP_DIRS or d.startswith("."):
ignore_dirs.append(d)
elif (Path(dirpath) / d / "SKILL.md").is_file():
ignore_dirs.append(d)
return ignore_dirs, []
def live_skill_changed(live_dir: Path, repo_dir: Path) -> bool:
"""True if the live skill dir differs from the repo copy (content-wise)."""
if not repo_dir.exists():
return True
for root, dirs, files in os.walk(live_dir):
dirs[:] = [d for d in dirs
if d not in SKIP_DIRS and not d.startswith(".")
and not (Path(root) / d / "SKILL.md").is_file()]
for f in files:
if f.startswith("."):
continue
src = Path(root) / f
rel = src.relative_to(live_dir)
dst = repo_dir / rel
if not dst.exists():
return True
if src.stat().st_size != dst.stat().st_size:
return True
if src.read_bytes() != dst.read_bytes():
return True
# Detect files present in repo copy but not in live (stale) — only within skill
# files. Skip nested skill subdirs: nested-only skills are intentionally kept
# (no flat twin) and must not flag the umbrella as changed.
for root, dirs, files in os.walk(repo_dir):
dirs[:] = [d for d in dirs
if d not in SKIP_DIRS and not d.startswith(".")
and not (Path(root) / d / "SKILL.md").is_file()]
for f in files:
if f.startswith("."):
continue
dst = Path(root) / f
rel = dst.relative_to(repo_dir)
if not (live_dir / rel).exists():
return True
return False
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--no-push", action="store_true")
args = ap.parse_args()
live = Path(LIVE)
repo = Path(REPO)
repo_skills = Path(REPO_SKILLS)
if not live.is_dir():
sys.exit(f"ERROR: live dir not found: {live}")
if not repo_skills.is_dir():
sys.exit(f"ERROR: repo skills dir not found: {repo_skills}")
live_skills = find_live_skills(str(live))
repo_skill_names = {
d.name for d in repo_skills.iterdir()
if d.is_dir() and (d / "SKILL.md").is_file() and not d.name.startswith(".")
}
to_add = sorted(n for n in live_skills if n not in repo_skill_names)
to_remove = sorted(n for n in repo_skill_names if n not in live_skills)
to_update = sorted(
n for n in live_skills
if n in repo_skill_names and live_skill_changed(live_skills[n], repo_skills / n)
)
# Nested skill dirs inside repo skill dirs. Only prune when a FLAT top-level
# twin exists (true dedup). Nested-only skills (no flat twin) are the sole
# copy — leave them and surface them for a human decision.
nested_dupes = []
nested_only = []
for d in sorted(repo_skills.iterdir()):
if not d.is_dir() or d.name.startswith("."):
continue
for sub in sorted(d.iterdir()):
if sub.is_dir() and (sub / "SKILL.md").is_file():
if (repo_skills / sub.name).is_dir() and (repo_skills / sub.name / "SKILL.md").is_file():
nested_dupes.append(f"{d.name}/{sub.name}")
else:
nested_only.append(f"{d.name}/{sub.name}")
print("=== skills-repo-sync ===")
print(f"live: {live}")
print(f"repo: {repo_skills}")
print()
print(f"ADD ({len(to_add)}):")
for n in to_add:
print(f" + {n}")
print(f"UPDATE ({len(to_update)}):")
for n in to_update:
print(f" ~ {n}")
print(f"REMOVE ({len(to_remove)}):")
for n in to_remove:
print(f" - {n}")
print(f"PRUNE nested dupes ({len(nested_dupes)}):")
for n in nested_dupes:
print(f" - {n}")
if nested_only:
print(f"NESTED-ONLY, kept (no flat twin — NOT pruned) ({len(nested_only)}):")
for n in nested_only:
print(f" = {n}")
if args.dry_run:
print("\n[dry-run] no changes made.")
return
changed = bool(to_add or to_update or to_remove or nested_dupes)
if not changed:
print("\nNo changes — repo already in sync.")
return
# Apply
def has_nested_only(d: Path) -> bool:
"""True if d contains a subdir that is a skill with no flat twin (sole copy)."""
for sub in d.iterdir():
if sub.is_dir() and (sub / "SKILL.md").is_file():
if not ((repo_skills / sub.name).is_dir()
and (repo_skills / sub.name / "SKILL.md").is_file()):
return True
return False
for n in to_add:
shutil.copytree(live_skills[n], repo_skills / n, ignore=ignore_nested_skills)
for n in to_update:
target = repo_skills / n
# Never rmtree a dir that holds nested-only skills (sole copies).
if has_nested_only(target):
# Copy own files over, preserving nested-only children.
for root, dirs, files in os.walk(live_skills[n]):
dirs[:] = [d for d in dirs
if d not in SKIP_DIRS and not d.startswith(".")
and not (Path(root) / d / "SKILL.md").is_file()]
for f in files:
if f.startswith("."):
continue
src = Path(root) / f
dst = target / src.relative_to(live_skills[n])
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
else:
shutil.rmtree(target)
shutil.copytree(live_skills[n], target, ignore=ignore_nested_skills)
for n in to_remove:
target = repo_skills / n
if has_nested_only(target):
print(f" ! SKIPPED remove of '{n}': contains nested-only skill(s). "
f"Handle manually.")
continue
shutil.rmtree(target)
for nd in nested_dupes:
shutil.rmtree(repo_skills / nd)
# Commit
def git(*a):
return subprocess.run(["git", "-C", str(repo), *a],
capture_output=True, text=True)
git("add", "-A")
st = git("status", "--porcelain")
if not st.stdout.strip():
print("\nNo git changes after mirror (unexpected).")
return
# Ensure a git identity exists (commit fails silently otherwise).
if not git("config", "user.email").stdout.strip():
sys.exit("ERROR: git user.email not set. Run:\n"
f" git -C {repo} config user.name 'Your Name'\n"
f" git -C {repo} config user.email '[email protected]'")
msg = (f"skills sync: +{len(to_add)} ~{len(to_update)} -{len(to_remove)}"
f" (prune {len(nested_dupes)} nested)")
cr = git("commit", "-m", msg)
if cr.returncode != 0:
sys.exit(f"ERROR: git commit failed:\n{cr.stderr.strip()}")
print(f"\nCommitted: {msg}")
if not args.no_push:
pr = git("push")
if pr.returncode == 0:
print("Pushed to origin.")
else:
print(f"WARNING: push failed (commit is local). {pr.stderr.strip()}")
print("Fix git auth, then: git -C " + str(repo) + " push")
if __name__ == "__main__":
main()
cron_sync.py — the daily watchdog (silent when in
sync, reports on change or failure):
#!/usr/bin/env python3
"""Cron wrapper for skills-repo-sync (watchdog pattern).
Runs the main sync and reports ONLY when it made a commit or failed.
Designed for a no_agent=True cron job: empty stdout = no message delivered,
non-empty stdout = delivered verbatim.
- in sync -> prints nothing (silent)
- synced changes -> prints the commit subject
- error -> prints a short failure summary
"""
import os
import subprocess
import sys
# sync.py lives next to this file in the skill's scripts/ directory.
SYNC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sync.py")
REPO = os.path.expanduser(os.environ.get("SKILLS_REPO_SYNC_REPO", "~/hermes-skills"))
def git(*a):
return subprocess.run(["git", "-C", REPO, *a], capture_output=True, text=True)
def main():
before = git("rev-parse", "HEAD").stdout.strip()
r = subprocess.run([sys.executable, SYNC], capture_output=True, text=True)
out = (r.stdout + r.stderr).strip()
after = git("rev-parse", "HEAD").stdout.strip()
if r.returncode != 0 or "ERROR" in out:
print(f"skills-repo-sync FAILED (exit {r.returncode}):\n{out[-2000:]}")
return 0 # exit 0 so it's delivered as a normal message, not an error alert
if after != before:
subj = git("log", "-1", "--format=%s").stdout.strip()
print(f"skills-repo-sync: {subj}")
# else: already in sync -> silent
if __name__ == "__main__":
sys.exit(main())
Why bother
The skills are the agent’s procedural memory — the “how do we do things around here” knowledge that takes real debugging sessions to accumulate. Keeping them in git means that knowledge survives pod recreations, is reviewable like any other code, and can be installed on a fresh machine with one tap add. The tap format is the nice part: it’s the ecosystem’s native distribution mechanism, so there’s nothing custom to maintain on the consumer side.
None of this is hard individually — it’s a flatten, a mirror, a commit, a cron. The value is in the boring reliability of having it happen automatically, every day, without me remembering to.
Leave a Reply