diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 9c4d4fe4dad25..fda440d66efa2 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -563,11 +563,14 @@ def _print_capped(header: str, lines: List[str], indent: str) -> None: # --- Backup --- +_RUN_BACKUP_PREFIX = "hermes-backup-" + + def _resolve_backup_output_path(output: Optional[str]) -> Path: """Turn ``--output`` (file, directory, or None) into a ``.zip`` path whose parent exists; an unwritable path exits with a one-line error, not a traceback.""" out_path = None - default_name = f"hermes-backup-{datetime.now().strftime('%Y-%m-%d-%H%M%S')}.zip" + default_name = f"{_RUN_BACKUP_PREFIX}{datetime.now().strftime('%Y-%m-%d-%H%M%S')}.zip" try: if output: out_path = Path(output).expanduser().resolve() @@ -679,6 +682,11 @@ def _progress(i: int) -> None: _print_capped(f"\n Warnings ({len(errors)} files skipped):", errors, " ") else: print(f"\nRestore with: hermes import {out_path.name}") + keep = getattr(args, "keep", 0) # 0 / absent: never prune (non-CLI callers) + if keep and out_path.name.startswith(_RUN_BACKUP_PREFIX): + pruned = _prune_prefixed_zips(out_path.parent, _RUN_BACKUP_PREFIX, keep, "backup") + if pruned: + print(f" Pruned {pruned} older {_RUN_BACKUP_PREFIX}*.zip (keeping {keep}).") # --- Import --- diff --git a/hermes_cli/subcommands/backup.py b/hermes_cli/subcommands/backup.py index 5ec7ac27214c2..282b3773f8dbf 100644 --- a/hermes_cli/subcommands/backup.py +++ b/hermes_cli/subcommands/backup.py @@ -20,4 +20,8 @@ def build_backup_parser(subparsers, *, cmd_backup: Callable) -> None: help="Quick snapshot: only critical state files (config, state.db, .env, auth, cron)") backup_parser.add_argument( "-l", "--label", help="Label for the snapshot (only used with --quick)") + backup_parser.add_argument( + "-k", "--keep", type=int, default=3, metavar="N", + help="After a full backup, delete older hermes-backup-*.zip files in the output " + "directory beyond the newest N (default 3; 0 keeps everything)") backup_parser.set_defaults(func=cmd_backup) diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 58b49c842a214..abc5bf6dcb680 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -2423,3 +2423,25 @@ def _count_rows(db_path: Path) -> tuple[int, int]: ) finally: conn.close() + + +def test_run_backup_prunes_older_default_named_zips_but_not_others(tmp_path, monkeypatch): + """Hourly `hermes backup` callers accumulated 150+ zips; --keep bounds the default-named + ones and leaves custom-named or foreign zips alone (#81317).""" + from argparse import Namespace + from hermes_cli import backup as backup_mod + + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text("model: x\n") + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + for i in range(4): + (tmp_path / f"hermes-backup-2026-01-0{i + 1}-000000.zip").write_bytes(b"old") + (tmp_path / "my-archive.zip").write_bytes(b"mine") + + backup_mod.run_backup(Namespace(output=None, keep=2)) + + kept = sorted(p.name for p in tmp_path.glob("hermes-backup-*.zip")) + assert len(kept) == 2 and kept[0] == "hermes-backup-2026-01-04-000000.zip" + assert (tmp_path / "my-archive.zip").exists() diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index b5e6093e6e548..d5a4bf320a17f 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -950,6 +950,7 @@ Create a zip archive of your Hermes configuration, skills, sessions, and data. T | `-o`, `--output ` | Output path for the zip file (default: `~/hermes-backup-.zip`). | | `-q`, `--quick` | Quick snapshot: only critical state files (config.yaml, state.db, .env, auth, cron jobs). Much faster than a full backup. | | `-l`, `--label ` | Label for the snapshot (only used with `--quick`). | +| `-k`, `--keep ` | After a full backup, delete older `hermes-backup-*.zip` files in the output directory beyond the newest N (default 3; `0` keeps everything). Custom-named zips are never touched. | The backup uses SQLite's `backup()` API for safe copying, so it works correctly even when Hermes is running (WAL-mode safe).