Files
hermes-agent/hermes_cli/agent_import_sync.py
ethernet 45c77a40cf fix: read text files with encoding=utf-8-sig in code merged from main
Same rule as 6e294f543d for the reads that arrived with the origin/main merge;
the Windows footguns lane is blocking.
2026-09-19 01:38:09 -04:00

193 lines
8.8 KiB
Python

"""``hermes import-agent --sync`` — keep previously imported Claude Code / Codex setups current.
Every successful ``hermes import-agent`` run records its source in ``HERMES_HOME/import-sync.json``
(the sync manifest); ``--sync`` re-imports every registered source whose files changed since the
last run. Change detection is a content digest over exactly the files the importer reads, so an
unchanged source is a cheap no-op and credential files (never read by the importer) can never
trigger a sync. Sync runs prompt-free — it only re-applies sources the user already imported once.
"""
from __future__ import annotations
import hashlib
import json
import logging
import time
from pathlib import Path
from typing import Any, Dict, Iterator, Optional
from utils import atomic_write_text
logger = logging.getLogger(__name__)
SYNC_MANIFEST_NAME = "import-sync.json"
# Credential stores that live inside the source trees; never part of the digest.
_CREDENTIAL_FILENAMES = frozenset({".credentials.json", "auth.json", "credentials.json"})
def sync_manifest_path(target_root: Path) -> Path:
return Path(target_root) / SYNC_MANIFEST_NAME
def _iter_sync_files(agent: str, source_root: Path) -> Iterator[Path]:
"""Yield the source files whose content determines the sync digest — exactly what
:class:`~hermes_cli.agent_import.AgentImporter` reads for ``agent``."""
if agent == "claude-code":
# ~/.claude.json lives NEXT TO ~/.claude/ (see AgentImporter._run_claude_code)
paths = [source_root / "CLAUDE.md", source_root / "settings.json",
source_root.parent / ".claude.json"]
else:
paths = [source_root / "AGENTS.md", source_root / "config.toml"]
paths.extend(sorted((source_root / "memories").glob("*.md")))
paths.extend(p for p in sorted((source_root / "skills").rglob("*")) if p.is_file())
for path in paths:
if path.name not in _CREDENTIAL_FILENAMES and path.is_file():
yield path
def compute_source_digest(agent: str, source_root: Path) -> str:
"""Content digest of everything the importer would read from ``source_root``."""
source_root = Path(source_root)
digest = hashlib.sha256()
for path in _iter_sync_files(agent, source_root):
try:
rel = str(path.relative_to(source_root))
except ValueError:
rel = f"../{path.name}"
try:
content_hash = hashlib.sha256(path.read_bytes()).hexdigest()
except OSError:
content_hash = "unreadable"
digest.update(f"{rel}\0{content_hash}\0".encode("utf-8", "replace"))
return digest.hexdigest()
def skill_tree_digest(skill_dir: Path) -> str:
"""Content digest of an installed skill directory (relative path + bytes of every file)."""
digest = hashlib.sha256()
for path in sorted(p for p in Path(skill_dir).rglob("*") if p.is_file()):
try:
digest.update(f"{path.relative_to(skill_dir)}\0{hashlib.sha256(path.read_bytes()).hexdigest()}\0".encode("utf-8", "replace"))
except OSError:
digest.update(b"unreadable\0")
return digest.hexdigest()
def managed_skills(entry: Any) -> Dict[str, Optional[str]]:
"""``imported_skills`` as ``{name: digest-at-import}`` (a pre-digest list maps to ``None`` = trusted)."""
skills = entry.get("imported_skills") if isinstance(entry, dict) else None
if isinstance(skills, dict):
return dict(skills)
return {name: None for name in skills} if isinstance(skills, list) else {}
def load_sync_manifest(target_root: Path) -> Dict[str, Any]:
"""Read the manifest; a missing, unreadable or malformed file yields an empty manifest."""
path = sync_manifest_path(target_root)
if not path.exists():
return {"version": 1, "agents": {}}
try:
data = json.loads(path.read_text(encoding="utf-8-sig", errors="replace"))
except (json.JSONDecodeError, OSError):
logger.warning("Unreadable %s — starting a fresh sync manifest", path)
return {"version": 1, "agents": {}}
if not isinstance(data, dict) or not isinstance(data.get("agents"), dict):
return {"version": 1, "agents": {}}
return data
def save_sync_manifest(target_root: Path, manifest: Dict[str, Any]) -> None:
atomic_write_text(sync_manifest_path(target_root),
json.dumps(manifest, indent=2, sort_keys=True) + "\n")
def update_sync_manifest(agent: str, source_root: Path, target_root: Path,
overwrite: bool, report: Dict[str, Any], *, refresh_digest: bool = True) -> None:
"""Record/refresh the sync entry for ``agent`` after a real (non-dry-run) import.
``imported_skills`` maps every skill this command ever copied for the agent to the digest of
the copy it wrote: a later sync replaces the destination only while it still matches that
digest, so a skill the user edited (or created) under the import category is never clobbered.
``refresh_digest=False`` keeps the previous source digest so a run with errors is retried.
"""
manifest = load_sync_manifest(target_root)
agents = manifest.setdefault("agents", {})
entry = agents.get(agent)
if not isinstance(entry, dict):
entry = {}
skills = managed_skills(entry)
for item in report.get("items", []):
if item.get("kind") == "skill" and item.get("status") == "imported" and item.get("destination"):
skills[Path(item["destination"]).name] = skill_tree_digest(Path(item["destination"]))
entry.update({"source": str(source_root), "overwrite": bool(overwrite),
"last_import": int(time.time()), "imported_skills": dict(sorted(skills.items()))})
if refresh_digest or "digest" not in entry:
entry["digest"] = compute_source_digest(agent, Path(source_root))
agents[agent] = entry
save_sync_manifest(target_root, manifest)
def sync_imported_agents(args) -> None:
"""Handle ``hermes import-agent --sync``: re-import every registered source whose digest
changed. Prompt-free (cron-friendly); ``--dry-run`` previews without touching the manifest."""
from hermes_cli.agent_import import AgentImporter, print_import_report
from hermes_cli.setup import print_error, print_header, print_info, print_success
from hermes_constants import get_hermes_home
dry_run = bool(getattr(args, "dry_run", False))
hermes_home = get_hermes_home().resolve()
agents: Dict[str, Any] = load_sync_manifest(hermes_home).get("agents", {})
if not agents:
print()
print_info("No import sources registered yet.")
print_info("Run 'hermes import-agent' first — successful imports are "
"registered for sync automatically.")
return
print()
print_header("Import Sync")
synced = unchanged = failed = 0
for agent_name in sorted(agents):
entry = agents[agent_name]
if not isinstance(entry, dict) or not entry.get("source"):
continue
source_dir = Path(entry["source"])
if not source_dir.is_dir():
print_info(f"{agent_name}: source {source_dir} no longer exists — skipped")
continue
if compute_source_digest(agent_name, source_dir) == entry.get("digest"):
print_info(f"{agent_name}: unchanged since last import")
unchanged += 1
continue
print_info(f"{agent_name}: changes detected in {source_dir}"
+ (" (dry run)" if dry_run else " — re-importing"))
overwrite = bool(entry.get("overwrite", False))
try:
report = AgentImporter(agent_name, source_dir, hermes_home, execute=not dry_run,
overwrite=overwrite, sync_skills=managed_skills(entry)).run()
except Exception as exc: # noqa: BLE001 — keep syncing the other sources
print_error(f"{agent_name}: sync failed: {exc}")
logger.debug("import-agent sync error", exc_info=True)
failed += 1
continue
print_import_report(report, dry_run=dry_run)
had_errors = bool(report.get("summary", {}).get("error"))
if had_errors:
failed += 1
if not dry_run:
try:
# Errors keep the old source digest so the next sync retries the failed items.
update_sync_manifest(agent_name, source_dir, hermes_home, overwrite, report,
refresh_digest=not had_errors)
except OSError as exc:
logger.warning("Could not update import sync manifest: %s", exc)
synced += 1
print()
if synced == 0 and failed == 0:
print_success(f"All {unchanged} source(s) already up to date.")
return
parts = [f"{synced} synced"] + [f"{n} {label}" for n, label in
((unchanged, "unchanged"), (failed, "failed")) if n]
print_success("Sync complete: " + ", ".join(parts) + ".")