""" Backup and import commands for hermes CLI. `hermes backup` creates a zip archive of the entire ~/.hermes/ directory (excluding the hermes-agent repo and transient files). `hermes import` restores from a backup zip, overlaying onto the current HERMES_HOME root. """ import json import logging import os import shutil import sqlite3 import sys import tempfile import time import zipfile from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional from hermes_constants import get_default_hermes_root, get_hermes_home, display_hermes_home logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Exclusion rules # --------------------------------------------------------------------------- # Directory names to skip entirely (matched against each path component) _EXCLUDED_DIRS = { "hermes-agent", # the codebase repo — re-clone instead "__pycache__", # bytecode caches — regenerated on import ".git", # nested git dirs (profiles shouldn't have these, but safety) "node_modules", # js deps if website/ somehow leaks in } # File-name suffixes to skip _EXCLUDED_SUFFIXES = ( ".pyc", ".pyo", ) # File names to skip (runtime state that's meaningless on another machine) _EXCLUDED_NAMES = { "gateway.pid", "cron.pid", } def _should_exclude(rel_path: Path) -> bool: """Return True if *rel_path* (relative to hermes root) should be skipped.""" parts = rel_path.parts # Any path component matches an excluded dir name for part in parts: if part in _EXCLUDED_DIRS: return True name = rel_path.name if name in _EXCLUDED_NAMES: return True if name.endswith(_EXCLUDED_SUFFIXES): return True return False # --------------------------------------------------------------------------- # SQLite safe copy # --------------------------------------------------------------------------- def _safe_copy_db(src: Path, dst: Path) -> bool: """Copy a SQLite database safely using the backup() API. Handles WAL mode — produces a consistent snapshot even while the DB is being written to. Falls back to raw copy on failure. """ try: conn = sqlite3.connect(f"file:{src}?mode=ro", uri=True) backup_conn = sqlite3.connect(str(dst)) conn.backup(backup_conn) backup_conn.close() conn.close() return True except Exception as exc: logger.warning("SQLite safe copy failed for %s: %s", src, exc) try: shutil.copy2(src, dst) return True except Exception as exc2: logger.error("Raw copy also failed for %s: %s", src, exc2) return False # --------------------------------------------------------------------------- # Backup # --------------------------------------------------------------------------- def _format_size(nbytes: int) -> str: """Human-readable file size.""" for unit in ("B", "KB", "MB", "GB"): if nbytes < 1024: return f"{nbytes:.1f} {unit}" if unit != "B" else f"{nbytes} {unit}" nbytes /= 1024 return f"{nbytes:.1f} TB" def run_backup(args) -> None: """Create a zip backup of the Hermes home directory.""" hermes_root = get_default_hermes_root() if not hermes_root.is_dir(): print(f"Error: Hermes home directory not found at {hermes_root}") sys.exit(1) # Determine output path if args.output: out_path = Path(args.output).expanduser().resolve() # If user gave a directory, put the zip inside it if out_path.is_dir(): stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") out_path = out_path / f"hermes-backup-{stamp}.zip" else: stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S") out_path = Path.home() / f"hermes-backup-{stamp}.zip" # Ensure the suffix is .zip if out_path.suffix.lower() != ".zip": out_path = out_path.with_suffix(out_path.suffix + ".zip") # Ensure parent directory exists out_path.parent.mkdir(parents=True, exist_ok=True) # Collect files print(f"Scanning {display_hermes_home()} ...") files_to_add: list[tuple[Path, Path]] = [] # (absolute, relative) skipped_dirs = set() for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False): dp = Path(dirpath) rel_dir = dp.relative_to(hermes_root) # Prune excluded directories in-place so os.walk doesn't descend orig_dirnames = dirnames[:] dirnames[:] = [ d for d in dirnames if d not in _EXCLUDED_DIRS ] for removed in set(orig_dirnames) - set(dirnames): skipped_dirs.add(str(rel_dir / removed)) for fname in filenames: fpath = dp / fname rel = fpath.relative_to(hermes_root) if _should_exclude(rel): continue # Skip the output zip itself if it happens to be inside hermes root try: if fpath.resolve() == out_path.resolve(): continue except (OSError, ValueError): pass files_to_add.append((fpath, rel)) if not files_to_add: print("No files to back up.") return # Create the zip file_count = len(files_to_add) print(f"Backing up {file_count} files ...") total_bytes = 0 errors = [] t0 = time.monotonic() with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf: for i, (abs_path, rel_path) in enumerate(files_to_add, 1): try: # Safe copy for SQLite databases (handles WAL mode) if abs_path.suffix == ".db": with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: tmp_db = Path(tmp.name) if _safe_copy_db(abs_path, tmp_db): zf.write(tmp_db, arcname=str(rel_path)) total_bytes += tmp_db.stat().st_size tmp_db.unlink(missing_ok=True) else: tmp_db.unlink(missing_ok=True) errors.append(f" {rel_path}: SQLite safe copy failed") continue else: zf.write(abs_path, arcname=str(rel_path)) total_bytes += abs_path.stat().st_size except (PermissionError, OSError) as exc: errors.append(f" {rel_path}: {exc}") continue # Progress every 500 files if i % 500 == 0: print(f" {i}/{file_count} files ...") elapsed = time.monotonic() - t0 zip_size = out_path.stat().st_size # Summary print() print(f"Backup complete: {out_path}") print(f" Files: {file_count}") print(f" Original: {_format_size(total_bytes)}") print(f" Compressed: {_format_size(zip_size)}") print(f" Time: {elapsed:.1f}s") if skipped_dirs: print(f"\n Excluded directories:") for d in sorted(skipped_dirs): print(f" {d}/") if errors: print(f"\n Warnings ({len(errors)} files skipped):") for e in errors[:10]: print(e) if len(errors) > 10: print(f" ... and {len(errors) - 10} more") print(f"\nRestore with: hermes import {out_path.name}") # --------------------------------------------------------------------------- # Import # --------------------------------------------------------------------------- def _validate_backup_zip(zf: zipfile.ZipFile) -> tuple[bool, str]: """Check that a zip looks like a Hermes backup. Returns (ok, reason). """ names = zf.namelist() if not names: return False, "zip archive is empty" # Look for telltale files that a hermes home would have markers = {"config.yaml", ".env", "state.db"} found = set() for n in names: # Could be at the root or one level deep (if someone zipped the directory) basename = Path(n).name if basename in markers: found.add(basename) if not found: return False, ( "zip does not appear to be a Hermes backup " "(no config.yaml, .env, or state databases found)" ) return True, "" def _detect_prefix(zf: zipfile.ZipFile) -> str: """Detect if the zip has a common directory prefix wrapping all entries. Some tools zip as `.hermes/config.yaml` instead of `config.yaml`. Returns the prefix to strip (empty string if none). """ names = [n for n in zf.namelist() if not n.endswith("/")] if not names: return "" # Find common prefix parts_list = [Path(n).parts for n in names] # Check if all entries share a common first directory first_parts = {p[0] for p in parts_list if len(p) > 1} if len(first_parts) == 1: prefix = first_parts.pop() # Only strip if it looks like a hermes dir name if prefix in (".hermes", "hermes"): return prefix + "/" return "" def run_import(args) -> None: """Restore a Hermes backup from a zip file.""" zip_path = Path(args.zipfile).expanduser().resolve() if not zip_path.is_file(): print(f"Error: File not found: {zip_path}") sys.exit(1) if not zipfile.is_zipfile(zip_path): print(f"Error: Not a valid zip file: {zip_path}") sys.exit(1) hermes_root = get_default_hermes_root() with zipfile.ZipFile(zip_path, "r") as zf: # Validate ok, reason = _validate_backup_zip(zf) if not ok: print(f"Error: {reason}") sys.exit(1) prefix = _detect_prefix(zf) members = [n for n in zf.namelist() if not n.endswith("/")] file_count = len(members) print(f"Backup contains {file_count} files") print(f"Target: {display_hermes_home()}") if prefix: print(f"Detected archive prefix: {prefix!r} (will be stripped)") # Check for existing installation has_config = (hermes_root / "config.yaml").exists() has_env = (hermes_root / ".env").exists() if (has_config or has_env) and not args.force: print() print("Warning: Target directory already has Hermes configuration.") print("Importing will overwrite existing files with backup contents.") print() try: answer = input("Continue? [y/N] ").strip().lower() except (EOFError, KeyboardInterrupt): print("\nAborted.") sys.exit(1) if answer not in ("y", "yes"): print("Aborted.") return # Extract print(f"\nImporting {file_count} files ...") hermes_root.mkdir(parents=True, exist_ok=True) errors = [] restored = 0 t0 = time.monotonic() for member in members: # Strip prefix if detected if prefix and member.startswith(prefix): rel = member[len(prefix):] else: rel = member if not rel: continue target = hermes_root / rel # Security: reject absolute paths and traversals try: target.resolve().relative_to(hermes_root.resolve()) except ValueError: errors.append(f" {rel}: path traversal blocked") continue try: target.parent.mkdir(parents=True, exist_ok=True) with zf.open(member) as src, open(target, "wb") as dst: dst.write(src.read()) restored += 1 except (PermissionError, OSError) as exc: errors.append(f" {rel}: {exc}") if restored % 500 == 0: print(f" {restored}/{file_count} files ...") elapsed = time.monotonic() - t0 # Summary print() print(f"Import complete: {restored} files restored in {elapsed:.1f}s") print(f" Target: {display_hermes_home()}") if errors: print(f"\n Warnings ({len(errors)} files skipped):") for e in errors[:10]: print(e) if len(errors) > 10: print(f" ... and {len(errors) - 10} more") # Post-import: restore profile wrapper scripts profiles_dir = hermes_root / "profiles" restored_profiles = [] if profiles_dir.is_dir(): try: from hermes_cli.profiles import ( create_wrapper_script, check_alias_collision, _is_wrapper_dir_in_path, _get_wrapper_dir, ) for entry in sorted(profiles_dir.iterdir()): if not entry.is_dir(): continue profile_name = entry.name # Only create wrappers for directories with config if not (entry / "config.yaml").exists() and not (entry / ".env").exists(): continue collision = check_alias_collision(profile_name) if collision: print(f" Skipped alias '{profile_name}': {collision}") restored_profiles.append((profile_name, False)) else: wrapper = create_wrapper_script(profile_name) restored_profiles.append((profile_name, wrapper is not None)) if restored_profiles: created = [n for n, ok in restored_profiles if ok] skipped = [n for n, ok in restored_profiles if not ok] if created: print(f"\n Profile aliases restored: {', '.join(created)}") if skipped: print(f" Profile aliases skipped: {', '.join(skipped)}") if not _is_wrapper_dir_in_path(): print(f"\n Note: {_get_wrapper_dir()} is not in your PATH.") print(' Add to your shell config (~/.bashrc or ~/.zshrc):') print(' export PATH="$HOME/.local/bin:$PATH"') except ImportError: # hermes_cli.profiles might not be available (fresh install) if any(profiles_dir.iterdir()): print(f"\n Profiles detected but aliases could not be created.") print(f" Run: hermes profile list (after installing hermes)") # Guidance print() if not (hermes_root / "hermes-agent").is_dir(): print("Note: The hermes-agent codebase was not included in the backup.") print(" If this is a fresh install, run: hermes update") if restored_profiles: gw_profiles = [n for n, _ in restored_profiles] print("\nTo re-enable gateway services for profiles:") for pname in gw_profiles: print(f" hermes -p {pname} gateway install") print("Done. Your Hermes configuration has been restored.") # --------------------------------------------------------------------------- # Quick state snapshots (used by /snapshot slash command and hermes backup --quick) # --------------------------------------------------------------------------- # Critical state files to include in quick snapshots (relative to HERMES_HOME). # Everything else is either regeneratable (logs, cache) or managed separately # (skills, repo, sessions/). _QUICK_STATE_FILES = ( "state.db", "config.yaml", ".env", "auth.json", "cron/jobs.json", "gateway_state.json", "channel_directory.json", "processes.json", ) _QUICK_SNAPSHOTS_DIR = "state-snapshots" _QUICK_DEFAULT_KEEP = 20 def _quick_snapshot_root(hermes_home: Optional[Path] = None) -> Path: home = hermes_home or get_hermes_home() return home / _QUICK_SNAPSHOTS_DIR def create_quick_snapshot( label: Optional[str] = None, hermes_home: Optional[Path] = None, ) -> Optional[str]: """Create a quick state snapshot of critical files. Copies STATE_FILES to a timestamped directory under state-snapshots/. Auto-prunes old snapshots beyond the keep limit. Returns: Snapshot ID (timestamp-based), or None if no files found. """ home = hermes_home or get_hermes_home() root = _quick_snapshot_root(home) ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") snap_id = f"{ts}-{label}" if label else ts snap_dir = root / snap_id snap_dir.mkdir(parents=True, exist_ok=True) manifest: Dict[str, int] = {} # rel_path -> file size for rel in _QUICK_STATE_FILES: src = home / rel if not src.exists() or not src.is_file(): continue dst = snap_dir / rel dst.parent.mkdir(parents=True, exist_ok=True) try: if src.suffix == ".db": if not _safe_copy_db(src, dst): continue else: shutil.copy2(src, dst) manifest[rel] = dst.stat().st_size except (OSError, PermissionError) as exc: logger.warning("Could not snapshot %s: %s", rel, exc) if not manifest: shutil.rmtree(snap_dir, ignore_errors=True) return None # Write manifest meta = { "id": snap_id, "timestamp": ts, "label": label, "file_count": len(manifest), "total_size": sum(manifest.values()), "files": manifest, } with open(snap_dir / "manifest.json", "w") as f: json.dump(meta, f, indent=2) # Auto-prune _prune_quick_snapshots(root, keep=_QUICK_DEFAULT_KEEP) logger.info("State snapshot created: %s (%d files)", snap_id, len(manifest)) return snap_id def list_quick_snapshots( limit: int = 20, hermes_home: Optional[Path] = None, ) -> List[Dict[str, Any]]: """List existing quick state snapshots, most recent first.""" root = _quick_snapshot_root(hermes_home) if not root.exists(): return [] results = [] for d in sorted(root.iterdir(), reverse=True): if not d.is_dir(): continue manifest_path = d / "manifest.json" if manifest_path.exists(): try: with open(manifest_path) as f: results.append(json.load(f)) except (json.JSONDecodeError, OSError): results.append({"id": d.name, "file_count": 0, "total_size": 0}) if len(results) >= limit: break return results def restore_quick_snapshot( snapshot_id: str, hermes_home: Optional[Path] = None, ) -> bool: """Restore state from a quick snapshot. Overwrites current state files with the snapshot's copies. Returns True if at least one file was restored. """ home = hermes_home or get_hermes_home() root = _quick_snapshot_root(home) snap_dir = root / snapshot_id if not snap_dir.is_dir(): return False manifest_path = snap_dir / "manifest.json" if not manifest_path.exists(): return False with open(manifest_path) as f: meta = json.load(f) restored = 0 for rel in meta.get("files", {}): src = snap_dir / rel if not src.exists(): continue dst = home / rel dst.parent.mkdir(parents=True, exist_ok=True) try: if dst.suffix == ".db": # Atomic-ish replace for databases tmp = dst.parent / f".{dst.name}.snap_restore" shutil.copy2(src, tmp) dst.unlink(missing_ok=True) shutil.move(str(tmp), str(dst)) else: shutil.copy2(src, dst) restored += 1 except (OSError, PermissionError) as exc: logger.error("Failed to restore %s: %s", rel, exc) logger.info("Restored %d files from snapshot %s", restored, snapshot_id) return restored > 0 def _prune_quick_snapshots(root: Path, keep: int = _QUICK_DEFAULT_KEEP) -> int: """Remove oldest quick snapshots beyond the keep limit. Returns count deleted.""" if not root.exists(): return 0 dirs = sorted( (d for d in root.iterdir() if d.is_dir()), key=lambda d: d.name, reverse=True, ) deleted = 0 for d in dirs[keep:]: try: shutil.rmtree(d) deleted += 1 except OSError as exc: logger.warning("Failed to prune snapshot %s: %s", d.name, exc) return deleted def prune_quick_snapshots( keep: int = _QUICK_DEFAULT_KEEP, hermes_home: Optional[Path] = None, ) -> int: """Manually prune quick snapshots. Returns count deleted.""" return _prune_quick_snapshots(_quick_snapshot_root(hermes_home), keep=keep) def run_quick_backup(args) -> None: """CLI entry point for hermes backup --quick.""" label = getattr(args, "label", None) snap_id = create_quick_snapshot(label=label) if snap_id: print(f"State snapshot created: {snap_id}") snaps = list_quick_snapshots() print(f" {len(snaps)} snapshot(s) stored in {display_hermes_home()}/state-snapshots/") print(f" Restore with: /snapshot restore {snap_id}") else: print("No state files found to snapshot.")