# NousResearch/hermes-agent · CLI export of the default root profile

## Open now

- Nothing open on GitHub.

The members concern exporting the default root (main) profile via the CLI. One is a bug report that it cannot be exported, and two share the same title for a CLI fix referencing #4366. The titles alone do not show whether the fix works or has shipped in a version.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #4417. Verify it addresses your case.
- **Recommended:** read PR #4417 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-4417`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #4366: \[Bug\]: Cannot export the default main "profile"

## Prior & competing PRs

- **#4417: fix(cli): support exporting the default root profile (#4366)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/profiles.py`, `tests/hermes_cli/test_profiles.py`
  - fix hunks:
  - `hermes_cli/profiles.py`

    ```diff
    @@ -58,6 +58,32 @@
         "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",
    ```

    ```diff
    @@ -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():
    ```

    ```diff
    @@ -698,6 +750,21 @@ def export_profile(name: str, output_path: str) -> Path:
         output = Path(output_path)
         # shutil.make_archive wants the base name without extension
         base = str(output).removesuffix(".tar.gz").removesuffix(".tgz")
    +
    +    if name == "default":
    +        # 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:
    +            staged = Path(tmpdir) / "default"
    +            shutil.copytree(
    +                profile_dir,
    +                staged,
    +                ignore=_default_export_ignore(profile_dir),
    +            )
    +            result = shutil.make_archive(base, "gztar", tmpdir, "default")
    +            return Path(result)
    +
         result = shutil.make_archive(base, "gztar", str(profile_dir.parent), name)
         return Path(result)
     
    ```

    ```diff
    @@ -788,6 +855,15 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
                 "Specify it explicitly: hermes profile import <archive> --name <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 <archive> --name <name>"
    +        )
    +
         validate_profile_name(inferred_name)
         profile_dir = get_profile_dir(inferred_name)
         if profile_dir.exists():
    ```
  - `tests/hermes_cli/test_profiles.py`

    ```diff
    @@ -488,6 +488,149 @@ def test_export_nonexistent_raises(self, profile_env, tmp_path):
             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
    ```
- **#4370: fix(cli): support exporting the default root profile (#4366)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#4417`

## Fix status

- Merged to main in PR #4417 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-01`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `hermes_cli`
