{"repo":"NousResearch/hermes-agent","public_id":"4366","display_label":"CLI export of the default root profile","summary":"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.","subsystem":"hermes_cli","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":4417,"merged_at":"2026-04-01T08:43:51+00:00"},"resolution":{"state":"resolved","by":"pr-4417"},"freshness":{"state":"stale","last_verified_at":"2026-04-01T08:43:51+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":4366,"pr_number":4417,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-01T08:43:51+00:00"}],"members":[{"kind":"issue","number":4366,"title":"[Bug]: Cannot export the default main \"profile\"","url":null,"state":"closed"},{"kind":"pr","number":4370,"title":"fix(cli): support exporting the default root profile (#4366)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#4417","number":4417}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":4417,"title":"fix(cli): support exporting the default root profile (#4366)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/profiles.py","tests/hermes_cli/test_profiles.py"],"fix_hunks":[{"file":"hermes_cli/profiles.py","hunks":["@@ -58,6 +58,32 @@\n     \"processes.json\",\n ]\n \n+# Directories/files to exclude when exporting the default (~/.hermes) profile.\n+# The default profile contains infrastructure (repo checkout, worktrees, DBs,\n+# caches, binaries) that named profiles don't have.  We exclude those so the\n+# export is a portable, reasonable-size archive of actual profile data.\n+_DEFAULT_EXPORT_EXCLUDE_ROOT = frozenset({\n+    # Infrastructure\n+    \"hermes-agent\",         # repo checkout (multi-GB)\n+    \".worktrees\",           # git worktrees\n+    \"profiles\",             # other profiles — never recursive-export\n+    \"bin\",                  # installed binaries (tirith, etc.)\n+    \"node_modules\",         # npm packages\n+    # Databases & runtime state\n+    \"state.db\", \"state.db-shm\", \"state.db-wal\",\n+    \"hermes_state.db\",\n+    \"response_store.db\", \"response_store.db-shm\", \"response_store.db-wal\",\n+    \"gateway.pid\", \"gateway_state.json\", \"processes.json\",\n+    \"auth.lock\", \"active_profile\", \".update_check\",\n+    \"errors.log\",\n+    \".hermes_history\",\n+    # Caches (regenerated on use)\n+    \"image_cache\", \"audio_cache\", \"document_cache\",\n+    \"browser_screenshots\", \"checkpoints\",\n+    \"sandboxes\",\n+    \"logs\",                 # gateway logs\n+})\n+\n # Names that cannot be used as profile aliases\n _RESERVED_NAMES = frozenset({\n     \"hermes\", \"default\", \"test\", \"tmp\", \"root\", \"sudo\",","@@ -685,11 +711,37 @@ def get_active_profile_name() -> str:\n # Export / Import\n # ---------------------------------------------------------------------------\n \n+def _default_export_ignore(root_dir: Path):\n+    \"\"\"Return an *ignore* callable for :func:`shutil.copytree`.\n+\n+    At the root level it excludes everything in ``_DEFAULT_EXPORT_EXCLUDE_ROOT``.\n+    At all levels it excludes ``__pycache__``, sockets, and temp files.\n+    \"\"\"\n+\n+    def _ignore(directory: str, contents: list) -> set:\n+        ignored: set = set()\n+        for entry in contents:\n+            # Universal exclusions (any depth)\n+            if entry == \"__pycache__\" or entry.endswith((\".sock\", \".tmp\")):\n+                ignored.add(entry)\n+            # npm lockfiles can appear at root\n+            elif entry in (\"package.json\", \"package-lock.json\"):\n+                ignored.add(entry)\n+        # Root-level exclusions\n+        if Path(directory) == root_dir:\n+            ignored.update(c for c in contents if c in _DEFAULT_EXPORT_EXCLUDE_ROOT)\n+        return ignored\n+\n+    return _ignore\n+\n+\n def export_profile(name: str, output_path: str) -> Path:\n     \"\"\"Export a profile to a tar.gz archive.\n \n     Returns the output file path.\n     \"\"\"\n+    import tempfile\n+\n     validate_profile_name(name)\n     profile_dir = get_profile_dir(name)\n     if not profile_dir.is_dir():","@@ -698,6 +750,21 @@ def export_profile(name: str, output_path: str) -> Path:\n     output = Path(output_path)\n     # shutil.make_archive wants the base name without extension\n     base = str(output).removesuffix(\".tar.gz\").removesuffix(\".tgz\")\n+\n+    if name == \"default\":\n+        # The default profile IS ~/.hermes itself — its parent is ~/ and its\n+        # directory name is \".hermes\", not \"default\".  We stage a clean copy\n+        # under a temp dir so the archive contains ``default/...``.\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            staged = Path(tmpdir) / \"default\"\n+            shutil.copytree(\n+                profile_dir,\n+                staged,\n+                ignore=_default_export_ignore(profile_dir),\n+            )\n+            result = shutil.make_archive(base, \"gztar\", tmpdir, \"default\")\n+            return Path(result)\n+\n     result = shutil.make_archive(base, \"gztar\", str(profile_dir.parent), name)\n     return Path(result)\n ","@@ -788,6 +855,15 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:\n             \"Specify it explicitly: hermes profile import <archive> --name <name>\"\n         )\n \n+    # Archives exported from the default profile have \"default/\" as top-level\n+    # dir.  Importing as \"default\" would target ~/.hermes itself — disallow\n+    # that and guide the user toward a named profile.\n+    if inferred_name == \"default\":\n+        raise ValueError(\n+            \"Cannot import as 'default' — that is the built-in root profile (~/.hermes). \"\n+            \"Specify a different name: hermes profile import <archive> --name <name>\"\n+        )\n+\n     validate_profile_name(inferred_name)\n     profile_dir = get_profile_dir(inferred_name)\n     if profile_dir.exists():"]},{"file":"tests/hermes_cli/test_profiles.py","hunks":["@@ -488,6 +488,149 @@ def test_export_nonexistent_raises(self, profile_env, tmp_path):\n         with pytest.raises(FileNotFoundError):\n             export_profile(\"nonexistent\", str(tmp_path / \"out.tar.gz\"))\n \n+    # ---------------------------------------------------------------\n+    # Default profile export / import\n+    # ---------------------------------------------------------------\n+\n+    def test_export_default_creates_valid_archive(self, profile_env, tmp_path):\n+        \"\"\"Exporting the default profile produces a valid tar.gz.\"\"\"\n+        default_dir = get_profile_dir(\"default\")\n+        (default_dir / \"config.yaml\").write_text(\"model: test\")\n+\n+        output = tmp_path / \"export\" / \"default.tar.gz\"\n+        output.parent.mkdir(parents=True, exist_ok=True)\n+        result = export_profile(\"default\", str(output))\n+\n+        assert Path(result).exists()\n+        assert tarfile.is_tarfile(str(result))\n+\n+    def test_export_default_includes_profile_data(self, profile_env, tmp_path):\n+        \"\"\"Profile data files end up in the archive.\"\"\"\n+        default_dir = get_profile_dir(\"default\")\n+        (default_dir / \"config.yaml\").write_text(\"model: test\")\n+        (default_dir / \".env\").write_text(\"KEY=val\")\n+        (default_dir / \"SOUL.md\").write_text(\"Be nice.\")\n+        mem_dir = default_dir / \"memories\"\n+        mem_dir.mkdir(exist_ok=True)\n+        (mem_dir / \"MEMORY.md\").write_text(\"remember this\")\n+\n+        output = tmp_path / \"export\" / \"default.tar.gz\"\n+        output.parent.mkdir(parents=True, exist_ok=True)\n+        export_profile(\"default\", str(output))\n+\n+        with tarfile.open(str(output), \"r:gz\") as tf:\n+            names = tf.getnames()\n+\n+        assert \"default/config.yaml\" in names\n+        assert \"default/.env\" in names\n+        assert \"default/SOUL.md\" in names\n+        assert \"default/memories/MEMORY.md\" in names\n+\n+    def test_export_default_excludes_infrastructure(self, profile_env, tmp_path):\n+        \"\"\"Repo checkout, worktrees, profiles, databases are excluded.\"\"\"\n+        default_dir = get_profile_dir(\"default\")\n+        (default_dir / \"config.yaml\").write_text(\"ok\")\n+\n+        # Create dirs/files that should be excluded\n+        for d in (\"hermes-agent\", \".worktrees\", \"profiles\", \"bin\",\n+                  \"image_cache\", \"logs\", \"sandboxes\", \"checkpoints\"):\n+            sub = default_dir / d\n+            sub.mkdir(exist_ok=True)\n+            (sub / \"marker.txt\").write_text(\"excluded\")\n+\n+        for f in (\"state.db\", \"gateway.pid\", \"gateway_state.json\",\n+                  \"processes.json\", \"errors.log\", \".hermes_history\",\n+                  \"active_profile\", \".update_check\", \"auth.lock\"):\n+            (default_dir / f).write_text(\"excluded\")\n+\n+        output = tmp_path / \"export\" / \"default.tar.gz\"\n+        output.parent.mkdir(parents=True, exist_ok=True)\n+        export_profile(\"default\", str(output))\n+\n+        with tarfile.open(str(output), \"r:gz\") as tf:\n+            names = tf.getnames()\n+\n+        # Config is present\n+        assert \"default/config.yaml\" in names\n+\n+        # Infrastructure excluded\n+        excluded_prefixes = [\n+            \"default/hermes-agent\", \"default/.worktrees\", \"default/profiles\",\n+            \"default/bin\", \"default/image_cache\", \"default/logs\",\n+            \"default/sandboxes\", \"default/checkpoints\",\n+        ]\n+        for prefix in excluded_prefixes:\n+            assert not any(n.startswith(prefix) for n in names), \\\n+                f\"Expected {prefix} to be excluded but found it in archive\"\n+\n+        excluded_files = [\n+            \"default/state.db\", \"default/gateway.pid\",\n+            \"default/gateway_state.json\", \"default/processes.json\",\n+            \"default/errors.log\", \"default/.hermes_history\",\n+            \"default/active_profile\", \"default/.update_check\",\n+            \"default/auth.lock\",\n+        ]\n+        for f in excluded_files:\n+            assert f not in names, f\"Expected {f} to be excluded\"\n+\n+    def test_export_default_excludes_pycache_at_any_depth(self, profile_env, tmp_path):\n+        \"\"\"__pycache__ dirs are excluded even inside nested directories.\"\"\"\n+        default_dir = get_profile_dir(\"default\")\n+        (default_dir / \"config.yaml\").write_text(\"ok\")\n+        nested = default_dir / \"skills\" / \"my-skill\" / \"__pycache__\"\n+        nested.mkdir(parents=True)\n+        (nested / \"cached.pyc\").write_text(\"bytecode\")\n+\n+        output = tmp_path / \"export\" / \"default.tar.gz\"\n+        output.parent.mkdir(parents=True, exist_ok=True)\n+        export_profile(\"default\", str(output))\n+\n+        with tarfile.open(str(output), \"r:gz\") as tf:\n+            names = tf.getnames()\n+\n+        assert not any(\"__pycache__\" in n for n in names)\n+\n+    def test_import_default_without_name_raises(self, profile_env, tmp_path):\n+        \"\"\"Importing a default export without --name gives clear guidance.\"\"\"\n+        default_dir = get_profile_dir(\"default\")\n+        (default_dir / \"config.yaml\").write_text(\"ok\")\n+\n+        archive = tmp_path / \"export\" / \"default.tar.gz\"\n+        archive.parent.mkdir(parents=True, exist_ok=True)\n+        export_profile(\"default\", str(archive))\n+\n+        with pytest.raises(ValueError, match=\"Cannot import as 'default'\"):\n+            import_profile(str(archive))\n+\n+    def test_import_default_with_explicit_default_name_raises(self, profile_env, tmp_path):\n+        \"\"\"Explicitly importing as 'default' is also rejected.\"\"\"\n+        default_dir = get_profile_dir(\"default\")\n+        (default_dir / \"config.yaml\").write_text(\"ok\")\n+\n+        archive = tmp_path / \"export\" / \"default.tar.gz\"\n+        archive.parent.mkdir(parents=True, exist_ok=True)\n+        export_profile(\"default\", str(archive))\n+\n+        with pytest.raises(ValueError, match=\"Cannot import as 'default'\"):\n+            import_profile(str(archive), name=\"default\")\n+\n+    def test_import_default_export_with_new_name_roundtrip(self, profile_env, tmp_path):\n+        \"\"\"Export default → import under a different name → data preserved.\"\"\"\n+        default_dir = get_profile_dir(\"default\")\n+        (default_dir / \"config.yaml\").write_text(\"model: opus\")\n+        mem_dir = default_dir / \"memories\"\n+        mem_dir.mkdir(exist_ok=True)\n+        (mem_dir / \"MEMORY.md\").write_text(\"important fact\")\n+\n+        archive = tmp_path / \"export\" / \"default.tar.gz\"\n+        archive.parent.mkdir(parents=True, exist_ok=True)\n+        export_profile(\"default\", str(archive))\n+\n+        imported = import_profile(str(archive), name=\"backup\")\n+        assert imported.is_dir()\n+        assert (imported / \"config.yaml\").read_text() == \"model: opus\"\n+        assert (imported / \"memories\" / \"MEMORY.md\").read_text() == \"important fact\"\n+\n \n # ===================================================================\n # TestProfileIsolation\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}