Same rule as 6e294f543d for the reads that arrived with the origin/main merge;
the Windows footguns lane is blocking.
193 lines
8.8 KiB
Python
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) + ".")
|