diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index a2901cad..75f6669c 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -58,6 +58,32 @@ _CLONE_ALL_STRIP = [ "processes.json", ] +# Directories/files to exclude when exporting the default (~/.hermes) profile. +# The default profile contains infrastructure (repo checkout, worktrees, DBs, +# caches, binaries) that named profiles don't have. We exclude those so the +# export is a portable, reasonable-size archive of actual profile data. +_DEFAULT_EXPORT_EXCLUDE_ROOT = frozenset({ + # Infrastructure + "hermes-agent", # repo checkout (multi-GB) + ".worktrees", # git worktrees + "profiles", # other profiles — never recursive-export + "bin", # installed binaries (tirith, etc.) + "node_modules", # npm packages + # Databases & runtime state + "state.db", "state.db-shm", "state.db-wal", + "hermes_state.db", + "response_store.db", "response_store.db-shm", "response_store.db-wal", + "gateway.pid", "gateway_state.json", "processes.json", + "auth.lock", "active_profile", ".update_check", + "errors.log", + ".hermes_history", + # Caches (regenerated on use) + "image_cache", "audio_cache", "document_cache", + "browser_screenshots", "checkpoints", + "sandboxes", + "logs", # gateway logs +}) + # Names that cannot be used as profile aliases _RESERVED_NAMES = frozenset({ "hermes", "default", "test", "tmp", "root", "sudo", @@ -685,11 +711,37 @@ def get_active_profile_name() -> str: # Export / Import # --------------------------------------------------------------------------- +def _default_export_ignore(root_dir: Path): + """Return an *ignore* callable for :func:`shutil.copytree`. + + At the root level it excludes everything in ``_DEFAULT_EXPORT_EXCLUDE_ROOT``. + At all levels it excludes ``__pycache__``, sockets, and temp files. + """ + + def _ignore(directory: str, contents: list) -> set: + ignored: set = set() + for entry in contents: + # Universal exclusions (any depth) + if entry == "__pycache__" or entry.endswith((".sock", ".tmp")): + ignored.add(entry) + # npm lockfiles can appear at root + elif entry in ("package.json", "package-lock.json"): + ignored.add(entry) + # Root-level exclusions + if Path(directory) == root_dir: + ignored.update(c for c in contents if c in _DEFAULT_EXPORT_EXCLUDE_ROOT) + return ignored + + return _ignore + + def export_profile(name: str, output_path: str) -> Path: """Export a profile to a tar.gz archive. Returns the output file path. """ + import tempfile + validate_profile_name(name) profile_dir = get_profile_dir(name) if not profile_dir.is_dir(): @@ -700,15 +752,15 @@ def export_profile(name: str, output_path: str) -> Path: base = str(output).removesuffix(".tar.gz").removesuffix(".tgz") if name == "default": - import tempfile - import shutil + # The default profile IS ~/.hermes itself — its parent is ~/ and its + # directory name is ".hermes", not "default". We stage a clean copy + # under a temp dir so the archive contains ``default/...``. with tempfile.TemporaryDirectory() as tmpdir: - tmp_default_dir = Path(tmpdir) / "default" - # Copy root profile contents to a dummy 'default' folder, ignoring other profiles and runtime state + staged = Path(tmpdir) / "default" shutil.copytree( profile_dir, - tmp_default_dir, - ignore=shutil.ignore_patterns("profiles", "gateway.pid", "*.sock", "__pycache__") + staged, + ignore=_default_export_ignore(profile_dir), ) result = shutil.make_archive(base, "gztar", tmpdir, "default") return Path(result) @@ -803,6 +855,15 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path: "Specify it explicitly: hermes profile import --name " ) + # Archives exported from the default profile have "default/" as top-level + # dir. Importing as "default" would target ~/.hermes itself — disallow + # that and guide the user toward a named profile. + if inferred_name == "default": + raise ValueError( + "Cannot import as 'default' — that is the built-in root profile (~/.hermes). " + "Specify a different name: hermes profile import --name " + ) + validate_profile_name(inferred_name) profile_dir = get_profile_dir(inferred_name) if profile_dir.exists(): diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 4e59d250..15c96d71 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -488,6 +488,149 @@ class TestExportImport: with pytest.raises(FileNotFoundError): export_profile("nonexistent", str(tmp_path / "out.tar.gz")) + # --------------------------------------------------------------- + # Default profile export / import + # --------------------------------------------------------------- + + def test_export_default_creates_valid_archive(self, profile_env, tmp_path): + """Exporting the default profile produces a valid tar.gz.""" + default_dir = get_profile_dir("default") + (default_dir / "config.yaml").write_text("model: test") + + output = tmp_path / "export" / "default.tar.gz" + output.parent.mkdir(parents=True, exist_ok=True) + result = export_profile("default", str(output)) + + assert Path(result).exists() + assert tarfile.is_tarfile(str(result)) + + def test_export_default_includes_profile_data(self, profile_env, tmp_path): + """Profile data files end up in the archive.""" + default_dir = get_profile_dir("default") + (default_dir / "config.yaml").write_text("model: test") + (default_dir / ".env").write_text("KEY=val") + (default_dir / "SOUL.md").write_text("Be nice.") + mem_dir = default_dir / "memories" + mem_dir.mkdir(exist_ok=True) + (mem_dir / "MEMORY.md").write_text("remember this") + + output = tmp_path / "export" / "default.tar.gz" + output.parent.mkdir(parents=True, exist_ok=True) + export_profile("default", str(output)) + + with tarfile.open(str(output), "r:gz") as tf: + names = tf.getnames() + + assert "default/config.yaml" in names + assert "default/.env" in names + assert "default/SOUL.md" in names + assert "default/memories/MEMORY.md" in names + + def test_export_default_excludes_infrastructure(self, profile_env, tmp_path): + """Repo checkout, worktrees, profiles, databases are excluded.""" + default_dir = get_profile_dir("default") + (default_dir / "config.yaml").write_text("ok") + + # Create dirs/files that should be excluded + for d in ("hermes-agent", ".worktrees", "profiles", "bin", + "image_cache", "logs", "sandboxes", "checkpoints"): + sub = default_dir / d + sub.mkdir(exist_ok=True) + (sub / "marker.txt").write_text("excluded") + + for f in ("state.db", "gateway.pid", "gateway_state.json", + "processes.json", "errors.log", ".hermes_history", + "active_profile", ".update_check", "auth.lock"): + (default_dir / f).write_text("excluded") + + output = tmp_path / "export" / "default.tar.gz" + output.parent.mkdir(parents=True, exist_ok=True) + export_profile("default", str(output)) + + with tarfile.open(str(output), "r:gz") as tf: + names = tf.getnames() + + # Config is present + assert "default/config.yaml" in names + + # Infrastructure excluded + excluded_prefixes = [ + "default/hermes-agent", "default/.worktrees", "default/profiles", + "default/bin", "default/image_cache", "default/logs", + "default/sandboxes", "default/checkpoints", + ] + for prefix in excluded_prefixes: + assert not any(n.startswith(prefix) for n in names), \ + f"Expected {prefix} to be excluded but found it in archive" + + excluded_files = [ + "default/state.db", "default/gateway.pid", + "default/gateway_state.json", "default/processes.json", + "default/errors.log", "default/.hermes_history", + "default/active_profile", "default/.update_check", + "default/auth.lock", + ] + for f in excluded_files: + assert f not in names, f"Expected {f} to be excluded" + + def test_export_default_excludes_pycache_at_any_depth(self, profile_env, tmp_path): + """__pycache__ dirs are excluded even inside nested directories.""" + default_dir = get_profile_dir("default") + (default_dir / "config.yaml").write_text("ok") + nested = default_dir / "skills" / "my-skill" / "__pycache__" + nested.mkdir(parents=True) + (nested / "cached.pyc").write_text("bytecode") + + output = tmp_path / "export" / "default.tar.gz" + output.parent.mkdir(parents=True, exist_ok=True) + export_profile("default", str(output)) + + with tarfile.open(str(output), "r:gz") as tf: + names = tf.getnames() + + assert not any("__pycache__" in n for n in names) + + def test_import_default_without_name_raises(self, profile_env, tmp_path): + """Importing a default export without --name gives clear guidance.""" + default_dir = get_profile_dir("default") + (default_dir / "config.yaml").write_text("ok") + + archive = tmp_path / "export" / "default.tar.gz" + archive.parent.mkdir(parents=True, exist_ok=True) + export_profile("default", str(archive)) + + with pytest.raises(ValueError, match="Cannot import as 'default'"): + import_profile(str(archive)) + + def test_import_default_with_explicit_default_name_raises(self, profile_env, tmp_path): + """Explicitly importing as 'default' is also rejected.""" + default_dir = get_profile_dir("default") + (default_dir / "config.yaml").write_text("ok") + + archive = tmp_path / "export" / "default.tar.gz" + archive.parent.mkdir(parents=True, exist_ok=True) + export_profile("default", str(archive)) + + with pytest.raises(ValueError, match="Cannot import as 'default'"): + import_profile(str(archive), name="default") + + def test_import_default_export_with_new_name_roundtrip(self, profile_env, tmp_path): + """Export default → import under a different name → data preserved.""" + default_dir = get_profile_dir("default") + (default_dir / "config.yaml").write_text("model: opus") + mem_dir = default_dir / "memories" + mem_dir.mkdir(exist_ok=True) + (mem_dir / "MEMORY.md").write_text("important fact") + + archive = tmp_path / "export" / "default.tar.gz" + archive.parent.mkdir(parents=True, exist_ok=True) + export_profile("default", str(archive)) + + imported = import_profile(str(archive), name="backup") + assert imported.is_dir() + assert (imported / "config.yaml").read_text() == "model: opus" + assert (imported / "memories" / "MEMORY.md").read_text() == "important fact" + # =================================================================== # TestProfileIsolation