{"repo":"NousResearch/hermes-agent","public_id":"20456","display_label":"Profile export, import, and sharing features","summary":"These issues and pull requests request the ability to export, import, back up, and share full user profiles or configurations across instances and computers. Proposed mechanisms include file-based export, git-based distribution, and packaged profile templates.","subsystem":"apps/desktop","counts":{"members":7,"open_issues":3,"open_issues_distinct":3,"open_prs":0,"closed_issues":3,"merged_prs":1,"attempt_prs":0},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":20831},"resolution":{"state":"partially_resolved","by":"pr-20831"},"freshness":{"state":"verified","last_verified_at":"2026-05-08T17:04:32+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.13333333333333333},"fix_edges":[{"issue_number":20456,"pr_number":20831,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":20456,"title":"Feature Request: Profile Distributions (shareable, packaged Hermes profiles)","url":null,"state":"closed"},{"kind":"issue","number":30661,"title":"How can I export a master environment and save it to another computer","url":null,"state":"closed"},{"kind":"issue","number":42779,"title":"Add portable Agent Capsule export/import for skills, workflows, and creation context","url":null,"state":"open"},{"kind":"issue","number":43784,"title":"[Feature]: Shareable Profile Templates","url":null,"state":"closed"},{"kind":"issue","number":49377,"title":"[Feature]: Desktop GUI: Add one-click backup/restore button in Settings","url":null,"state":"open"},{"kind":"issue","number":65224,"title":"[desktop] Profiles panel: add Export and Import for full profile setup (SOUL, skills, etc.) to move between instances","url":null,"state":"open"},{"kind":"pr","number":20831,"title":"feat(profile): shareable profile distributions via git","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","hermes_cli/profile_distribution.py","hermes_cli/profiles.py","tests/hermes_cli/test_profile_distribution.py","tests/hermes_cli/test_profiles.py","website/docs/reference/cli-commands.md","website/docs/reference/profile-commands.md","website/package.json"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -8089,8 +8089,14 @@ def cmd_profile(args):\n             return\n \n         # Header\n-        print(f\"\\n {'Profile':<16} {'Model':<28} {'Gateway':<12} {'Alias'}\")\n-        print(f\" {'─' * 15}    {'─' * 27}    {'─' * 11}    {'─' * 12}\")\n+        print(\n+            f\"\\n {'Profile':<16} {'Model':<28} {'Gateway':<12} \"\n+            f\"{'Alias':<12} {'Distribution'}\"\n+        )\n+        print(\n+            f\" {'─' * 15}    {'─' * 27}    {'─' * 11}    \"\n+            f\"{'─' * 11}    {'─' * 20}\"\n+        )\n \n         for p in profiles:\n             marker = (","@@ -8104,7 +8110,12 @@ def cmd_profile(args):\n             alias = p.name if p.alias_path else \"—\"\n             if p.is_default:\n                 alias = \"—\"\n-            print(f\"{marker}{name:<15} {model:<28} {gw:<12} {alias}\")\n+            if p.distribution_name:\n+                dist = f\"{p.distribution_name}@{p.distribution_version or '?'}\"\n+                dist = dist[:30]\n+            else:\n+                dist = \"—\"\n+            print(f\"{marker}{name:<15} {model:<28} {gw:<12} {alias:<12} {dist}\")\n         print()\n \n     elif action == \"use\":","@@ -8234,6 +8245,7 @@ def cmd_profile(args):\n             _read_config_model,\n             _check_gateway_running,\n             _count_skills,\n+            _read_distribution_meta,\n         )\n \n         if not profile_exists(name):","@@ -8243,6 +8255,7 @@ def cmd_profile(args):\n         model, provider = _read_config_model(profile_dir)\n         gw = _check_gateway_running(profile_dir)\n         skills = _count_skills(profile_dir)\n+        dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir)\n         wrapper = _get_wrapper_dir() / name\n \n         print(f\"\\nProfile: {name}\")","@@ -8257,6 +8270,11 @@ def cmd_profile(args):\n         print(\n             f\"SOUL.md: {'exists' if (profile_dir / 'SOUL.md').exists() else 'not configured'}\"\n         )\n+        if dist_name:\n+            print(f\"Distribution: {dist_name}@{dist_version or '?'}\")\n+            if dist_source:\n+                print(f\"Installed from: {dist_source}\")\n+            print(f\"  (run `hermes profile info {name}` for full manifest)\")\n         if wrapper.exists():\n             print(f\"Alias:   {wrapper}\")\n         print()","@@ -8337,6 +8355,208 @@ def cmd_profile(args):\n             print(f\"Error: {e}\")\n             sys.exit(1)\n \n+    elif action == \"install\":\n+        import tempfile\n+        from hermes_cli.profile_distribution import (\n+            plan_install,\n+            install_distribution,\n+            DistributionError,\n+        )\n+\n+        try:\n+            # Preview: stage the distribution into a scratch dir, show the\n+            # manifest, then do the real install.  The double-stage avoids\n+            # any side-effects if the user declines.\n+            with tempfile.TemporaryDirectory(prefix=\"hermes_dist_preview_\") as tmp:\n+                plan = plan_install(\n+                    args.source,\n+                    Path(tmp),\n+                    override_name=getattr(args, \"install_name\", None),\n+                )\n+                _render_distribution_plan(plan)\n+\n+                if not getattr(args, \"yes\", False):\n+                    try:\n+                        answer = input(\"\\nProceed with install? [y/N] \").strip().lower()\n+                    except (EOFError, KeyboardInterrupt):\n+                        answer = \"\"\n+                    if answer not in (\"y\", \"yes\"):\n+                        print(\"Install cancelled.\")\n+                        return\n+\n+            plan = install_distribution(\n+                args.source,\n+                name=getattr(args, \"install_name\", None),\n+                force=getattr(args, \"force\", False),\n+                create_alias=getattr(args, \"alias\", False),\n+            )\n+            print(f\"\\n✓ Installed '{plan.manifest.name}' v{plan.manifest.version}\")\n+            print(f\"  Profile path: {plan.target_dir}\")\n+            if plan.manifest.env_requires:\n+                print(\n+                    f\"  Next: copy .env.EXAMPLE to .env and fill in required keys:\\n\"\n+                    f\"    {plan.target_dir}/.env.EXAMPLE\"\n+                )\n+            if plan.has_cron:\n+                print(\n+                    \"  Cron jobs were included but are NOT scheduled automatically.\\n\"\n+                    f\"  Review them with:  hermes -p {plan.manifest.name} cron list\"\n+                )\n+            print(f\"\\n  Use with:      hermes -p {plan.manifest.name} chat\")\n+        except (DistributionError, ValueError) as e:\n+            print(f\"Error: {e}\")\n+            sys.exit(1)\n+\n+    elif action == \"update\":\n+        from hermes_cli.profile_distribution import (\n+            update_distribution,\n+            read_manifest,\n+            DistributionError,\n+        )\n+        from hermes_cli.profiles import get_profile_dir, normalize_profile_name\n+\n+        name = args.profile_name\n+        try:\n+            canon = normalize_profile_name(name)\n+            current = read_manifest(get_profile_dir(canon))\n+            if current is None:\n+                print(\n+                    f\"Error: Profile '{canon}' is not a distribution (no distribution.yaml). \"\n+                    \"Only profiles installed via `hermes profile install` can be updated.\"\n+                )\n+                sys.exit(1)\n+\n+            force_config = getattr(args, \"force_config\", False)\n+            if not getattr(args, \"yes\", False):\n+                print(f\"\\nUpdate '{canon}' from: {current.source or '(no source)'}\")\n+                print(f\"  Currently at version {current.version}\")\n+                if force_config:\n+                    print(\"  --force-config set: config.yaml WILL be overwritten.\")\n+                else:\n+                    print(\"  config.yaml will be preserved (pass --force-config to overwrite).\")\n+                print(\"  User data (memories, sessions, auth, .env) will NOT be touched.\")\n+                try:\n+                    answer = input(\"\\nProceed? [y/N] \").strip().lower()\n+                except (EOFError, KeyboardInterrupt):\n+                    answer = \"\"\n+                if answer not in (\"y\", \"yes\"):\n+                    print(\"Update cancelled.\")\n+                    return\n+\n+            plan = update_distribution(canon, force_config=force_config)\n+            print(f\"\\n✓ Updated '{plan.manifest.name}' → v{plan.manifest.version}\")\n+            if plan.has_cron:\n+                print(\n+                    \"  Cron files were refreshed.  Review with:  \"\n+                    f\"hermes -p {plan.manifest.name} cron list\"\n+                )\n+        except (DistributionError, ValueError) as e:\n+            print(f\"Error: {e}\")\n+            sys.exit(1)\n+\n+    elif action == \"info\":\n+        from hermes_cli.profile_distribution import describe_distribution, DistributionError\n+\n+        try:\n+            data = describe_distribution(args.profile_name)\n+        except (DistributionError, ValueError) as e:\n+            print(f\"Error: {e}\")\n+            sys.exit(1)\n+        if not data:\n+            print(\n+                f\"Profile '{args.profile_name}' is not a distribution \"\n+                \"(no distribution.yaml).\"\n+            )\n+            return\n+        print(f\"\\nDistribution: {data.get('name')}\")\n+        print(f\"Version:      {data.get('version', '?')}\")\n+        if data.get(\"description\"):\n+            print(f\"Description:  {data['description']}\")\n+        if data.get(\"author\"):\n+            print(f\"Author:       {data['author']}\")\n+        if data.get(\"license\"):\n+            print(f\"License:      {data['license']}\")\n+        if data.get(\"hermes_requires\"):\n+            print(f\"Requires:     Hermes {data['hermes_requires']}\")\n+        if data.get(\"source\"):\n+            print(f\"Source:       {data['source']}\")\n+        if data.get(\"installed_at\"):\n+            print(f\"Installed:    {data['installed_at']}\")\n+        env_reqs = data.get(\"env_requires\") or []\n+        if env_reqs:\n+            print(\"\\nEnvironment variables:\")\n+            for er in env_reqs:\n+                tag = \"required\" if er.get(\"required\", True) else \"optional\"\n+                line = f\"  {er['name']} ({tag})\"\n+                if er.get(\"description\"):\n+                    line += f\" — {er['description']}\"\n+                print(line)\n+                if er.get(\"default\") is not None:\n+                    print(f\"      default: {er['default']}\")\n+        print()\n+\n+\n+def _render_distribution_plan(plan) -> None:\n+    \"\"\"Print a human-readable summary of a pending distribution install.\"\"\"\n+    from hermes_cli.profile_distribution import MANIFEST_FILENAME\n+    mf = plan.manifest\n+    print(f\"\\nDistribution: {mf.name} v{mf.version}\")\n+    if mf.description:\n+        print(f\"  {mf.description}\")\n+    if mf.author:\n+        print(f\"  Author:   {mf.author}\")\n+    if mf.hermes_requires:\n+        print(f\"  Requires: Hermes {mf.hermes_requires}\")\n+    print(f\"  Source:   {plan.provenance}\")\n+    print(f\"  Target:   {plan.target_dir}\")\n+    if plan.existing:\n+        # Distinguish \"updating an existing distribution\" (well-understood\n+        # semantics — dist-owned overwritten, config preserved, user data\n+        # untouched) from \"overwriting a hand-built plain profile\" (same\n+        # mechanics but the user didn't sign up for this when they created\n+        # the profile manually).\n+        existing_is_distribution = (plan.target_dir / MANIFEST_FILENAME).is_file()\n+        if existing_is_distribution:\n+            print(\"  (profile exists — will overwrite distribution-owned files only)\")\n+        else:\n+            print(\n+                \"  ⚠ Profile exists but is NOT a distribution.  Installing here will\\n\"\n+                \"    overwrite its SOUL.md, skills/, cron/, and mcp.json.\\n\"\n+                \"    Your memories, sessions, auth.json, and .env will be preserved,\\n\"\n+                \"    but any hand-edits to distribution-owned files will be lost.\"\n+            )\n+    if mf.env_requires:\n+        print(\"\\n  Env vars:\")\n+        for er in mf.env_requires:\n+            tag = \"required\" if er.required else \"optional\"\n+            # Check both the current shell environment and the target profile's\n+            # .env file so we don't nag about keys the user already has set up.\n+            already = os.environ.get(er.name) is not None\n+            if not already and plan.target_dir.is_dir():\n+                env_path = plan.target_dir / \".env\"\n+                if env_path.is_file():\n+                    try:\n+                        for raw in env_path.read_text().splitlines():\n+                            line = raw.strip()\n+                            if not line or line.startswith(\"#\"):\n+                                continue\n+                            key = line.split(\"=\", 1)[0].strip()\n+                            if key == er.name:\n+                                already = True\n+                                break\n+                    except OSError:\n+                        pass\n+            status = \"✓ set\" if already else (\"needs setting\" if er.required else \"—\")\n+            line = f\"    • {er.name} ({tag}, {status})\"\n+            if er.description:\n+                line += f\" — {er.description}\"\n+            print(line)\n+    if plan.has_cron:\n+        print(\n+            \"\\n  ⚠ This distribution ships cron jobs.  They will NOT run \"\n+            \"automatically — review and enable manually.\"\n+        )\n+\n \n def _report_dashboard_status() -> int:\n     \"\"\"Print ``hermes dashboard`` PIDs and return the count.","@@ -10570,6 +10790,63 @@ def cmd_acp(args):\n         help=\"Profile name (default: inferred from archive)\",\n     )\n \n+    # ---------- Distribution subcommands (issue #20456) ----------\n+    profile_install = profile_subparsers.add_parser(\n+        \"install\",\n+        help=\"Install a profile distribution from a git URL or local directory\",\n+        description=(\n+            \"Install a Hermes profile distribution. SOURCE can be a git URL \"\n+            \"(github.com/user/repo, https://..., git@...) or a local \"\n+            \"directory containing distribution.yaml at its root.\"\n+        ),\n+    )\n+    profile_install.add_argument(\n+        \"source\",\n+        help=\"Distribution source (git URL or local directory)\",\n+    )\n+    profile_install.add_argument(\n+        \"--name\", dest=\"install_name\", metavar=\"NAME\",\n+        help=\"Override profile name (default: read from manifest)\",\n+    )\n+    profile_install.add_argument(\n+        \"--alias\", action=\"store_true\",\n+        help=\"Create a shell wrapper alias for the installed profile\",\n+    )\n+    profile_install.add_argument(\n+        \"--force\", action=\"store_true\",\n+        help=\"Overwrite an existing profile of the same name (user data preserved)\",\n+    )\n+    profile_install.add_argument(\n+        \"-y\", \"--yes\", action=\"store_true\",\n+        help=\"Skip manifest preview confirmation\",\n+    )\n+\n+    profile_update = profile_subparsers.add_parser(\n+        \"update\",\n+        help=\"Re-pull a distribution and apply updates (user data preserved)\",\n+        description=(\n+            \"Fetch the distribution from its recorded source and overwrite \"\n+            \"distribution-owned files (SOUL.md, skills/, cron/, mcp.json). \"\n+            \"User data (memories, sessions, auth, .env) is never touched. \"\n+            \"config.yaml is preserved unless --force-config is passed.\"\n+        ),\n+    )\n+    profile_update.add_argument(\"profile_name\", help=\"Profile to update\")\n+    profile_update.add_argument(\n+        \"--force-config\", action=\"store_true\",\n+        help=\"Also overwrite config.yaml (normally preserved to keep user overrides)\",\n+    )\n+    profile_update.add_argument(\n+        \"-y\", \"--yes\", action=\"store_true\",\n+        help=\"Skip confirmation\",\n+    )\n+\n+    profile_info = profile_subparsers.add_parser(\n+        \"info\",\n+        help=\"Show a profile's distribution manifest (version, requirements, source)\",\n+    )\n+    profile_info.add_argument(\"profile_name\", help=\"Profile to inspect\")\n+\n     profile_parser.set_defaults(func=cmd_profile)\n \n     # ========================================================================="]},{"file":"hermes_cli/profile_distribution.py","hunks":["@@ -0,0 +1,702 @@\n+\"\"\"Profile distributions — shareable, packaged Hermes profiles via git.\n+\n+A distribution is a Hermes profile published as a git repository (or\n+installed from a local directory for development). Install with one command\n+from a git URL, update in place, and keep your local memories / sessions /\n+credentials untouched.\n+\n+Where this fits relative to the existing pieces:\n+\n+* ``hermes profile export/import`` — local backup / restore for a profile\n+  on your own machine. NOT a distribution format. Stays as-is.\n+* ``hermes skills install <url>`` — the URL install pattern we're mirroring,\n+  but at the profile granularity.\n+\n+Subcommands (all live under ``hermes profile``, not a parallel tree):\n+\n+    hermes profile install <source> [--name N] [--alias] [--force] [--yes]\n+    hermes profile update  <name>  [--force-config] [--yes]\n+    hermes profile info    <name>\n+\n+``<source>`` is one of:\n+\n+* A git URL (``github.com/user/repo``, ``https://github.com/...``, ``git@...``,\n+  ``ssh://``, ``git://``), optionally with ``#<ref>`` to pin a tag / branch /\n+  commit SHA.\n+* A local directory that already contains ``distribution.yaml`` — used\n+  during profile development before the first push.\n+\n+Manifest format (``distribution.yaml`` at the profile root)::\n+\n+    name: telemetry\n+    version: 0.1.0\n+    description: \"Compliance monitoring harness\"\n+    hermes_requires: \">=0.12.0\"\n+    author: \"...\"\n+    license: \"...\"\n+    env_requires:\n+      - name: OPENAI_API_KEY\n+        description: \"OpenAI API key\"\n+        required: true\n+      - name: GRAPHITI_MCP_URL\n+        description: \"Memory graph URL\"\n+        required: false\n+        default: \"http://127.0.0.1:8000/sse\"\n+    distribution_owned:      # optional; sensible defaults apply\n+      - SOUL.md\n+      - skills/\n+      - cron/\n+      - mcp.json\n+\n+Update semantics:\n+\n+* Distribution-owned paths (SOUL.md, mcp.json, skills/, cron/,\n+  distribution.yaml) are replaced from the new source.\n+* ``config.yaml`` is distribution-owned but preserved on update unless\n+  ``--force-config`` is passed (user overrides typically live here).\n+* User-owned paths (memories/, sessions/, state.db, auth.json, .env,\n+  logs/, workspace/, home/, plans/, *_cache/, and anything under\n+  ``local/``) are never touched.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import re\n+import shutil\n+import subprocess\n+import tempfile\n+from dataclasses import dataclass, field\n+from datetime import datetime, timezone\n+from pathlib import Path\n+from typing import Any, Dict, List, Optional, Tuple\n+\n+\n+# ---------------------------------------------------------------------------\n+# Constants\n+# ---------------------------------------------------------------------------\n+\n+MANIFEST_FILENAME = \"distribution.yaml\"\n+ENV_TEMPLATE_FILENAME = \".env.template\"\n+ENV_EXAMPLE_FILENAME = \".env.EXAMPLE\"\n+\n+# Default distribution-owned paths (relative to profile root).  Authors may\n+# override via ``distribution_owned:`` in the manifest.  config.yaml is\n+# distribution-owned but treated specially on update (see _is_config_like).\n+DEFAULT_DIST_OWNED: Tuple[str, ...] = (\n+    \"SOUL.md\",\n+    \"config.yaml\",\n+    \"mcp.json\",\n+    \"skills\",\n+    \"cron\",\n+    MANIFEST_FILENAME,\n+)\n+\n+# Paths that are NEVER part of a distribution. These are user-owned and are\n+# protected on update. Must stay consistent with\n+# ``profiles.py::_DEFAULT_EXPORT_EXCLUDE_ROOT`` plus the ``local/``\n+# convention for user customizations.\n+USER_OWNED_EXCLUDE: frozenset = frozenset({\n+    # Credentials & runtime secrets\n+    \"auth.json\", \".env\",\n+    # Databases & runtime state\n+    \"state.db\", \"state.db-shm\", \"state.db-wal\",\n+    \"hermes_state.db\", \"response_store.db\",\n+    \"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\", \".hermes_history\",\n+    # User data\n+    \"memories\", \"sessions\", \"logs\", \"plans\", \"workspace\", \"home\",\n+    \"image_cache\", \"audio_cache\", \"document_cache\",\n+    \"browser_screenshots\", \"checkpoints\", \"sandboxes\",\n+    \"backups\", \"cache\",\n+    # Infrastructure\n+    \"hermes-agent\", \".worktrees\", \"profiles\", \"bin\", \"node_modules\",\n+    # User customization namespace\n+    \"local\",\n+})\n+\n+\n+# ---------------------------------------------------------------------------\n+# Errors\n+# ---------------------------------------------------------------------------\n+\n+\n+class DistributionError(Exception):\n+    \"\"\"Raised for distribution install/update failures.\"\"\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Manifest\n+# ---------------------------------------------------------------------------\n+\n+\n+@dataclass\n+class EnvRequirement:\n+    name: str\n+    description: str = \"\"\n+    required: bool = True\n+    default: Optional[str] = None\n+\n+    @classmethod\n+    def from_dict(cls, data: Any) -> \"EnvRequirement\":\n+        if not isinstance(data, dict):\n+            raise DistributionError(\n+                f\"env_requires entry must be a mapping, got {type(data).__name__}\"\n+            )\n+        name = str(data.get(\"name\") or \"\").strip()\n+        if not name:\n+            raise DistributionError(\"env_requires entry missing 'name'\")\n+        return cls(\n+            name=name,\n+            description=str(data.get(\"description\") or \"\"),\n+            required=bool(data.get(\"required\", True)),\n+            default=data.get(\"default\"),\n+        )\n+\n+    def to_dict(self) -> Dict[str, Any]:\n+        out: Dict[str, Any] = {\"name\": self.name, \"description\": self.description}\n+        if not self.required:\n+            out[\"required\"] = False\n+        if self.default is not None:\n+            out[\"default\"] = self.default\n+        return out\n+\n+\n+@dataclass\n+class DistributionManifest:\n+    name: str\n+    version: str = \"0.1.0\"\n+    description: str = \"\"\n+    hermes_requires: str = \"\"\n+    author: str = \"\"\n+    license: str = \"\"\n+    env_requires: List[EnvRequirement] = field(default_factory=list)\n+    distribution_owned: List[str] = field(default_factory=list)\n+    # Tracked after install — where we pulled from, so ``update`` can re-pull.\n+    source: str = \"\"\n+    # ISO-8601 UTC timestamp written on install / update, so ``info`` and\n+    # ``list`` can show when a distribution landed on disk.  Empty for\n+    # manifests that ship in a repo (authors don't populate this).\n+    installed_at: str = \"\"\n+\n+    @classmethod\n+    def from_dict(cls, data: Any) -> \"DistributionManifest\":\n+        if not isinstance(data, dict):\n+            raise DistributionError(\n+                f\"{MANIFEST_FILENAME} must be a mapping, got {type(data).__name__}\"\n+            )\n+        name = str(data.get(\"name\") or \"\").strip()\n+        if not name:\n+            raise DistributionError(f\"{MANIFEST_FILENAME} missing 'name'\")\n+        env_raw = data.get(\"env_requires\") or []\n+        if not isinstance(env_raw, list):\n+            raise DistributionError(\"env_requires must be a list\")\n+        env_requires = [EnvRequirement.from_dict(e) for e in env_raw]\n+        dist_owned_raw = data.get(\"distribution_owned\") or []\n+        if dist_owned_raw and not isinstance(dist_owned_raw, list):\n+            raise DistributionError(\"distribution_owned must be a list\")\n+        distribution_owned = [str(p).strip().strip(\"/\") for p in dist_owned_raw if str(p).strip()]\n+        return cls(\n+            name=name,\n+            version=str(data.get(\"version\") or \"0.1.0\"),\n+            description=str(data.get(\"description\") or \"\"),\n+            hermes_requires=str(data.get(\"hermes_requires\") or \"\"),\n+            author=str(data.get(\"author\") or \"\"),\n+            license=str(data.get(\"license\") or \"\"),\n+            env_requires=env_requires,\n+            distribution_owned=distribution_owned,\n+            source=str(data.get(\"source\") or \"\"),\n+            installed_at=str(data.get(\"installed_at\") or \"\"),\n+        )\n+\n+    def to_dict(self) -> Dict[str, Any]:\n+        out: Dict[str, Any] = {\n+            \"name\": self.name,\n+            \"version\": self.version,\n+        }\n+        if self.description:\n+            out[\"description\"] = self.description\n+        if self.hermes_requires:\n+            out[\"hermes_requires\"] = self.hermes_requires\n+        if self.author:\n+            out[\"author\"] = self.author\n+        if self.license:\n+            out[\"license\"] = self.license\n+        if self.env_requires:\n+            out[\"env_requires\"] = [e.to_dict() for e in self.env_requires]\n+        if self.distribution_owned:\n+            out[\"distribution_owned\"] = self.distribution_owned\n+        if self.source:\n+            out[\"source\"] = self.source\n+        if self.installed_at:\n+            out[\"installed_at\"] = self.installed_at\n+        return out\n+\n+    def owned_paths(self) -> List[str]:\n+        \"\"\"Resolve which paths count as distribution-owned.\"\"\"\n+        if self.distribution_owned:\n+            return list(self.distribution_owned)\n+        return list(DEFAULT_DIST_OWNED)\n+\n+\n+def _load_yaml(text: str) -> Any:\n+    try:\n+        import yaml\n+    except ImportError as exc:  # pragma: no cover — pyyaml is a hard dep\n+        raise DistributionError(\"PyYAML is required for distribution manifests\") from exc\n+    return yaml.safe_load(text)\n+\n+\n+def _dump_yaml(data: Any) -> str:\n+    import yaml\n+\n+    return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)\n+\n+\n+def read_manifest(profile_dir: Path) -> Optional[DistributionManifest]:\n+    \"\"\"Return the manifest for *profile_dir*, or None if it isn't a distribution.\"\"\"\n+    mf_path = profile_dir / MANIFEST_FILENAME\n+    if not mf_path.is_file():\n+        return None\n+    try:\n+        data = _load_yaml(mf_path.read_text(encoding=\"utf-8\"))\n+    except Exception as exc:\n+        raise DistributionError(f\"Failed to parse {mf_path}: {exc}\") from exc\n+    return DistributionManifest.from_dict(data or {})\n+\n+\n+def write_manifest(profile_dir: Path, manifest: DistributionManifest) -> Path:\n+    mf_path = profile_dir / MANIFEST_FILENAME\n+    mf_path.write_text(_dump_yaml(manifest.to_dict()), encoding=\"utf-8\")\n+    return mf_path\n+\n+\n+# ---------------------------------------------------------------------------\n+# Version check\n+# ---------------------------------------------------------------------------\n+\n+\n+_VERSION_OP_RE = re.compile(r\"^\\s*(>=|<=|==|!=|>|<)\\s*(.+?)\\s*$\")\n+\n+\n+def _parse_semver(v: str) -> Tuple[int, int, int]:\n+    \"\"\"Very small semver parser — major.minor.patch only.  Extra labels stripped.\"\"\"\n+    s = str(v).strip().lstrip(\"v\")\n+    # Strip any pre-release / build metadata (e.g. \"0.12.0-rc1+abc\")\n+    s = re.split(r\"[-+]\", s, 1)[0]\n+    parts = s.split(\".\")\n+    while len(parts) < 3:\n+        parts.append(\"0\")\n+    try:\n+        return (int(parts[0]), int(parts[1]), int(parts[2]))\n+    except ValueError as exc:\n+        raise DistributionError(f\"Unparseable version: {v!r}\") from exc\n+\n+\n+def check_hermes_requires(spec: str, current_version: str) -> None:\n+    \"\"\"Raise DistributionError if ``current_version`` does not satisfy ``spec``.\n+\n+    ``spec`` accepts a single comparator (``>=0.12.0``, ``==0.12.0``, etc.).\n+    Empty or blank spec is a no-op — no requirement.\n+    \"\"\"\n+    if not spec or not spec.strip():\n+        return\n+    m = _VERSION_OP_RE.match(spec)\n+    if not m:\n+        # Bare version → treat as ``>=``\n+        op, target = \">=\", spec.strip()\n+    else:\n+        op, target = m.group(1), m.group(2)\n+    cur = _parse_semver(current_version)\n+    tgt = _parse_semver(target)\n+    ok = {\n+        \">=\": cur >= tgt,\n+        \"<=\": cur <= tgt,\n+        \"==\": cur == tgt,\n+        \"!=\": cur != tgt,\n+        \">\":  cur > tgt,\n+        \"<\":  cur < tgt,\n+    }[op]\n+    if not ok:\n+        raise DistributionError(\n+            f\"This distribution requires Hermes {op}{target}, \"\n+            f\"but you have {current_version}.\"\n+        )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Env var template helper\n+# ---------------------------------------------------------------------------\n+\n+\n+def _env_template_from_manifest(manifest: DistributionManifest) -> str:\n+    \"\"\"Generate a ``.env.template`` body from env_requires.\"\"\"\n+    lines = [\n+        \"# Environment variables required by this Hermes distribution.\",\n+        \"# Copy to `.env` and fill in your own values before running.\",\n+        \"\",\n+    ]\n+    for req in manifest.env_requires:\n+        if req.description:\n+            lines.append(f\"# {req.description}\")\n+        status = \"required\" if req.required else \"optional\"\n+        lines.append(f\"# ({status})\")\n+        default_val = req.default if req.default is not None else \"\"\n+        prefix = \"\" if req.required else \"# \"\n+        lines.append(f\"{prefix}{req.name}={default_val}\")\n+        lines.append(\"\")\n+    return \"\\n\".join(lines).rstrip() + \"\\n\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Source staging — git clone or local directory\n+# ---------------------------------------------------------------------------\n+\n+\n+def _looks_like_git_url(s: str) -> bool:\n+    s = s.strip()\n+    if s.endswith(\".git\"):\n+        return True\n+    if s.startswith((\"git@\", \"ssh://\", \"git://\")):\n+        return True\n+    if s.startswith((\"http://\", \"https://\")):\n+        # Any http(s) URL is treated as a git repo.  We no longer accept\n+        # tar.gz URLs — git is the only remote transport.\n+        return True\n+    # Bare github.com/user/repo shorthand\n+    if re.match(r\"^github\\.com/[\\w.-]+/[\\w.-]+/?$\", s):\n+        return True\n+    return False\n+\n+\n+def _git_clone(url: str, dest: Path) -> None:\n+    # Normalize github.com/user/repo shorthand\n+    if re.match(r\"^github\\.com/[\\w.-]+/[\\w.-]+/?$\", url):\n+        url = f\"https://{url.rstrip('/')}\"\n+    try:\n+        subprocess.run(\n+            [\"git\", \"clone\", \"--depth\", \"1\", url, str(dest)],\n+            check=True,\n+            capture_output=True,\n+        )\n+    except FileNotFoundError as exc:\n+        raise DistributionError(\"git is required for git-URL installs\") from exc\n+    except subprocess.CalledProcessError as exc:\n+        stderr = exc.stderr.decode(\"utf-8\", errors=\"replace\") if exc.stderr else \"\"\n+        raise DistributionError(f\"git clone failed: {stderr.strip()}\") from exc\n+\n+\n+def _stage_source(source: str, workdir: Path) -> Tuple[Path, str]:\n+    \"\"\"Resolve *source* to a local directory containing distribution.yaml.\n+\n+    Returns ``(staged_dir, provenance)`` where ``provenance`` is stored in the\n+    installed manifest's ``source:`` field so ``hermes profile update`` can\n+    re-pull from the same place.\n+\n+    Accepts:\n+      * A git URL (https / ssh / git@ / bare github.com shorthand) — cloned\n+        into a temp directory; ``.git`` removed after clone.\n+      * A local directory already containing ``distribution.yaml``.\n+    \"\"\"\n+    src_str = source.strip()\n+\n+    # Git URL\n+    if _looks_like_git_url(src_str):\n+        cloned = workdir / \"clone\"\n+        _git_clone(src_str, cloned)\n+        # Remove .git to keep the staged tree clean\n+        shutil.rmtree(cloned / \".git\", ignore_errors=True)\n+        if not (cloned / MANIFEST_FILENAME).is_file():\n+            raise DistributionError(\n+                f\"No {MANIFEST_FILENAME} at the root of {src_str!r}. \"\n+                \"This repository is not a Hermes profile distribution.\"\n+            )\n+        return cloned, src_str\n+\n+    # Local directory\n+    path_guess = Path(src_str).expanduser()\n+    if path_guess.is_dir():\n+        if not (path_guess / MANIFEST_FILENAME).is_file():\n+            raise DistributionError(\n+                f\"No {MANIFEST_FILENAME} in {path_guess}. \"\n+                \"A local-directory source must contain a distribution.yaml at its root.\"\n+            )\n+        return path_guess.resolve(), str(path_guess.resolve())\n+\n+    raise DistributionError(\n+        f\"Cannot resolve distribution source: {source!r}. \"\n+        \"Expected a git URL (e.g. github.com/user/repo) or a local directory.\"\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Install\n+# ---------------------------------------------------------------------------\n+\n+\n+@dataclass\n+class InstallPlan:\n+    \"\"\"Summary of what an install will do, surfaced for user confirmation.\"\"\"\n+    manifest: DistributionManifest\n+    staged_dir: Path\n+    provenance: str\n+    target_dir: Path\n+    existing: bool  # True if target profile already exists (update path)\n+    preserves_config: bool = True\n+    has_cron: bool = False\n+    has_skills: bool = False\n+\n+\n+def _has_cron_jobs(staged: Path) -> bool:\n+    cron_dir = staged / \"cron\"\n+    if not cron_dir.is_dir():\n+        return False\n+    for _ in cron_dir.rglob(\"*.json\"):\n+        return True\n+    for _ in cron_dir.rglob(\"*.yaml\"):\n+        return True\n+    return False\n+\n+\n+def _count_skills(staged: Path) -> int:\n+    skills_dir = staged / \"skills\"\n+    if not skills_dir.is_dir():\n+        return 0\n+    return sum(1 for _ in skills_dir.rglob(\"SKILL.md\"))\n+\n+\n+def plan_install(\n+    source: str,\n+    workdir: Path,\n+    override_name: Optional[str] = None,\n+) -> InstallPlan:\n+    \"\"\"Stage *source* and produce a plan describing what install would do.\"\"\"\n+    from hermes_cli.profiles import (\n+        get_profile_dir,\n+        normalize_profile_name,\n+        validate_profile_name,\n+    )\n+    from hermes_cli import __version__ as hermes_version\n+\n+    staged, provenance = _stage_source(source, workdir)\n+    manifest = read_manifest(staged)\n+    if manifest is None:\n+        raise DistributionError(\n+            f\"No {MANIFEST_FILENAME} found at the distribution root — \"\n+            \"this source is not a Hermes distribution.\"\n+        )\n+\n+    # Version check up-front so we fail fast\n+    check_hermes_requires(manifest.hermes_requires, hermes_version)\n+\n+    # Resolve target profile name\n+    target_name = override_name or manifest.name\n+    canon = normalize_profile_name(target_name)\n+    validate_profile_name(canon)\n+    if canon == \"default\":\n+        raise DistributionError(\n+            \"Cannot install a distribution as 'default' — that is the built-in \"\n+            \"root profile (~/.hermes).  Pass --name <name> to install under a \"\n+            \"new profile.\"\n+        )\n+    manifest.name = canon\n+    manifest.source = provenance\n+    # Stamped once here so plan_install() callers (both fresh install and\n+    # update) propagate a freshly-minted timestamp through _copy_dist_payload.\n+    manifest.installed_at = datetime.now(timezone.utc).isoformat(timespec=\"seconds\")\n+\n+    target_dir = get_profile_dir(canon)\n+    existing = target_dir.is_dir()\n+    has_cron = _has_cron_jobs(staged)\n+    skill_count = _count_skills(staged)\n+\n+    return InstallPlan(\n+        manifest=manifest,\n+        staged_dir=staged,\n+        provenance=provenance,\n+        target_dir=target_dir,\n+        existing=existing,\n+        preserves_config=existing,\n+        has_cron=has_cron,\n+        has_skills=skill_count > 0,\n+    )\n+\n+\n+def _copy_dist_payload(\n+    staged: Path,\n+    target: Path,\n+    manifest: DistributionManifest,\n+    preserve_config: bool,\n+) -> None:\n+    \"\"\"Copy distribution-owned files from *staged* into *target*.\n+\n+    User-owned paths are never touched.  ``config.yaml`` is replaced only when\n+    ``preserve_config`` is False (fresh install or ``--force-config`` update).\n+    ``.env.template`` is renamed to ``.env.EXAMPLE`` in the target to avoid\n+    shadowing a real ``.env``.\n+    \"\"\"\n+    target.mkdir(parents=True, exist_ok=True)\n+\n+    for entry in staged.iterdir():\n+        name = entry.name\n+\n+        if name in USER_OWNED_EXCLUDE:\n+            continue\n+        if name == ENV_TEMPLATE_FILENAME:\n+            shutil.copy2(entry, target / ENV_EXAMPLE_FILENAME)\n+            continue\n+        if name == \"config.yaml\" and preserve_config and (target / \"config.yaml\").exists():\n+            # Leave user's config.yaml alone on update\n+            continue\n+\n+        dest = target / name\n+        if entry.is_dir():\n+            if dest.exists():\n+                shutil.rmtree(dest)\n+            shutil.copytree(\n+                entry,\n+                dest,\n+                ignore=lambda d, names: [n for n in names if n in USER_OWNED_EXCLUDE],\n+            )\n+        else:\n+            shutil.copy2(entry, dest)\n+\n+    # Emit .env.EXAMPLE from manifest if the staged tree didn't ship one\n+    if manifest.env_requires and not (target / ENV_EXAMPLE_FILENAME).exists():\n+        (target / ENV_EXAMPLE_FILENAME).write_text(\n+            _env_template_from_manifest(manifest), encoding=\"utf-8\"\n+        )\n+\n+    # Make sure the manifest on disk reflects resolved name + source\n+    write_manifest(target, manifest)\n+\n+\n+def _bootstrap_user_dirs(target: Path) -> None:\n+    \"\"\"Create the bootstrap dirs a fresh profile expects.\"\"\"\n+    for d in (\"memories\", \"sessions\", \"skills\", \"skins\", \"logs\",\n+              \"plans\", \"workspace\", \"cron\", \"home\"):\n+        (target / d).mkdir(parents=True, exist_ok=True)\n+\n+\n+def install_distribution(\n+    source: str,\n+    name: Optional[str] = None,\n+    force: bool = False,\n+    create_alias: bool = False,\n+) -> InstallPlan:\n+    \"\"\"Install a distribution from *source* into a new profile.\n+\n+    Returns the resolved :class:`InstallPlan`.  Use :func:`plan_install`\n+    first if you want to preview + prompt the user before calling this.\n+    \"\"\"\n+    from hermes_cli.profiles import (\n+        check_alias_collision,\n+        create_wrapper_script,\n+    )\n+\n+    with tempfile.TemporaryDirectory(prefix=\"hermes_dist_install_\") as tmp:\n+        plan = plan_install(source, Path(tmp), override_name=name)\n+\n+        if plan.existing and not force:\n+            raise DistributionError(\n+                f\"Profile '{plan.manifest.name}' already exists at {plan.target_dir}. \"\n+                \"Use `hermes profile update` to upgrade in place, \"\n+                \"or pass --force to overwrite.\"\n+            )\n+\n+        # Fresh install: config.yaml comes from the distribution.\n+        _bootstrap_user_dirs(plan.target_dir)\n+        _copy_dist_payload(\n+            plan.staged_dir,\n+            plan.target_dir,\n+            plan.manifest,\n+            preserve_config=False,\n+        )\n+\n+        if create_alias:\n+            collision = check_alias_collision(plan.manifest.name)\n+            if collision is None:\n+                create_wrapper_script(plan.manifest.name)\n+\n+        return plan\n+\n+\n+def update_distribution(\n+    profile_name: str,\n+    force_config: bool = False,\n+) -> InstallPlan:\n+    \"\"\"Re-pull the distribution for an existing profile and apply updates.\n+\n+    The source is read from the installed profile's ``distribution.yaml``\n+    ``source:`` field.  Distribution-owned files are overwritten; user-owned\n+    data (memories, sessions, auth) is never touched.  ``config.yaml`` is\n+    preserved unless ``force_config`` is True.\n+    \"\"\"\n+    from hermes_cli.profiles import (\n+        get_profile_dir,\n+        normalize_profile_name,\n+        validate_profile_name,\n+    )\n+\n+    canon = normalize_profile_name(profile_name)\n+    validate_profile_name(canon)\n+    target = get_profile_dir(canon)\n+    if not target.is_dir():\n+        raise DistributionError(f\"Profile '{canon}' does not exist.\")\n+\n+    existing_manifest = read_manifest(target)\n+    if existing_manifest is None:\n+        raise DistributionError(\n+            f\"Profile '{canon}' is not a distribution (no {MANIFEST_FILENAME}). \"\n+            \"Only profiles installed via `hermes profile install` can be updated.\"\n+        )\n+    if not existing_manifest.source:\n+        raise DistributionError(\n+            f\"Profile '{canon}' has no recorded source.  Re-install with \"\n+            \"`hermes profile install <source> --name {canon} --force`.\"\n+        )\n+\n+    with tempfile.TemporaryDirectory(prefix=\"hermes_dist_update_\") as tmp:\n+        plan = plan_install(\n+            existing_manifest.source,\n+            Path(tmp),\n+            override_name=canon,\n+        )\n+        plan.preserves_config = not force_config\n+\n+        _copy_dist_payload(\n+            plan.staged_dir,\n+            plan.target_dir,\n+            plan.manifest,\n+            preserve_config=plan.preserves_config,\n+        )\n+        return plan\n+\n+\n+# ---------------------------------------------------------------------------\n+# Info — render a manifest summary\n+# ---------------------------------------------------------------------------\n+\n+\n+def describe_distribution(profile_name: str) -> Dict[str, Any]:\n+    \"\"\"Return a structured view of a profile's distribution metadata.\n+\n+    Returns an empty dict if the profile exists but has no manifest.\n+    Raises DistributionError if the profile itself doesn't exist.\n+    \"\"\"\n+    from hermes_cli.profiles import (\n+        get_profile_dir,\n+        normalize_profile_name,\n+        validate_profile_name,\n+    )\n+\n+    canon = normalize_profile_name(profile_name)\n+    validate_profile_name(canon)\n+    target = get_profile_dir(canon)\n+    if not target.is_dir():\n+        raise DistributionError(f\"Profile '{canon}' does not exist.\")\n+    manifest = read_manifest(target)\n+    if manifest is None:\n+        return {}\n+    return manifest.to_dict()"]},{"file":"hermes_cli/profiles.py","hunks":["@@ -205,6 +205,12 @@ def validate_profile_name(name: str) -> None:\n     call :func:`normalize_profile_name` first. This separation keeps validate\n     honest about what the on-disk directory name must look like, while\n     ingress-point normalization handles UX flexibility (see #18498).\n+\n+    Also rejects names in :data:`_RESERVED_NAMES` (``hermes``, ``test``,\n+    ``tmp``, ``root``, ``sudo``) that would create confusing on-disk\n+    collisions (a ``hermes`` profile inside ``~/.hermes/``) or get refused\n+    at alias-creation time anyway. ``default`` is a special pass-through —\n+    it's a valid alias for the built-in root profile.\n     \"\"\"\n     if name == \"default\":\n         return  # special alias for ~/.hermes","@@ -213,6 +219,12 @@ def validate_profile_name(name: str) -> None:\n             f\"Invalid profile name {name!r}. Must match \"\n             f\"[a-z0-9][a-z0-9_-]{{0,63}}\"\n         )\n+    if name in _RESERVED_NAMES:\n+        raise ValueError(\n+            f\"Profile name {name!r} is reserved — it collides with either \"\n+            f\"the Hermes installation itself or a common system binary.  \"\n+            f\"Pick a different name.\"\n+        )\n \n \n def get_profile_dir(name: str) -> Path:","@@ -329,6 +341,35 @@ class ProfileInfo:\n     has_env: bool = False\n     skill_count: int = 0\n     alias_path: Optional[Path] = None\n+    # Distribution metadata (None if the profile wasn't installed from a distribution).\n+    distribution_name: Optional[str] = None\n+    distribution_version: Optional[str] = None\n+    distribution_source: Optional[str] = None\n+\n+\n+def _read_distribution_meta(profile_dir: Path) -> tuple:\n+    \"\"\"Return ``(name, version, source)`` from the profile's ``distribution.yaml``\n+    if present; ``(None, None, None)`` otherwise.\n+\n+    Failures (missing file, bad YAML) are swallowed — a bad manifest should\n+    never break ``hermes profile list`` for an unrelated profile.\n+    \"\"\"\n+    mf_path = profile_dir / \"distribution.yaml\"\n+    if not mf_path.is_file():\n+        return None, None, None\n+    try:\n+        import yaml\n+        with open(mf_path, \"r\") as f:\n+            data = yaml.safe_load(f) or {}\n+        if not isinstance(data, dict):\n+            return None, None, None\n+        return (\n+            data.get(\"name\"),\n+            data.get(\"version\"),\n+            data.get(\"source\"),\n+        )\n+    except Exception:\n+        return None, None, None\n \n \n def _read_config_model(profile_dir: Path) -> tuple:","@@ -384,6 +425,7 @@ def list_profiles() -> List[ProfileInfo]:\n     default_home = _get_default_hermes_home()\n     if default_home.is_dir():\n         model, provider = _read_config_model(default_home)\n+        dist_name, dist_version, dist_source = _read_distribution_meta(default_home)\n         profiles.append(ProfileInfo(\n             name=\"default\",\n             path=default_home,","@@ -393,6 +435,9 @@ def list_profiles() -> List[ProfileInfo]:\n             provider=provider,\n             has_env=(default_home / \".env\").exists(),\n             skill_count=_count_skills(default_home),\n+            distribution_name=dist_name,\n+            distribution_version=dist_version,\n+            distribution_source=dist_source,\n         ))\n \n     # Named profiles","@@ -406,6 +451,7 @@ def list_profiles() -> List[ProfileInfo]:\n                 continue\n             model, provider = _read_config_model(entry)\n             alias_path = wrapper_dir / name\n+            dist_name, dist_version, dist_source = _read_distribution_meta(entry)\n             profiles.append(ProfileInfo(\n                 name=name,\n                 path=entry,","@@ -416,6 +462,9 @@ def list_profiles() -> List[ProfileInfo]:\n                 has_env=(entry / \".env\").exists(),\n                 skill_count=_count_skills(entry),\n                 alias_path=alias_path if alias_path.exists() else None,\n+                distribution_name=dist_name,\n+                distribution_version=dist_version,\n+                distribution_source=dist_source,\n             ))\n \n     return profiles","@@ -588,6 +637,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:\n     model, provider = _read_config_model(profile_dir)\n     gw_running = _check_gateway_running(profile_dir)\n     skill_count = _count_skills(profile_dir)\n+    dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir)\n \n     print(f\"\\nProfile: {canon}\")\n     print(f\"Path:    {profile_dir}\")","@@ -595,6 +645,10 @@ def delete_profile(name: str, yes: bool = False) -> Path:\n         print(f\"Model:   {model}\" + (f\" ({provider})\" if provider else \"\"))\n     if skill_count:\n         print(f\"Skills:  {skill_count}\")\n+    if dist_name:\n+        print(f\"Distribution: {dist_name}@{dist_version or '?'}\")\n+        if dist_source:\n+            print(f\"Installed from: {dist_source}\")\n \n     items = [\n         \"All config, API keys, memories, sessions, skills, cron jobs\","]},{"file":"tests/hermes_cli/test_profile_distribution.py","hunks":["@@ -0,0 +1,584 @@\n+\"\"\"Tests for hermes_cli.profile_distribution — git-based profile installs.\n+\n+Covers manifest parsing, version requirement checks, install / update / describe\n+on local-directory sources, and guards on what can and can't be installed.\n+\n+Transport-layer tests (git clone, URL handling) are exercised through live\n+E2E runs, not unit tests — git itself is tested upstream, and subprocess-\n+mocking git would just test the mock.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import os\n+from pathlib import Path\n+\n+import pytest\n+\n+from hermes_cli.profile_distribution import (\n+    DEFAULT_DIST_OWNED,\n+    DistributionError,\n+    DistributionManifest,\n+    EnvRequirement,\n+    MANIFEST_FILENAME,\n+    USER_OWNED_EXCLUDE,\n+    _env_template_from_manifest,\n+    _looks_like_git_url,\n+    _parse_semver,\n+    check_hermes_requires,\n+    describe_distribution,\n+    install_distribution,\n+    plan_install,\n+    read_manifest,\n+    update_distribution,\n+    write_manifest,\n+)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Isolated profile env (matches tests/hermes_cli/test_profiles.py)\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]()\n+def profile_env(tmp_path, monkeypatch):\n+    monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n+    default_home = tmp_path / \".hermes\"\n+    default_home.mkdir(exist_ok=True)\n+    monkeypatch.setenv(\"HERMES_HOME\", str(default_home))\n+    return tmp_path\n+\n+\n+def _make_staging_dir(root: Path, name: str = \"src\", *, manifest: DistributionManifest = None) -> Path:\n+    \"\"\"Build a local distribution staging directory (what a git clone would\n+    contain after .git is removed).\n+\n+    Lays down a minimal but representative tree: SOUL.md, config.yaml,\n+    mcp.json, one skill, one cron file, plus the distribution.yaml manifest.\n+    \"\"\"\n+    staged = root / f\"staging_{name}\"\n+    staged.mkdir(parents=True, exist_ok=True)\n+    (staged / \"SOUL.md\").write_text(\"I am Source.\\n\")\n+    (staged / \"config.yaml\").write_text(\"model:\\n  model: gpt-4\\n\")\n+    (staged / \"mcp.json\").write_text('{\"servers\": {}}\\n')\n+    (staged / \"skills\").mkdir(exist_ok=True)\n+    (staged / \"skills\" / \"demo\").mkdir(exist_ok=True)\n+    (staged / \"skills\" / \"demo\" / \"SKILL.md\").write_text(\n+        \"---\\nname: demo\\ndescription: test\\n---\\n# Demo skill\\n\"\n+    )\n+    (staged / \"cron\").mkdir(exist_ok=True)\n+    (staged / \"cron\" / \"daily.json\").write_text('{\"schedule\": \"0 9 * * *\"}')\n+\n+    mf = manifest or DistributionManifest(name=name, version=\"0.1.0\")\n+    write_manifest(staged, mf)\n+    return staged\n+\n+\n+# ===========================================================================\n+# Manifest parsing\n+# ===========================================================================\n+\n+\n+class TestManifestParsing:\n+\n+    def test_minimal_manifest(self, tmp_path):\n+        (tmp_path / MANIFEST_FILENAME).write_text(\"name: minimal\\n\")\n+        m = read_manifest(tmp_path)\n+        assert m.name == \"minimal\"\n+        assert m.version == \"0.1.0\"\n+        assert m.env_requires == []\n+        assert m.distribution_owned == []\n+\n+    def test_full_manifest(self, tmp_path):\n+        (tmp_path / MANIFEST_FILENAME).write_text(\n+            \"name: telem\\n\"\n+            \"version: 1.2.3\\n\"\n+            \"description: Telem monitor\\n\"\n+            \"hermes_requires: '>=0.12.0'\\n\"\n+            \"author: Kyle\\n\"\n+            \"license: MIT\\n\"\n+            \"env_requires:\\n\"\n+            \"  - name: OPENAI_API_KEY\\n\"\n+            \"    description: OpenAI key\\n\"\n+            \"  - name: GRAPH_URL\\n\"\n+            \"    required: false\\n\"\n+            \"    default: http://127.0.0.1:8000\\n\"\n+            \"distribution_owned:\\n\"\n+            \"  - SOUL.md\\n\"\n+            \"  - skills/\\n\"\n+        )\n+        m = read_manifest(tmp_path)\n+        assert m.name == \"telem\"\n+        assert m.version == \"1.2.3\"\n+        assert m.author == \"Kyle\"\n+        assert m.license == \"MIT\"\n+        assert len(m.env_requires) == 2\n+        assert m.env_requires[0].name == \"OPENAI_API_KEY\"\n+        assert m.env_requires[0].required is True\n+        assert m.env_requires[1].required is False\n+        assert m.env_requires[1].default == \"http://127.0.0.1:8000\"\n+        assert m.distribution_owned == [\"SOUL.md\", \"skills\"]\n+\n+    def test_missing_name_rejected(self, tmp_path):\n+        (tmp_path / MANIFEST_FILENAME).write_text(\"version: 1.0\\n\")\n+        with pytest.raises(DistributionError, match=\"missing 'name'\"):\n+            read_manifest(tmp_path)\n+\n+    def test_env_requires_not_list_rejected(self, tmp_path):\n+        (tmp_path / MANIFEST_FILENAME).write_text(\n+            \"name: bad\\nenv_requires:\\n  name: FOO\\n\"\n+        )\n+        with pytest.raises(DistributionError, match=\"env_requires must be a list\"):\n+            read_manifest(tmp_path)\n+\n+    def test_read_manifest_returns_none_when_absent(self, tmp_path):\n+        assert read_manifest(tmp_path) is None\n+\n+    def test_owned_paths_default(self):\n+        m = DistributionManifest(name=\"x\")\n+        assert m.owned_paths() == list(DEFAULT_DIST_OWNED)\n+\n+    def test_owned_paths_explicit(self):\n+        m = DistributionManifest(name=\"x\", distribution_owned=[\"SOUL.md\", \"skills\"])\n+        assert m.owned_paths() == [\"SOUL.md\", \"skills\"]\n+\n+    def test_roundtrip_write_read(self, tmp_path):\n+        original = DistributionManifest(\n+            name=\"rt\",\n+            version=\"1.0.0\",\n+            description=\"roundtrip\",\n+            env_requires=[EnvRequirement(name=\"FOO\", description=\"foo\")],\n+        )\n+        write_manifest(tmp_path, original)\n+        parsed = read_manifest(tmp_path)\n+        assert parsed.name == \"rt\"\n+        assert parsed.env_requires[0].name == \"FOO\"\n+\n+\n+# ===========================================================================\n+# Version requirement checks\n+# ===========================================================================\n+\n+\n+class TestVersionRequires:\n+\n+    @pytest.mark.parametrize(\"spec,cur,ok\", [\n+        (\"\", \"0.1.0\", True),\n+        (\">=0.12.0\", \"0.12.0\", True),\n+        (\">=0.12.0\", \"0.13.0\", True),\n+        (\">=0.12.0\", \"0.11.9\", False),\n+        (\"==0.12.0\", \"0.12.0\", True),\n+        (\"==0.12.0\", \"0.13.0\", False),\n+        (\"!=0.12.0\", \"0.13.0\", True),\n+        (\">0.12.0\", \"0.12.1\", True),\n+        (\">0.12.0\", \"0.12.0\", False),\n+        (\"<0.13.0\", \"0.12.9\", True),\n+        (\"<=0.12.0\", \"0.12.0\", True),\n+        (\"0.12.0\", \"0.13.0\", True),     # Bare = >=\n+        (\"0.12.0\", \"0.11.0\", False),    # Bare = >=\n+    ])\n+    def test_check_matrix(self, spec, cur, ok):\n+        if ok:\n+            check_hermes_requires(spec, cur)\n+        else:\n+            with pytest.raises(DistributionError, match=\"requires Hermes\"):\n+                check_hermes_requires(spec, cur)\n+\n+    def test_parse_semver_handles_prerelease(self):\n+        assert _parse_semver(\"0.12.0-rc1\") == (0, 12, 0)\n+        assert _parse_semver(\"v0.12.0+abc\") == (0, 12, 0)\n+\n+    def test_parse_semver_pads(self):\n+        assert _parse_semver(\"1\") == (1, 0, 0)\n+        assert _parse_semver(\"1.2\") == (1, 2, 0)\n+\n+    def test_parse_semver_rejects_garbage(self):\n+        with pytest.raises(DistributionError, match=\"Unparseable\"):\n+            _parse_semver(\"not-a-version\")\n+\n+\n+# ===========================================================================\n+# Env template\n+# ===========================================================================\n+\n+\n+class TestEnvTemplate:\n+\n+    def test_required_is_uncommented(self):\n+        m = DistributionManifest(\n+            name=\"x\",\n+            env_requires=[EnvRequirement(name=\"FOO\", description=\"foo key\")],\n+        )\n+        out = _env_template_from_manifest(m)\n+        assert \"# foo key\" in out\n+        assert \"# (required)\" in out\n+        assert \"FOO=\" in out\n+        # No leading `# ` before FOO=\n+        assert \"\\nFOO=\" in out or out.startswith(\"FOO=\") or \"\\nFOO=\\n\" in out or \"FOO=\\n\" in out\n+\n+    def test_optional_is_commented(self):\n+        m = DistributionManifest(\n+            name=\"x\",\n+            env_requires=[EnvRequirement(name=\"BAR\", required=False, default=\"http://x\")],\n+        )\n+        out = _env_template_from_manifest(m)\n+        assert \"# (optional)\" in out\n+        assert \"# BAR=http://x\" in out\n+\n+    def test_empty_env_requires_is_header_only(self):\n+        m = DistributionManifest(name=\"x\")\n+        out = _env_template_from_manifest(m)\n+        assert \"Hermes distribution\" in out\n+        assert \"FOO\" not in out\n+\n+\n+# ===========================================================================\n+# Source URL detection\n+# ===========================================================================\n+\n+\n+class TestLooksLikeGitUrl:\n+\n+    @pytest.mark.parametrize(\"src\", [\n+        \"github.com/user/repo\",\n+        \"https://github.com/user/repo\",\n+        \"https://github.com/user/repo.git\",\n+        \"http://example.com/repo\",\n+        \"[redacted:email]:user/repo.git\",\n+        \"ssh://[redacted:email]/repo.git\",\n+        \"git://example.com/repo.git\",\n+    ])\n+    def test_accepts_git_sources(self, src):\n+        assert _looks_like_git_url(src)\n+\n+    @pytest.mark.parametrize(\"src\", [\n+        \"/tmp/local/path\",\n+        \"./relative/dir\",\n+        \"~/profile\",\n+        \"some-random-string\",\n+    ])\n+    def test_rejects_non_git(self, src):\n+        assert not _looks_like_git_url(src)\n+\n+\n+# ===========================================================================\n+# Install — fresh and force (from a local-directory source)\n+# ===========================================================================\n+\n+\n+class TestInstall:\n+\n+    def test_install_from_directory(self, profile_env):\n+        staged = _make_staging_dir(profile_env, \"src\")\n+        plan = install_distribution(str(staged), name=\"installed\")\n+        assert plan.target_dir.is_dir()\n+        assert (plan.target_dir / \"SOUL.md\").read_text() == \"I am Source.\\n\"\n+        assert (plan.target_dir / \"skills\" / \"demo\" / \"SKILL.md\").exists()\n+        assert (plan.target_dir / \"mcp.json\").exists()\n+        # Manifest on disk records canonical name + provenance\n+        m = read_manifest(plan.target_dir)\n+        assert m.name == \"installed\"\n+        assert m.source == str(staged)\n+\n+    def test_install_uses_manifest_name_when_no_override(self, profile_env):\n+        mf = DistributionManifest(name=\"telem\", version=\"1.0.0\")\n+        staged = _make_staging_dir(profile_env, \"telem\", manifest=mf)\n+        plan = install_distribution(str(staged))\n+        assert plan.manifest.name == \"telem\"\n+        assert plan.target_dir.name == \"telem\"\n+\n+    def test_install_rejects_existing_without_force(self, profile_env):\n+        staged = _make_staging_dir(profile_env, \"src\")\n+        install_distribution(str(staged), name=\"existing\")\n+        with pytest.raises(DistributionError, match=\"already exists\"):\n+            install_distribution(str(staged), name=\"existing\")\n+\n+    def test_install_with_force_overwrites(self, profile_env):\n+        staged = _make_staging_dir(profile_env, \"src\")\n+        install_distribution(str(staged), name=\"target\")\n+        # Install again with --force succeeds\n+        plan = install_distribution(str(staged), name=\"target\", force=True)\n+        assert plan.target_dir.is_dir()\n+\n+    def test_install_rejects_default_name(self, profile_env):\n+        staged = _make_staging_dir(profile_env, \"src\")\n+        with pytest.raises(DistributionError, match=\"Cannot install\"):\n+            install_distribution(str(staged), name=\"default\")\n+\n+    def test_install_rejects_non_distribution_directory(self, profile_env, tmp_path):\n+        bogus = tmp_path / \"bogus_dir\"\n+        bogus.mkdir()\n+        (bogus / \"some_file\").write_text(\"hi\")\n+        with pytest.raises(DistributionError, match=\"No distribution.yaml\"):\n+            plan_install(str(bogus), tmp_path / \"work\", override_name=\"x\")\n+\n+    def test_install_rejects_unknown_source(self, profile_env, tmp_path):\n+        with pytest.raises(DistributionError, match=\"Cannot resolve\"):\n+            plan_install(\"definitely-not-a-thing\", tmp_path / \"work\", override_name=\"x\")\n+\n+    def test_install_emits_env_example_when_manifest_has_env(self, profile_env):\n+        mf = DistributionManifest(\n+            name=\"needs_env\",\n+            version=\"0.1.0\",\n+            env_requires=[EnvRequirement(name=\"OPENAI_API_KEY\", description=\"key\")],\n+        )\n+        staged = _make_staging_dir(profile_env, \"needs_env\", manifest=mf)\n+        plan = install_distribution(str(staged), name=\"needs_env\")\n+        example = plan.target_dir / \".env.EXAMPLE\"\n+        assert example.is_file()\n+        assert \"OPENAI_API_KEY\" in example.read_text()\n+\n+    def test_install_enforces_hermes_requires(self, profile_env, monkeypatch):\n+        # Pin current Hermes version to something well below the requirement\n+        import hermes_cli\n+        monkeypatch.setattr(hermes_cli, \"__version__\", \"0.1.0\", raising=False)\n+\n+        mf = DistributionManifest(\n+            name=\"future\",\n+            version=\"1.0.0\",\n+            hermes_requires=\">=99.0.0\",\n+        )\n+        staged = _make_staging_dir(profile_env, \"future\", manifest=mf)\n+        with pytest.raises(DistributionError, match=\"requires Hermes\"):\n+            install_distribution(str(staged), name=\"future\")\n+\n+\n+# ===========================================================================\n+# Update — preserves user data, preserves config by default\n+# ===========================================================================\n+\n+\n+class TestUpdate:\n+\n+    def test_update_preserves_user_data(self, profile_env):\n+        # 1. Build staging dir, install\n+        staged = _make_staging_dir(profile_env, \"src\")\n+        plan = install_distribution(str(staged), name=\"telem\")\n+\n+        # 2. Add user-owned data to the installed profile\n+        (plan.target_dir / \"memories\").mkdir(exist_ok=True)\n+        (plan.target_dir / \"memories\" / \"MEMORY.md\").write_text(\"# USER MEMORY\\n\")\n+        (plan.target_dir / \".env\").write_text(\"OPENAI_API_KEY=[redacted:secret-assignment]\")\n+        (plan.target_dir / \"auth.json\").write_text('{\"user\": \"auth\"}')\n+        (plan.target_dir / \"sessions\").mkdir(exist_ok=True)\n+        (plan.target_dir / \"sessions\" / \"chat.json\").write_text('{\"s\": 1}')\n+\n+        # 3. Bump source in the staging dir\n+        (staged / \"SOUL.md\").write_text(\"I am Source v2.\\n\")\n+\n+        # 4. Update\n+        update_distribution(\"telem\", force_config=False)\n+\n+        # 5. Dist-owned changed\n+        assert (plan.target_dir / \"SOUL.md\").read_text() == \"I am Source v2.\\n\"\n+        # 6. User-owned preserved\n+        assert (plan.target_dir / \"memories\" / \"MEMORY.md\").read_text() == \"# USER MEMORY\\n\"\n+        assert (plan.target_dir / \".env\").read_text() == \"OPENAI_API_KEY=[redacted:secret-assignment]\"\n+        assert (plan.target_dir / \"auth.json\").read_text() == '{\"user\": \"auth\"}'\n+        assert (plan.target_dir / \"sessions\" / \"chat.json\").read_text() == '{\"s\": 1}'\n+\n+    def test_update_preserves_config_by_default(self, profile_env):\n+        staged = _make_staging_dir(profile_env, \"src\")\n+        plan = install_distribution(str(staged), name=\"t2\")\n+\n+        # User edits config\n+        (plan.target_dir / \"config.yaml\").write_text(\n+            \"model:\\n  model: gpt-5\\n# user override\\n\"\n+        )\n+\n+        # Bump source config\n+        (staged / \"config.yaml\").write_text(\"model:\\n  model: claude\\n\")\n+\n+        update_distribution(\"t2\", force_config=False)\n+        assert \"gpt-5\" in (plan.target_dir / \"config.yaml\").read_text()\n+        assert \"user override\" in (plan.target_dir / \"config.yaml\").read_text()\n+\n+    def test_update_force_config_overwrites(self, profile_env):\n+        staged = _make_staging_dir(profile_env, \"src\")\n+        plan = install_distribution(str(staged), name=\"t3\")\n+\n+        (plan.target_dir / \"config.yaml\").write_text(\"model:\\n  model: gpt-5\\n\")\n+\n+        (staged / \"config.yaml\").write_text(\"model:\\n  model: claude\\n\")\n+\n+        update_distribution(\"t3\", force_config=True)\n+        assert \"claude\" in (plan.target_dir / \"config.yaml\").read_text()\n+        assert \"gpt-5\" not in (plan.target_dir / \"config.yaml\").read_text()\n+\n+    def test_update_missing_manifest_errors(self, profile_env):\n+        # Make a profile without a manifest; update must refuse\n+        from hermes_cli.profiles import create_profile\n+        create_profile(name=\"plain\", no_alias=True)\n+        with pytest.raises(DistributionError, match=\"not a distribution\"):\n+            update_distribution(\"plain\")\n+\n+\n+# ===========================================================================\n+# describe_distribution — info subcommand\n+# ===========================================================================\n+\n+\n+class TestDescribe:\n+\n+    def test_describe_existing_distribution(self, profile_env):\n+        mf = DistributionManifest(\n+            name=\"telem\",\n+            version=\"1.0.0\",\n+            description=\"compliance monitor\",\n+            env_requires=[EnvRequirement(name=\"API\", description=\"api key\")],\n+        )\n+        staged = _make_staging_dir(profile_env, \"telem\", manifest=mf)\n+        install_distribution(str(staged), name=\"telem\")\n+        data = describe_distribution(\"telem\")\n+        assert data[\"name\"] == \"telem\"\n+        assert data[\"version\"] == \"1.0.0\"\n+        assert data[\"env_requires\"][0][\"name\"] == \"API\"\n+\n+    def test_describe_non_distribution_returns_empty(self, profile_env):\n+        from hermes_cli.profiles import create_profile\n+        create_profile(name=\"plain\", no_alias=True)\n+        assert describe_distribution(\"plain\") == {}\n+\n+    def test_describe_missing_profile_raises(self, profile_env):\n+        with pytest.raises(DistributionError, match=\"does not exist\"):\n+            describe_distribution(\"nonexistent\")\n+\n+\n+# ===========================================================================\n+# Security — USER_OWNED_EXCLUDE covers the right paths\n+# ===========================================================================\n+\n+\n+class TestSecurity:\n+\n+    def test_user_owned_exclude_covers_credentials(self):\n+        assert \"auth.json\" in USER_OWNED_EXCLUDE\n+        assert \".env\" in USER_OWNED_EXCLUDE\n+        assert \"memories\" in USER_OWNED_EXCLUDE\n+        assert \"sessions\" in USER_OWNED_EXCLUDE\n+        assert \"local\" in USER_OWNED_EXCLUDE\n+\n+    def test_install_does_not_import_credentials_from_staging(self, profile_env):\n+        \"\"\"If an author accidentally ships auth.json or .env in their\n+        staging dir, the installer must NOT copy them to the target profile.\"\"\"\n+        staged = _make_staging_dir(profile_env, \"src\")\n+        # Author leaks credentials into the staging tree (shouldn't happen, but...)\n+        (staged / \"auth.json\").write_text('{\"leaked\": true}')\n+        (staged / \".env\").write_text(\"LEAKED=1\")\n+\n+        plan = install_distribution(str(staged), name=\"clean\")\n+        assert not (plan.target_dir / \"auth.json\").exists(), \"auth.json leaked\"\n+        # Fresh profile may have its own .env via the bootstrap; what we care\n+        # about is that the leaked content didn't land in the target.\n+        if (plan.target_dir / \".env\").exists():\n+            assert \"LEAKED\" not in (plan.target_dir / \".env\").read_text()\n+\n+\n+# ===========================================================================\n+# Install-time metadata (installed_at stamp)\n+# ===========================================================================\n+\n+\n+class TestInstalledAtStamp:\n+\n+    def test_install_stamps_installed_at(self, profile_env):\n+        staged = _make_staging_dir(profile_env, \"src\")\n+        plan = install_distribution(str(staged), name=\"stamped\")\n+        mf = read_manifest(plan.target_dir)\n+        assert mf.installed_at, \"installed_at should be set after install\"\n+        # ISO-8601 UTC sanity: starts with 4-digit year, contains 'T', ends with '+00:00'.\n+        assert mf.installed_at[:4].isdigit()\n+        assert \"T\" in mf.installed_at\n+        assert mf.installed_at.endswith(\"+00:00\")\n+\n+    def test_update_refreshes_installed_at(self, profile_env, monkeypatch):\n+        staged = _make_staging_dir(profile_env, \"src\")\n+        install_distribution(str(staged), name=\"demo\")\n+        from hermes_cli.profiles import get_profile_dir\n+        first = read_manifest(get_profile_dir(\"demo\")).installed_at\n+\n+        # Freeze `datetime.now()` to a fixed future time so we can observe that\n+        # update writes a NEW stamp (installs within the same second otherwise\n+        # collide at iso-8601 seconds resolution).\n+        import datetime as _dt\n+        class _FakeDT(_dt.datetime):\n+            @classmethod\n+            def now(cls, tz=None):\n+                return _dt.datetime(2099, 1, 1, 0, 0, 0, tzinfo=tz or _dt.timezone.utc)\n+        monkeypatch.setattr(\n+            \"hermes_cli.profile_distribution.datetime\", _FakeDT, raising=True\n+        )\n+\n+        from hermes_cli.profile_distribution import update_distribution\n+        update_distribution(\"demo\")\n+        refreshed = read_manifest(get_profile_dir(\"demo\")).installed_at\n+        assert refreshed != first, \"installed_at should change on update\"\n+        assert refreshed.startswith(\"2099-01-01\"), refreshed\n+\n+\n+# ===========================================================================\n+# ProfileInfo exposes distribution metadata\n+# ===========================================================================\n+\n+\n+class TestProfileInfoDistribution:\n+\n+    def test_installed_distribution_shows_in_list(self, profile_env):\n+        staged = _make_staging_dir(\n+            profile_env, \"src\",\n+            manifest=DistributionManifest(name=\"telem\", version=\"1.2.3\"),\n+        )\n+        install_distribution(str(staged), name=\"telem\")\n+\n+        from hermes_cli.profiles import list_profiles\n+        rows = {p.name: p for p in list_profiles()}\n+        assert \"telem\" in rows\n+        row = rows[\"telem\"]\n+        assert row.distribution_name == \"telem\"\n+        assert row.distribution_version == \"1.2.3\"\n+        assert row.distribution_source  # path populated, exact value depends on fixture\n+\n+    def test_plain_profile_has_no_distribution_fields(self, profile_env):\n+        from hermes_cli.profiles import create_profile, list_profiles\n+        create_profile(name=\"plain\", no_alias=True)\n+        rows = {p.name: p for p in list_profiles()}\n+        assert rows[\"plain\"].distribution_name is None\n+        assert rows[\"plain\"].distribution_version is None\n+\n+    def test_malformed_manifest_does_not_break_list(self, profile_env):\n+        from hermes_cli.profiles import create_profile, list_profiles, get_profile_dir\n+        create_profile(name=\"brokenmeta\", no_alias=True)\n+        # Write a distribution.yaml that isn't a valid mapping\n+        (get_profile_dir(\"brokenmeta\") / \"distribution.yaml\").write_text(\n+            \"not: [a, valid, mapping\\n\"  # broken YAML\n+        )\n+        # list_profiles must NOT raise; distribution_* stay None for this row.\n+        rows = {p.name: p for p in list_profiles()}\n+        assert rows[\"brokenmeta\"].distribution_name is None\n+\n+\n+# ===========================================================================\n+# Error surfaces: validation failures should propagate as DistributionError\n+# or ValueError (both caught and rendered cleanly by the CLI handler)\n+# ===========================================================================\n+\n+\n+class TestErrorSurfaces:\n+\n+    def test_bad_profile_name_raises_valueerror_not_traceback(self, profile_env, tmp_path):\n+        \"\"\"A manifest whose 'name' can't be used as a profile identifier\n+        should raise ValueError from validate_profile_name — the CLI handler\n+        catches both DistributionError and ValueError so users see a clean\n+        'Error: ...' line instead of a Python traceback.\n+        \"\"\"\n+        mf = DistributionManifest(name=\"Invalid Name With Spaces\", version=\"0.1.0\")\n+        staged = _make_staging_dir(profile_env, \"bad\", manifest=mf)\n+        with pytest.raises((ValueError, DistributionError)):\n+            plan_install(str(staged), tmp_path / \"work\")\n+\n+    def test_path_traversal_name_rejected(self, profile_env, tmp_path):\n+        mf = DistributionManifest(name=\"../../etc/passwd\", version=\"0.1.0\")\n+        staged = _make_staging_dir(profile_env, \"bad\", manifest=mf)\n+        with pytest.raises((ValueError, DistributionError)):\n+            plan_install(str(staged), tmp_path / \"work\")\n+"]},{"file":"tests/hermes_cli/test_profiles.py","hunks":["@@ -113,6 +113,14 @@ def test_empty_string_rejected(self):\n         with pytest.raises(ValueError):\n             validate_profile_name(\"\")\n \n+    @pytest.mark.parametrize(\"name\", [\"hermes\", \"test\", \"tmp\", \"root\", \"sudo\"])\n+    def test_reserved_names_rejected(self, name):\n+        \"\"\"Reserved names collide with the Hermes install itself or with\n+        common system binaries — reject them at validate time so\n+        create/install/rename all share one gate.\"\"\"\n+        with pytest.raises(ValueError, match=\"reserved\"):\n+            validate_profile_name(name)\n+\n \n # ===================================================================\n # TestGetProfileDir"]},{"file":"website/docs/reference/cli-commands.md","hunks":["@@ -1076,8 +1076,11 @@ Manage profiles — multiple isolated Hermes instances, each with its own config\n | `show <name>` | Show profile details (home directory, config, etc.). |\n | `alias <name> [--remove] [--name NAME]` | Manage wrapper scripts for quick profile access. |\n | `rename <old> <new>` | Rename a profile. |\n-| `export <name> [-o FILE]` | Export a profile to a `.tar.gz` archive. |\n-| `import <archive> [--name NAME]` | Import a profile from a `.tar.gz` archive. |\n+| `export <name> [-o FILE]` | Export a profile to a `.tar.gz` archive (local backup). |\n+| `import <archive> [--name NAME]` | Import a profile from a `.tar.gz` archive (local restore). |\n+| `install <source> [--name N] [--alias] [--force] [-y]` | Install a profile distribution from a git URL or local directory. |\n+| `update <name> [--force-config] [-y]` | Re-pull a distribution; preserves user data (memories, sessions, auth). |\n+| `info <name>` | Show a profile's distribution manifest (version, requirements, source). |\n \n Examples:\n ","@@ -1088,6 +1091,8 @@ hermes profile use work\n hermes profile alias work --name h-work\n hermes profile export work -o work-backup.tar.gz\n hermes profile import work-backup.tar.gz --name restored\n+hermes profile install github.com/user/my-distro --alias\n+hermes profile update work\n hermes -p work chat -q \"Hello from work profile\"\n ```\n "]},{"file":"website/docs/reference/profile-commands.md","hunks":["@@ -243,6 +243,161 @@ hermes profile import ./work-2026-03-29.tar.gz\n hermes profile import ./work-2026-03-29.tar.gz --name work-restored\n ```\n \n+## Distribution commands\n+\n+Distributions turn a profile into a shareable, versioned artifact published\n+as a **git repository**. A recipient installs the distribution with a single\n+command and can update it in place later without touching their local\n+memories, sessions, or credentials.\n+\n+`auth.json` and `.env` are never part of a distribution — they stay on the\n+installing user's machine.\n+\n+The recipient's user data (memories, sessions, auth, their own edits to\n+`.env`) is always preserved across the initial install and subsequent\n+updates.\n+\n+:::info\n+`hermes profile export` / `import` are still the right commands for\n+**local backup and restore** of a profile on your own machine. Distribution\n+(`install` / `update` / `info`) is a separate concept: ship a profile via\n+git so someone else can install it.\n+:::\n+\n+### `hermes profile install`\n+\n+```bash\n+hermes profile install <source> [--name <name>] [--alias] [--force] [--yes]\n+```\n+\n+Installs a profile distribution from a git URL or a local directory.\n+\n+| Option | Description |\n+|--------|-------------|\n+| `<source>` | Git URL (`github.com/user/repo`, `https://...`, `git@...`, `ssh://`, `git://`) or a local directory containing `distribution.yaml` at its root. |\n+| `--name NAME` | Override the profile name from the manifest. |\n+| `--alias` | Also create a shell wrapper (e.g. `telemetry` → `hermes -p telemetry`). |\n+| `--force` | Overwrite an existing profile of the same name. User data is still preserved. |\n+| `-y`, `--yes` | Skip the manifest-preview confirmation prompt. |\n+\n+The installer shows the manifest, lists required env vars, and warns about\n+cron jobs before asking for confirmation. Required env vars go into a\n+`.env.EXAMPLE` file you copy to `.env` and fill in.\n+\n+**Examples:**\n+\n+```bash\n+# Install from a GitHub repo (shorthand)\n+hermes profile install github.com/kyle/telemetry-distribution --alias\n+\n+# Install from a full HTTPS git URL\n+hermes profile install https://github.com/kyle/telemetry-distribution.git\n+\n+# Install from SSH\n+hermes profile install [redacted:email]:kyle/telemetry-distribution.git\n+\n+# Install from a local directory during development\n+hermes profile install ./telemetry/\n+```\n+\n+### `hermes profile update`\n+\n+```bash\n+hermes profile update <name> [--force-config] [--yes]\n+```\n+\n+Re-clones the distribution from its recorded source and applies updates.\n+Distribution-owned files (SOUL.md, skills/, cron/, mcp.json) are\n+overwritten; user data (memories, sessions, auth, .env) is never touched.\n+\n+`config.yaml` is preserved by default to keep your local overrides.\n+Pass `--force-config` to reset it to the distribution's shipped config.\n+\n+### `hermes profile info`\n+\n+```bash\n+hermes profile info <name>\n+```\n+\n+Prints the profile's distribution manifest — name, version, required\n+Hermes version, author, env var requirements, the source URL/path, and\n+the `Installed:` timestamp recorded when the distribution was last\n+`install`-ed or `update`-d. Useful for checking what a shared profile\n+needs before installing it, and for spotting \"this profile was installed\n+6 months ago and hasn't been updated.\"\n+\n+`hermes profile list` also shows the distribution name and version in a\n+`Distribution` column, and `hermes profile show <name>` / `delete <name>`\n+surface the source URL so you can tell at a glance which profiles came\n+from a git repo vs. were created locally.\n+\n+### Private distributions\n+\n+A private git repository works as a distribution source with no extra\n+configuration — the install shells out to your normal `git` binary, so\n+whatever authentication your shell is already set up for (SSH key,\n+`git credential` helper, GitHub CLI's stored HTTPS credentials) applies\n+transparently.\n+\n+```bash\n+# Uses your SSH key, the same as any other `git clone`\n+hermes profile install [redacted:email]:your-org/internal-assistant.git\n+\n+# Uses your git credential helper\n+hermes profile install https://github.com/your-org/internal-assistant.git\n+```\n+\n+If a clone prompts for credentials interactively in your terminal during\n+install, that prompt flows through. Set up your auth the way you'd\n+normally use `git clone` against the same repo first, then install.\n+\n+### Distribution manifest (`distribution.yaml`)\n+\n+Every distribution has a `distribution.yaml` at the root of its repository:\n+\n+```yaml\n+name: telemetry\n+version: 0.1.0\n+description: \"Compliance monitoring harness\"\n+hermes_requires: \">=0.12.0\"\n+author: \"Your Name\"\n+license: \"MIT\"\n+env_requires:\n+  - name: OPENAI_API_KEY\n+    description: \"OpenAI API key\"\n+    required: true\n+  - name: GRAPHITI_MCP_URL\n+    description: \"Memory graph URL\"\n+    required: false\n+    default: \"http://127.0.0.1:8000/sse\"\n+distribution_owned:   # optional; defaults to SOUL.md, config.yaml,\n+                      #   mcp.json, skills/, cron/, distribution.yaml\n+  - SOUL.md\n+  - skills/compliance/\n+  - cron/\n+```\n+\n+`hermes_requires` supports `>=`, `<=`, `==`, `!=`, `>`, `<`, or a bare\n+version (treated as `>=`). Install fails with a clear error if the current\n+Hermes version doesn't satisfy the spec.\n+\n+`distribution_owned` is optional. If set, only those paths are replaced on\n+update; anything else in the profile stays user-owned. If omitted, the\n+defaults above apply.\n+\n+### Publishing a distribution\n+\n+Authoring a distribution is just a git push:\n+\n+1. In your profile directory, create `distribution.yaml` with at least `name`\n+   and `version`.\n+2. Initialize a git repo (or use an existing one) and push to GitHub /\n+   GitLab / any host Hermes can clone from.\n+3. Tell recipients to run `hermes profile install <your-repo-url>`.\n+\n+Use git tags for versioned releases — recipients who clone `HEAD` get your\n+latest state, and you can always bump `version:` in the manifest.\n+\n ## `hermes -p` / `hermes --profile`\n \n ```bash"]},{"file":"website/package.json","hunks":["@@ -15,7 +15,7 @@\n     \"write-translations\": \"docusaurus write-translations\",\n     \"write-heading-ids\": \"docusaurus write-heading-ids\",\n     \"typecheck\": \"tsc\",\n-    \"lint:diagrams\": \"ascii-guard lint docs\"\n+    \"lint:diagrams\": \"ascii-guard lint --exclude-code-blocks docs\"\n   },\n   \"dependencies\": {\n     \"@docusaurus/core\": \"3.9.2\",\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}