Files
alirezarezvani--claude-skills/scripts/sync-hermes-skills.py
T
wehub-resource-sync a1fa97429b
Release / Tag + GitHub Release (push) Waiting to run
Deploy Documentation to Pages / build (push) Waiting to run
Deploy Documentation to Pages / deploy (push) Blocked by required conditions
Sync Codex Skills Symlinks / sync (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:41:47 +08:00

280 lines
10 KiB
Python

#!/usr/bin/env python3
"""
sync-hermes-skills.py — Install claude-code-skills into Hermes Agent.
Hermes Agent (https://github.com/NousResearch/hermes-agent) discovers skills
from ~/.hermes/skills/. This script creates symlinks from our repo's skill
directories into Hermes's skill directory, preserving the category structure.
Both tools use the agentskills.io standard (SKILL.md with YAML frontmatter),
so no format conversion is needed — just symlink the directories.
Usage:
python scripts/sync-hermes-skills.py # full sync
python scripts/sync-hermes-skills.py --verbose # show each skill
python scripts/sync-hermes-skills.py --domain engineering # one domain
python scripts/sync-hermes-skills.py --dry-run # preview only
python scripts/sync-hermes-skills.py --copy # copy instead of symlink
Hermes skill directory: ~/.hermes/skills/
Our skills land under: ~/.hermes/skills/claude-skills/<domain>/<skill-name>/
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
HERMES_SKILLS_DIR = Path.home() / ".hermes" / "skills"
TARGET_SUBDIR = "claude-skills" # namespace to avoid collisions with Hermes built-in skills
# Domain directories that contain skills (each subdirectory with a SKILL.md)
DOMAIN_DIRS = [
"engineering",
"engineering-team",
"product-team",
"marketing-skill",
"c-level-advisor",
"project-management",
"ra-qm-team",
"business-growth",
"finance",
"productivity", # v2.7.0 — capture, email-pair, reflect
"marketing", # v2.7.0 — landing (top-level, distinct from marketing-skill/)
"research", # v2.7.0 — pulse, litreview, grants, dossier, patent, syllabus, notebooklm, research orchestrator
"business-operations", # v2.8.0 — process-mapper, vendor-management, capacity-planner, internal-comms, knowledge-ops, procurement-optimizer + orchestrator
"commercial", # v2.8.0 — pricing-strategist, deal-desk, partnerships-architect, channel-economics, commercial-policy, rfp-responder, commercial-forecaster + orchestrator
"research-ops", # v2.9.0 — clinical-research, research-finance, market-research, product-research + orchestrator
"compliance-os", # ISO 13485/27001, SOC 2, GDPR, FDA QSR, EU AI Act audit-prep + orchestrator
"markdown-html", # v2.10.x — orchestrator, design-system, md-document, md-review, md-slides
]
def discover_skills(repo_root, domains=None):
"""Find all skills across specified domains.
Supports three discovery patterns (same as sync-codex-skills.py):
1. <domain>/<skill>/SKILL.md — flat-domain pattern (legacy)
2. <domain>/skills/<skill>/SKILL.md — flat-with-skills-dir pattern (e.g., c-level-advisor/skills/)
3. <domain>/<plugin>/skills/<skill>/SKILL.md — nested plugin pattern (e.g., research/research/skills/research/)
Dedupes by SKILL.md path so a skill discovered under multiple patterns is only counted once.
"""
skills = []
seen_paths: set = set()
search_domains = domains or DOMAIN_DIRS
for domain in search_domains:
domain_path = repo_root / domain
if not domain_path.is_dir():
continue
# Pattern 2: <domain>/skills/<skill>/SKILL.md
skills_subdir = domain_path / "skills"
if skills_subdir.is_dir():
for skill_dir in sorted(skills_subdir.iterdir()):
if not skill_dir.is_dir():
continue
skill_md = skill_dir / "SKILL.md"
if skill_md.exists() and str(skill_md) not in seen_paths:
seen_paths.add(str(skill_md))
skills.append({
"domain": domain,
"name": skill_dir.name,
"source": skill_dir,
"skill_md": skill_md,
})
# Pattern 1: <domain>/<skill>/SKILL.md (flat)
# Pattern 3: <domain>/<plugin>/skills/<skill>/SKILL.md (nested plugin)
for entry in sorted(domain_path.iterdir()):
if not entry.is_dir() or entry.name in {"skills", ".claude-plugin", ".codex-plugin"}:
continue
# Pattern 1
skill_md = entry / "SKILL.md"
if skill_md.exists() and str(skill_md) not in seen_paths:
seen_paths.add(str(skill_md))
skills.append({
"domain": domain,
"name": entry.name,
"source": entry,
"skill_md": skill_md,
})
continue
# Pattern 3: nested plugin with skills/ subdir
nested_skills = entry / "skills"
if not nested_skills.is_dir():
continue
for inner in sorted(nested_skills.iterdir()):
if not inner.is_dir():
continue
inner_skill_md = inner / "SKILL.md"
if inner_skill_md.exists() and str(inner_skill_md) not in seen_paths:
seen_paths.add(str(inner_skill_md))
skills.append({
"domain": domain,
"name": inner.name,
"source": inner,
"skill_md": inner_skill_md,
})
return skills
def read_frontmatter(skill_md):
"""Extract name and description from SKILL.md frontmatter."""
try:
text = skill_md.read_text(encoding="utf-8", errors="replace")
if not text.startswith("---"):
return {}
end = text.find("---", 3)
if end < 0:
return {}
fm = {}
for line in text[3:end].splitlines():
if ":" in line and not line.strip().startswith("#"):
k, _, v = line.partition(":")
fm[k.strip()] = v.strip().strip("'\"")
return fm
except Exception:
return {}
def sync_skill(skill, target_root, use_copy, verbose, dry_run):
"""Create a symlink or copy for one skill."""
target = target_root / skill["domain"] / skill["name"]
if target.exists() or target.is_symlink():
if verbose:
print(f" skip (exists): {skill['domain']}/{skill['name']}")
return "skip"
if dry_run:
if verbose:
print(f" would {'copy' if use_copy else 'link'}: {skill['domain']}/{skill['name']}")
return "would"
target.parent.mkdir(parents=True, exist_ok=True)
if use_copy:
shutil.copytree(skill["source"], target, dirs_exist_ok=True)
else:
# Prefer relative symlinks so the tree is portable when committed to the repo.
# Falls back to absolute if target is outside the source tree (e.g., ~/.hermes/).
try:
rel = os.path.relpath(skill["source"], target.parent)
target.symlink_to(rel)
except ValueError:
# Cross-device or unrelated tree — use absolute
target.symlink_to(skill["source"])
if verbose:
print(f" {'copied' if use_copy else 'linked'}: {skill['domain']}/{skill['name']}")
return "new"
def write_index(target_root, skills):
"""Write a skills-index.json for quick lookup."""
index = {
"source": "claude-code-skills",
"total_skills": len(skills),
"domains": {},
}
for s in skills:
d = s["domain"]
if d not in index["domains"]:
index["domains"][d] = []
fm = read_frontmatter(s["skill_md"])
index["domains"][d].append({
"name": s["name"],
"description": fm.get("description", ""),
"path": f"{d}/{s['name']}",
})
index_path = target_root / "skills-index.json"
index_path.write_text(json.dumps(index, indent=2), encoding="utf-8")
return index_path
def main():
p = argparse.ArgumentParser(
description="Sync claude-code-skills into Hermes Agent (~/.hermes/skills/).",
epilog="Both tools use the agentskills.io SKILL.md standard. No format conversion needed.",
)
p.add_argument(
"--domain",
default=None,
help="Sync only one domain (e.g. engineering, marketing-skill)",
)
p.add_argument("--verbose", action="store_true", help="Show each skill")
p.add_argument("--dry-run", action="store_true", help="Preview only, don't create files")
p.add_argument("--copy", action="store_true", help="Copy files instead of symlink")
p.add_argument("--json", action="store_true", help="JSON output")
p.add_argument(
"--target",
default=str(HERMES_SKILLS_DIR),
help=f"Override Hermes skills dir (default: {HERMES_SKILLS_DIR})",
)
args = p.parse_args()
target_root = Path(args.target).expanduser() / TARGET_SUBDIR
domains = [args.domain] if args.domain else None
skills = discover_skills(REPO_ROOT, domains)
if not skills:
msg = f"No skills found in {REPO_ROOT}"
if args.json:
print(json.dumps({"status": "error", "message": msg}))
else:
print(f"[error] {msg}", file=sys.stderr)
sys.exit(1)
if not args.dry_run:
target_root.mkdir(parents=True, exist_ok=True)
counts = {"new": 0, "skip": 0, "would": 0}
for s in skills:
result = sync_skill(s, target_root, args.copy, args.verbose, args.dry_run)
counts[result] += 1
# Write index
if not args.dry_run:
idx_path = write_index(target_root, skills)
else:
idx_path = target_root / "skills-index.json"
summary = {
"status": "ok",
"target": str(target_root),
"total_skills": len(skills),
"new": counts["new"],
"skipped": counts["skip"],
"dry_run": args.dry_run,
"mode": "copy" if args.copy else "symlink",
"index": str(idx_path),
"domains": list({s["domain"] for s in skills}),
}
if args.json:
print(json.dumps(summary, indent=2))
return
action = "Would sync" if args.dry_run else "Synced"
print(f"{action} {len(skills)} skills to {target_root}")
print(f" New: {counts['new']} Skipped: {counts['skip']}")
print(f" Mode: {'copy' if args.copy else 'symlink'}")
if not args.dry_run:
print(f" Index: {idx_path}")
print()
print("Hermes will discover these skills via /skills or /<skill-name>.")
print("No format conversion needed — both tools use agentskills.io SKILL.md standard.")
if __name__ == "__main__":
main()