NousResearch/hermes-agent

CLI export of the default root profile

Cluster 4366 · hermes cli. Data as of

Nothing open on GitHub. Fix merged in #4417 on 1 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #4417. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #4417. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #4417 fix(cli): support exporting the default root profile (#4366)

    merged to main

    files: hermes_cli/profiles.py, tests/hermes_cli/test_profiles.py

    Show the diff
    • hermes_cli/profiles.py
      @@ -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",
      @@ -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():
      @@ -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)
       
      @@ -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
      @@ -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

1 PR closed without merging

From the close comments: merged via another change 1

  • #4370 fix(cli): support exporting the default root profile (#4366) · merged via another change → #4417
Closed issues · 1
  • Closed#4366[Bug]: Cannot export the default main "profile"
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#4417
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
1 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.