# NousResearch/hermes-agent · Profile export, import, and sharing features

## Open now

- 3 issues, 0 PRs open on GitHub.
- #42779: Add portable Agent Capsule export/import for skills, workflows, and creation context
- #49377: \[Feature\]: Desktop GUI: Add one-click backup/restore button in Settings
- #65224: \[desktop\] Profiles panel: add Export and Import for full profile setup (SOUL, skills, etc.) to move between instances
- _Broad area: open items may be separate problems._

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.

## TL;DR

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

## Maintainer resolution verdict

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

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.13`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #42779: Add portable Agent Capsule export/import for skills, workflows, and creation context
- #49377: \[Feature\]: Desktop GUI: Add one-click backup/restore button in Settings
- #65224: \[desktop\] Profiles panel: add Export and Import for full profile setup (SOUL, skills, etc.) to move between instances

## Closed / duplicate issues

- #20456: Feature Request: Profile Distributions (shareable, packaged Hermes profiles)
- #30661: How can I export a master environment and save it to another computer
- #43784: \[Feature\]: Shareable Profile Templates

## Prior & competing PRs

- **#20831: feat(profile): shareable profile distributions via git**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `hermes_cli/main.py`

    ```diff
    @@ -8089,8 +8089,14 @@ def cmd_profile(args):
                 return
     
             # Header
    -        print(f"\n {'Profile':<16} {'Model':<28} {'Gateway':<12} {'Alias'}")
    -        print(f" {'─' * 15}    {'─' * 27}    {'─' * 11}    {'─' * 12}")
    +        print(
    +            f"\n {'Profile':<16} {'Model':<28} {'Gateway':<12} "
    +            f"{'Alias':<12} {'Distribution'}"
    +        )
    +        print(
    +            f" {'─' * 15}    {'─' * 27}    {'─' * 11}    "
    +            f"{'─' * 11}    {'─' * 20}"
    +        )
     
             for p in profiles:
                 marker = (
    ```

    ```diff
    @@ -8104,7 +8110,12 @@ def cmd_profile(args):
                 alias = p.name if p.alias_path else "—"
                 if p.is_default:
                     alias = "—"
    -            print(f"{marker}{name:<15} {model:<28} {gw:<12} {alias}")
    +            if p.distribution_name:
    +                dist = f"{p.distribution_name}@{p.distribution_version or '?'}"
    +                dist = dist[:30]
    +            else:
    +                dist = "—"
    +            print(f"{marker}{name:<15} {model:<28} {gw:<12} {alias:<12} {dist}")
             print()
     
         elif action == "use":
    ```

    ```diff
    @@ -8234,6 +8245,7 @@ def cmd_profile(args):
                 _read_config_model,
                 _check_gateway_running,
                 _count_skills,
    +            _read_distribution_meta,
             )
     
             if not profile_exists(name):
    ```

    ```diff
    @@ -8243,6 +8255,7 @@ def cmd_profile(args):
             model, provider = _read_config_model(profile_dir)
             gw = _check_gateway_running(profile_dir)
             skills = _count_skills(profile_dir)
    +        dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir)
             wrapper = _get_wrapper_dir() / name
     
             print(f"\nProfile: {name}")
    ```

    ```diff
    @@ -8257,6 +8270,11 @@ def cmd_profile(args):
             print(
                 f"SOUL.md: {'exists' if (profile_dir / 'SOUL.md').exists() else 'not configured'}"
             )
    +        if dist_name:
    +            print(f"Distribution: {dist_name}@{dist_version or '?'}")
    +            if dist_source:
    +                print(f"Installed from: {dist_source}")
    +            print(f"  (run `hermes profile info {name}` for full manifest)")
             if wrapper.exists():
                 print(f"Alias:   {wrapper}")
             print()
    ```

    ```diff
    @@ -8337,6 +8355,208 @@ def cmd_profile(args):
                 print(f"Error: {e}")
                 sys.exit(1)
     
    +    elif action == "install":
    +        import tempfile
    +        from hermes_cli.profile_distribution import (
    +            plan_install,
    +            install_distribution,
    +            DistributionError,
    +        )
    +
    +        try:
    +            # Preview: stage the distribution into a scratch dir, show the
    +            # manifest, then do the real install.  The double-stage avoids
    +            # any side-effects if the user declines.
    +            with tempfile.TemporaryDirectory(prefix="hermes_dist_preview_") as tmp:
    +                plan = plan_install(
    +                    args.source,
    +                    Path(tmp),
    +                    override_name=getattr(args, "install_name", None),
    +                )
    +                _render_distribution_plan(plan)
    +
    +                if not getattr(args, "yes", False):
    +                    try:
    +                        answer = input("\nProceed with install? [y/N] ").strip().lower()
    +                    except (EOFError, KeyboardInterrupt):
    +                        answer = ""
    +                    if answer not in ("y", "yes"):
    +                        print("Install cancelled.")
    +                        return
    +
    +            plan = install_distribution(
    +                args.source,
    +                name=getattr(args, "install_name", None),
    +                force=getattr(args, "force", False),
    +                create_alias=getattr(args, "alias", False),
    +            )
    +            print(f"\n✓ Installed '{plan.manifest.name}' v{plan.manifest.version}")
    +            print(f"  Profile path: {plan.target_dir}")
    +            if plan.manifest.env_requires:
    +                print(
    +                    f"  Next: copy .env.EXAMPLE to .env and fill in required keys:\n"
    +                    f"    {plan.target_dir}/.env.EXAMPLE"
    +                )
    +            if plan.has_cron:
    +                print(
    +                    "  Cron jobs were included but are NOT scheduled automatically.\n"
    +                    f"  Review them with:  hermes -p {plan.manifest.name} cron list"
    +                )
    +            print(f"\n  Use with:      hermes -p {plan.manifest.name} chat")
    +        except (DistributionError, ValueError) as e:
    +            print(f"Error: {e}")
    +            sys.exit(1)
    +
    +    elif action == "update":
    +        from hermes_cli.profile_distribution import (
    +            update_distribution,
    +            read_manifest,
    +            DistributionError,
    +        )
    +        from hermes_cli.profiles import get_profile_dir, normalize_profile_name
    +
    +        name = args.profile_name
    +        try:
    +            canon = normalize_profile_name(name)
    +            current = read_manifest(get_profile_dir(canon))
    +            if current is None:
    +                print(
    +                    f"Error: Profile '{canon}' is not a distribution (no distribution.yaml). "
    +                    "Only profiles installed via `hermes profile install` can be updated."
    +                )
    +                sys.exit(1)
    +
    +            force_config = getattr(args, "force_config", False)
    +            if not getattr(args, "yes", False):
    +                print(f"\nUpdate '{canon}' from: {current.source or '(no source)'}")
    +                print(f"  Currently at version {current.version}")
    +                if force_config:
    +                    print("  --force-config set: config.yaml WILL be overwritten.")
    +                else:
    +                    print("  config.yaml will be preserved (pass --force-config to overwrite).")
    +                print("  User data (memories, sessions, auth, .env) will NOT be touched.")
    +                try:
    +                    answer = input("\nProceed? [y/N] ").strip().lower()
    +                except (EOFError, KeyboardInterrupt):
    +                    answer = ""
    +                if answer not in ("y", "yes"):
    +                    print("Update cancelled.")
    +                    return
    +
    +            plan = update_distribution(canon, force_config=force_config)
    +            print(f"\n✓ Updated '{plan.manifest.name}' → v{plan.manifest.version}")
    +            if plan.has_cron:
    +                print(
    +                    "  Cron files were refreshed.  Review with:  "
    +                    f"hermes -p {plan.manifest.name} cron list"
    +                )
    +        except (DistributionError, ValueError) as e:
    +            print(f"Error: {e}")
    +            sys.exit(1)
    +
    +    elif action == "info":
    +        from hermes_cli.profile_distribution import describe_distribution, DistributionError
    +
    +        try:
    +            data = describe_distribution(args.profile_name)
    +        except (DistributionError, ValueError) as e:
    +            print(f"Error: {e}")
    +            sys.exit(1)
    +        if not data:
    +            print(
    +                f"Profile '{args.profile_name}' is not a distribution "
    +                "(no distribution.yaml)."
    +            )
    +            return
    +        print(f"\nDistribution: {data.get('name')}")
    +        print(f"Version:      {data.get('version', '?')}")
    +        if data.get("description"):
    +            print(f"Description:  {data['description']}")
    +        if data.get("author"):
    +            print(f"Author:       {data['author']}")
    +        if data.get("license"):
    +            print(f"License:      {data['license']}")
    +        if data.get("hermes_requires"):
    +            print(f"Requires:     Hermes {data['hermes_requires']}")
    +        if data.get("source"):
    +            print(f"Source:       {data['source']}")
    +        if data.get("installed_at"):
    +            print(f"Installed:    {data['installed_at']}")
    +        env_reqs = data.get("env_requires") or []
    +        if env_reqs:
    +            print("\nEnvironment variables:")
    +            for er in env_reqs:
    +                tag = "required" if er.get("required", True) else "optional"
    +                line = f"  {er['name']} ({tag})"
    +                if er.get("description"):
    +                    line += f" — {er['description']}"
    +                print(line)
    +                if er.get("default") is not None:
    +                    print(f"      default: {er['default']}")
    +        print()
    +
    +
    +def _render_distribution_plan(plan) -> None:
    +    """Print a human-readable summary of a pending distribution install."""
    +    from hermes_cli.profile_distribution import MANIFEST_FILENAME
    +    mf = plan.manifest
    +    print(f"\nDistribution: {mf.name} v{mf.version}")
    +    if mf.description:
    +        print(f"  {mf.description}")
    +    if mf.author:
    +        print(f"  Author:   {mf.author}")
    +    if mf.hermes_requires:
    +        print(f"  Requires: Hermes {mf.hermes_requires}")
    +    print(f"  Source:   {plan.provenance}")
    +    print(f"  Target:   {plan.target_dir}")
    +    if plan.existing:
    +        # Distinguish "updating an existing distribution" (well-understood
    +        # semantics — dist-owned overwritten, config preserved, user data
    +        # untouched) from "overwriting a hand-built plain profile" (same
    +        # mechanics but the user didn't sign up for this when they created
    +        # the profile manually).
    +        existing_is_distribution = (plan.target_dir / MANIFEST_FILENAME).is_file()
    +        if existing_is_distribution:
    +            print("  (profile exists — will overwrite distribution-owned files only)")
    +        else:
    +            print(
    +                "  ⚠ Profile exists but is NOT a distribution.  Installing here will\n"
    +                "    overwrite its SOUL.md, skills/, cron/, and mcp.json.\n"
    +                "    Your memories, sessions, auth.json, and .env will be preserved,\n"
    +                "    but any hand-edits to distribution-owned files will be lost."
    +            )
    +    if mf.env_requires:
    +        print("\n  Env vars:")
    +        for er in mf.env_requires:
    +            tag = "required" if er.required else "optional"
    +            # Check both the current shell environment and the target profile's
    +            # .env file so we don't nag about keys the user already has set up.
    +            already = os.environ.get(er.name) is not None
    +            if not already and plan.target_dir.is_dir():
    +                env_path = plan.target_dir / ".env"
    +                if env_path.is_file():
    +                    try:
    +                        for raw in env_path.read_text().splitlines():
    +                            line = raw.strip()
    +                            if not line or line.startswith("#"):
    +                                continue
    +                            key = line.split("=", 1)[0].strip()
    +                            if key == er.name:
    +                                already = True
    +                                break
    +                    except OSError:
    +                        pass
    +            status = "✓ set" if already else ("needs setting" if er.required else "—")
    +            line = f"    • {er.name} ({tag}, {status})"
    +            if er.description:
    +                line += f" — {er.description}"
    +            print(line)
    +    if plan.has_cron:
    +        print(
    +            "\n  ⚠ This distribution ships cron jobs.  They will NOT run "
    +            "automatically — review and enable manually."
    +        )
    +
     
     def _report_dashboard_status() -> int:
         """Print ``hermes dashboard`` PIDs and return the count.
    ```

    ```diff
    @@ -10570,6 +10790,63 @@ def cmd_acp(args):
             help="Profile name (default: inferred from archive)",
         )
     
    +    # ---------- Distribution subcommands (issue #20456) ----------
    +    profile_install = profile_subparsers.add_parser(
    +        "install",
    +        help="Install a profile distribution from a git URL or local directory",
    +        description=(
    +            "Install a Hermes profile distribution. SOURCE can be a git URL "
    +            "(github.com/user/repo, https://..., git@...) or a local "
    +            "directory containing distribution.yaml at its root."
    +        ),
    +    )
    +    profile_install.add_argument(
    +        "source",
    +        help="Distribution source (git URL or local directory)",
    +    )
    +    profile_install.add_argument(
    +        "--name", dest="install_name", metavar="NAME",
    +        help="Override profile name (default: read from manifest)",
    +    )
    +    profile_install.add_argument(
    +        "--alias", action="store_true",
    +        help="Create a shell wrapper alias for the installed profile",
    +    )
    +    profile_install.add_argument(
    +        "--force", action="store_true",
    +        help="Overwrite an existing profile of the same name (user data preserved)",
    +    )
    +    profile_install.add_argument(
    +        "-y", "--yes", action="store_true",
    +        help="Skip manifest preview confirmation",
    +    )
    +
    +    profile_update = profile_subparsers.add_parser(
    +        "update",
    +        help="Re-pull a distribution and apply updates (user data preserved)",
    +        description=(
    +            "Fetch the distribution from its recorded source and overwrite "
    +            "distribution-owned files (SOUL.md, skills/, cron/, mcp.json). "
    +            "User data (memories, sessions, auth, .env) is never touched. "
    +            "config.yaml is preserved unless --force-config is passed."
    +        ),
    +    )
    +    profile_update.add_argument("profile_name", help="Profile to update")
    +    profile_update.add_argument(
    +        "--force-config", action="store_true",
    +        help="Also overwrite config.yaml (normally preserved to keep user overrides)",
    +    )
    +    profile_update.add_argument(
    +        "-y", "--yes", action="store_true",
    +        help="Skip confirmation",
    +    )
    +
    +    profile_info = profile_subparsers.add_parser(
    +        "info",
    +        help="Show a profile's distribution manifest (version, requirements, source)",
    +    )
    +    profile_info.add_argument("profile_name", help="Profile to inspect")
    +
         profile_parser.set_defaults(func=cmd_profile)
     
         # =========================================================================
    ```
  - `hermes_cli/profile_distribution.py`

    ```diff
    @@ -0,0 +1,702 @@
    +"""Profile distributions — shareable, packaged Hermes profiles via git.
    +
    +A distribution is a Hermes profile published as a git repository (or
    +installed from a local directory for development). Install with one command
    +from a git URL, update in place, and keep your local memories / sessions /
    +credentials untouched.
    +
    +Where this fits relative to the existing pieces:
    +
    +* ``hermes profile export/import`` — local backup / restore for a profile
    +  on your own machine. NOT a distribution format. Stays as-is.
    +* ``hermes skills install <url>`` — the URL install pattern we're mirroring,
    +  but at the profile granularity.
    +
    +Subcommands (all live under ``hermes profile``, not a parallel tree):
    +
    +    hermes profile install <source> [--name N] [--alias] [--force] [--yes]
    +    hermes profile update  <name>  [--force-config] [--yes]
    +    hermes profile info    <name>
    +
    +``<source>`` is one of:
    +
    +* A git URL (``github.com/user/repo``, ``https://github.com/...``, ``git@...``,
    +  ``ssh://``, ``git://``), optionally with ``#<ref>`` to pin a tag / branch /
    +  commit SHA.
    +* A local directory that already contains ``distribution.yaml`` — used
    +  during profile development before the first push.
    +
    +Manifest format (``distribution.yaml`` at the profile root)::
    +
    +    name: telemetry
    +    version: 0.1.0
    +    description: "Compliance monitoring harness"
    +    hermes_requires: ">=0.12.0"
    +    author: "..."
    +    license: "..."
    +    env_requires:
    +      - name: OPENAI_API_KEY
    +        description: "OpenAI API key"
    +        required: true
    +      - name: GRAPHITI_MCP_URL
    +        description: "Memory graph URL"
    +        required: false
    +        default: "http://127.0.0.1:8000/sse"
    +    distribution_owned:      # optional; sensible defaults apply
    +      - SOUL.md
    +      - skills/
    +      - cron/
    +      - mcp.json
    +
    +Update semantics:
    +
    +* Distribution-owned paths (SOUL.md, mcp.json, skills/, cron/,
    +  distribution.yaml) are replaced from the new source.
    +* ``config.yaml`` is distribution-owned but preserved on update unless
    +  ``--force-config`` is passed (user overrides typically live here).
    +* User-owned paths (memories/, sessions/, state.db, auth.json, .env,
    +  logs/, workspace/, home/, plans/, *_cache/, and anything under
    +  ``local/``) are never touched.
    +"""
    +
    +from __future__ import annotations
    +
    +import re
    +import shutil
    +import subprocess
    +import tempfile
    +from dataclasses import dataclass, field
    +from datetime import datetime, timezone
    +from pathlib import Path
    +from typing import Any, Dict, List, Optional, Tuple
    +
    +
    +# ---------------------------------------------------------------------------
    +# Constants
    +# ---------------------------------------------------------------------------
    +
    +MANIFEST_FILENAME = "distribution.yaml"
    +ENV_TEMPLATE_FILENAME = ".env.template"
    +ENV_EXAMPLE_FILENAME = ".env.EXAMPLE"
    +
    +# Default distribution-owned paths (relative to profile root).  Authors may
    +# override via ``distribution_owned:`` in the manifest.  config.yaml is
    +# distribution-owned but treated specially on update (see _is_config_like).
    +DEFAULT_DIST_OWNED: Tuple[str, ...] = (
    +    "SOUL.md",
    +    "config.yaml",
    +    "mcp.json",
    +    "skills",
    +    "cron",
    +    MANIFEST_FILENAME,
    +)
    +
    +# Paths that are NEVER part of a distribution. These are user-owned and are
    +# protected on update. Must stay consistent with
    +# ``profiles.py::_DEFAULT_EXPORT_EXCLUDE_ROOT`` plus the ``local/``
    +# convention for user customizations.
    +USER_OWNED_EXCLUDE: frozenset = frozenset({
    +    # Credentials & runtime secrets
    +    "auth.json", ".env",
    +    # Databases & runtime state
    +    "state.db", "state.db-shm", "state.db-wal",
    +    "hermes_state.db", "response_store.db",
    +    "response_store.db-shm", "response_store.db-wal",
    +    "gateway.pid", "gateway_state.json", "processes.json",
    +    "auth.lock", "active_profile", ".update_check",
    +    "errors.log", ".hermes_history",
    +    # User data
    +    "memories", "sessions", "logs", "plans", "workspace", "home",
    +    "image_cache", "audio_cache", "document_cache",
    +    "browser_screenshots", "checkpoints", "sandboxes",
    +    "backups", "cache",
    +    # Infrastructure
    +    "hermes-agent", ".worktrees", "profiles", "bin", "node_modules",
    +    # User customization namespace
    +    "local",
    +})
    +
    +
    +# ---------------------------------------------------------------------------
    +# Errors
    +# ---------------------------------------------------------------------------
    +
    +
    +class DistributionError(Exception):
    +    """Raised for distribution install/update failures."""
    +
    +
    +# ---------------------------------------------------------------------------
    +# Manifest
    +# ---------------------------------------------------------------------------
    +
    +
    +@dataclass
    +class EnvRequirement:
    +    name: str
    +    description: str = ""
    +    required: bool = True
    +    default: Optional[str] = None
    +
    +    @classmethod
    +    def from_dict(cls, data: Any) -> "EnvRequirement":
    +        if not isinstance(data, dict):
    +            raise DistributionError(
    +                f"env_requires entry must be a mapping, got {type(data).__name__}"
    +            )
    +        name = str(data.get("name") or "").strip()
    +        if not name:
    +            raise DistributionError("env_requires entry missing 'name'")
    +        return cls(
    +            name=name,
    +            description=str(data.get("description") or ""),
    +            required=bool(data.get("required", True)),
    +            default=data.get("default"),
    +        )
    +
    +    def to_dict(self) -> Dict[str, Any]:
    +        out: Dict[str, Any] = {"name": self.name, "description": self.description}
    +        if not self.required:
    +            out["required"] = False
    +        if self.default is not None:
    +            out["default"] = self.default
    +        return out
    +
    +
    +@dataclass
    +class DistributionManifest:
    +    name: str
    +    version: str = "0.1.0"
    +    description: str = ""
    +    hermes_requires: str = ""
    +    author: str = ""
    +    license: str = ""
    +    env_requires: List[EnvRequirement] = field(default_factory=list)
    +    distribution_owned: List[str] = field(default_factory=list)
    +    # Tracked after install — where we pulled from, so ``update`` can re-pull.
    +    source: str = ""
    +    # ISO-8601 UTC timestamp written on install / update, so ``info`` and
    +    # ``list`` can show when a distribution landed on disk.  Empty for
    +    # manifests that ship in a repo (authors don't populate this).
    +    installed_at: str = ""
    +
    +    @classmethod
    +    def from_dict(cls, data: Any) -> "DistributionManifest":
    +        if not isinstance(data, dict):
    +            raise DistributionError(
    +                f"{MANIFEST_FILENAME} must be a mapping, got {type(data).__name__}"
    +            )
    +        name = str(data.get("name") or "").strip()
    +        if not name:
    +            raise DistributionError(f"{MANIFEST_FILENAME} missing 'name'")
    +        env_raw = data.get("env_requires") or []
    +        if not isinstance(env_raw, list):
    +            raise DistributionError("env_requires must be a list")
    +        env_requires = [EnvRequirement.from_dict(e) for e in env_raw]
    +        dist_owned_raw = data.get("distribution_owned") or []
    +        if dist_owned_raw and not isinstance(dist_owned_raw, list):
    +            raise DistributionError("distribution_owned must be a list")
    +        distribution_owned = [str(p).strip().strip("/") for p in dist_owned_raw if str(p).strip()]
    +        return cls(
    +            name=name,
    +            version=str(data.get("version") or "0.1.0"),
    +            description=str(data.get("description") or ""),
    +            hermes_requires=str(data.get("hermes_requires") or ""),
    +            author=str(data.get("author") or ""),
    +            license=str(data.get("license") or ""),
    +            env_requires=env_requires,
    +            distribution_owned=distribution_owned,
    +            source=str(data.get("source") or ""),
    +            installed_at=str(data.get("installed_at") or ""),
    +        )
    +
    +    def to_dict(self) -> Dict[str, Any]:
    +        out: Dict[str, Any] = {
    +            "name": self.name,
    +            "version": self.version,
    +        }
    +        if self.description:
    +            out["description"] = self.description
    +        if self.hermes_requires:
    +            out["hermes_requires"] = self.hermes_requires
    +        if self.author:
    +            out["author"] = self.author
    +        if self.license:
    +            out["license"] = self.license
    +        if self.env_requires:
    +            out["env_requires"] = [e.to_dict() for e in self.env_requires]
    +        if self.distribution_owned:
    +            out["distribution_owned"] = self.distribution_owned
    +        if self.source:
    +            out["source"] = self.source
    +        if self.installed_at:
    +            out["installed_at"] = self.installed_at
    +        return out
    +
    +    def owned_paths(self) -> List[str]:
    +        """Resolve which paths count as distribution-owned."""
    +        if self.distribution_owned:
    +            return list(self.distribution_owned)
    +        return list(DEFAULT_DIST_OWNED)
    +
    +
    +def _load_yaml(text: str) -> Any:
    +    try:
    +        import yaml
    +    except ImportError as exc:  # pragma: no cover — pyyaml is a hard dep
    +        raise DistributionError("PyYAML is required for distribution manifests") from exc
    +    return yaml.safe_load(text)
    +
    +
    +def _dump_yaml(data: Any) -> str:
    +    import yaml
    +
    +    return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
    +
    +
    +def read_manifest(profile_dir: Path) -> Optional[DistributionManifest]:
    +    """Return the manifest for *profile_dir*, or None if it isn't a distribution."""
    +    mf_path = profile_dir / MANIFEST_FILENAME
    +    if not mf_path.is_file():
    +        return None
    +    try:
    +        data = _load_yaml(mf_path.read_text(encoding="utf-8"))
    +    except Exception as exc:
    +        raise DistributionError(f"Failed to parse {mf_path}: {exc}") from exc
    +    return DistributionManifest.from_dict(data or {})
    +
    +
    +def write_manifest(profile_dir: Path, manifest: DistributionManifest) -> Path:
    +    mf_path = profile_dir / MANIFEST_FILENAME
    +    mf_path.write_text(_dump_yaml(manifest.to_dict()), encoding="utf-8")
    +    return mf_path
    +
    +
    +# ---------------------------------------------------------------------------
    +# Version check
    +# ---------------------------------------------------------------------------
    +
    +
    +_VERSION_OP_RE = re.compile(r"^\s*(>=|<=|==|!=|>|<)\s*(.+?)\s*$")
    +
    +
    +def _parse_semver(v: str) -> Tuple[int, int, int]:
    +    """Very small semver parser — major.minor.patch only.  Extra labels stripped."""
    +    s = str(v).strip().lstrip("v")
    +    # Strip any pre-release / build metadata (e.g. "0.12.0-rc1+abc")
    +    s = re.split(r"[-+]", s, 1)[0]
    +    parts = s.split(".")
    +    while len(parts) < 3:
    +        parts.append("0")
    +    try:
    +        return (int(parts[0]), int(parts[1]), int(parts[2]))
    +    except ValueError as exc:
    +        raise DistributionError(f"Unparseable version: {v!r}") from exc
    +
    +
    +def check_hermes_requires(spec: str, current_version: str) -> None:
    +    """Raise DistributionError if ``current_version`` does not satisfy ``spec``.
    +
    +    ``spec`` accepts a single comparator (``>=0.12.0``, ``==0.12.0``, etc.).
    +    Empty or blank spec is a no-op — no requirement.
    +    """
    +    if not spec or not spec.strip():
    +        return
    +    m = _VERSION_OP_RE.match(spec)
    +    if not m:
    +        # Bare version → treat as ``>=``
    +        op, target = ">=", spec.strip()
    +    else:
    +        op, target = m.group(1), m.group(2)
    +    cur = _parse_semver(current_version)
    +    tgt = _parse_semver(target)
    +    ok = {
    +        ">=": cur >= tgt,
    +        "<=": cur <= tgt,
    +        "==": cur == tgt,
    +        "!=": cur != tgt,
    +        ">":  cur > tgt,
    +        "<":  cur < tgt,
    +    }[op]
    +    if not ok:
    +        raise DistributionError(
    +            f"This distribution requires Hermes {op}{target}, "
    +            f"but you have {current_version}."
    +        )
    +
    +
    +# ---------------------------------------------------------------------------
    +# Env var template helper
    +# ---------------------------------------------------------------------------
    +
    +
    +def _env_template_from_manifest(manifest: DistributionManifest) -> str:
    +    """Generate a ``.env.template`` body from env_requires."""
    +    lines = [
    +        "# Environment variables required by this Hermes distribution.",
    +        "# Copy to `.env` and fill in your own values before running.",
    +        "",
    +    ]
    +    for req in manifest.env_requires:
    +        if req.description:
    +            lines.append(f"# {req.description}")
    +        status = "required" if req.required else "optional"
    +        lines.append(f"# ({status})")
    +        default_val = req.default if req.default is not None else ""
    +        prefix = "" if req.required else "# "
    +        lines.append(f"{prefix}{req.name}={default_val}")
    +        lines.append("")
    +    return "\n".join(lines).rstrip() + "\n"
    +
    +
    +# ---------------------------------------------------------------------------
    +# Source staging — git clone or local directory
    +# ---------------------------------------------------------------------------
    +
    +
    +def _looks_like_git_url(s: str) -> bool:
    +    s = s.strip()
    +    if s.endswith(".git"):
    +        return True
    +    if s.startswith(("git@", "ssh://", "git://")):
    +        return True
    +    if s.startswith(("http://", "https://")):
    +        # Any http(s) URL is treated as a git repo.  We no longer accept
    +        # tar.gz URLs — git is the only remote transport.
    +        return True
    +    # Bare github.com/user/repo shorthand
    +    if re.match(r"^github\.com/[\w.-]+/[\w.-]+/?$", s):
    +        return True
    +    return False
    +
    +
    +def _git_clone(url: str, dest: Path) -> None:
    +    # Normalize github.com/user/repo shorthand
    +    if re.match(r"^github\.com/[\w.-]+/[\w.-]+/?$", url):
    +        url = f"https://{url.rstrip('/')}"
    +    try:
    +        subprocess.run(
    +            ["git", "clone", "--depth", "1", url, str(dest)],
    +            check=True,
    +            capture_output=True,
    +        )
    +    except FileNotFoundError as exc:
    +        raise DistributionError("git is required for git-URL installs") from exc
    +    except subprocess.CalledProcessError as exc:
    +        stderr = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else ""
    +        raise DistributionError(f"git clone failed: {stderr.strip()}") from exc
    +
    +
    +def _stage_source(source: str, workdir: Path) -> Tuple[Path, str]:
    +    """Resolve *source* to a local directory containing distribution.yaml.
    +
    +    Returns ``(staged_dir, provenance)`` where ``provenance`` is stored in the
    +    installed manifest's ``source:`` field so ``hermes profile update`` can
    +    re-pull from the same place.
    +
    +    Accepts:
    +      * A git URL (https / ssh / git@ / bare github.com shorthand) — cloned
    +        into a temp directory; ``.git`` removed after clone.
    +      * A local directory already containing ``distribution.yaml``.
    +    """
    +    src_str = source.strip()
    +
    +    # Git URL
    +    if _looks_like_git_url(src_str):
    +        cloned = workdir / "clone"
    +        _git_clone(src_str, cloned)
    +        # Remove .git to keep the staged tree clean
    +        shutil.rmtree(cloned / ".git", ignore_errors=True)
    +        if not (cloned / MANIFEST_FILENAME).is_file():
    +            raise DistributionError(
    +                f"No {MANIFEST_FILENAME} at the root of {src_str!r}. "
    +                "This repository is not a Hermes profile distribution."
    +            )
    +        return cloned, src_str
    +
    +    # Local directory
    +    path_guess = Path(src_str).expanduser()
    +    if path_guess.is_dir():
    +        if not (path_guess / MANIFEST_FILENAME).is_file():
    +            raise DistributionError(
    +                f"No {MANIFEST_FILENAME} in {path_guess}. "
    +                "A local-directory source must contain a distribution.yaml at its root."
    +            )
    +        return path_guess.resolve(), str(path_guess.resolve())
    +
    +    raise DistributionError(
    +        f"Cannot resolve distribution source: {source!r}. "
    +        "Expected a git URL (e.g. github.com/user/repo) or a local directory."
    +    )
    +
    +
    +# ---------------------------------------------------------------------------
    +# Install
    +# ---------------------------------------------------------------------------
    +
    +
    +@dataclass
    +class InstallPlan:
    +    """Summary of what an install will do, surfaced for user confirmation."""
    +    manifest: DistributionManifest
    +    staged_dir: Path
    +    provenance: str
    +    target_dir: Path
    +    existing: bool  # True if target profile already exists (update path)
    +    preserves_config: bool = True
    +    has_cron: bool = False
    +    has_skills: bool = False
    +
    +
    +def _has_cron_jobs(staged: Path) -> bool:
    +    cron_dir = staged / "cron"
    +    if not cron_dir.is_dir():
    +        return False
    +    for _ in cron_dir.rglob("*.json"):
    +        return True
    +    for _ in cron_dir.rglob("*.yaml"):
    +        return True
    +    return False
    +
    +
    +def _count_skills(staged: Path) -> int:
    +    skills_dir = staged / "skills"
    +    if not skills_dir.is_dir():
    +        return 0
    +    return sum(1 for _ in skills_dir.rglob("SKILL.md"))
    +
    +
    +def plan_install(
    +    source: str,
    +    workdir: Path,
    +    override_name: Optional[str] = None,
    +) -> InstallPlan:
    +    """Stage *source* and produce a plan describing what install would do."""
    +    from hermes_cli.profiles import (
    +        get_profile_dir,
    +        normalize_profile_name,
    +        validate_profile_name,
    +    )
    +    from hermes_cli import __version__ as hermes_version
    +
    +    staged, provenance = _stage_source(source, workdir)
    +    manifest = read_manifest(staged)
    +    if manifest is None:
    +        raise DistributionError(
    +            f"No {MANIFEST_FILENAME} found at the distribution root — "
    +            "this source is not a Hermes distribution."
    +        )
    +
    +    # Version check up-front so we fail fast
    +    check_hermes_requires(manifest.hermes_requires, hermes_version)
    +
    +    # Resolve target profile name
    +    target_name = override_name or manifest.name
    +    canon = normalize_profile_name(target_name)
    +    validate_profile_name(canon)
    +    if canon == "default":
    +        raise DistributionError(
    +            "Cannot install a distribution as 'default' — that is the built-in "
    +            "root profile (~/.hermes).  Pass --name <name> to install under a "
    +            "new profile."
    +        )
    +    manifest.name = canon
    +    manifest.source = provenance
    +    # Stamped once here so plan_install() callers (both fresh install and
    +    # update) propagate a freshly-minted timestamp through _copy_dist_payload.
    +    manifest.installed_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
    +
    +    target_dir = get_profile_dir(canon)
    +    existing = target_dir.is_dir()
    +    has_cron = _has_cron_jobs(staged)
    +    skill_count = _count_skills(staged)
    +
    +    return InstallPlan(
    +        manifest=manifest,
    +        staged_dir=staged,
    +        provenance=provenance,
    +        target_dir=target_dir,
    +        existing=existing,
    +        preserves_config=existing,
    +        has_cron=has_cron,
    +        has_skills=skill_count > 0,
    +    )
    +
    +
    +def _copy_dist_payload(
    +    staged: Path,
    +    target: Path,
    +    manifest: DistributionManifest,
    +    preserve_config: bool,
    +) -> None:
    +    """Copy distribution-owned files from *staged* into *target*.
    +
    +    User-owned paths are never touched.  ``config.yaml`` is replaced only when
    +    ``preserve_config`` is False (fresh install or ``--force-config`` update).
    +    ``.env.template`` is renamed to ``.env.EXAMPLE`` in the target to avoid
    +    shadowing a real ``.env``.
    +    """
    +    target.mkdir(parents=True, exist_ok=True)
    +
    +    for entry in staged.iterdir():
    +        name = entry.name
    +
    +        if name in USER_OWNED_EXCLUDE:
    +            continue
    +        if name == ENV_TEMPLATE_FILENAME:
    +            shutil.copy2(entry, target / ENV_EXAMPLE_FILENAME)
    +            continue
    +        if name == "config.yaml" and preserve_config and (target / "config.yaml").exists():
    +            # Leave user's config.yaml alone on update
    +            continue
    +
    +        dest = target / name
    +        if entry.is_dir():
    +            if dest.exists():
    +                shutil.rmtree(dest)
    +            shutil.copytree(
    +                entry,
    +                dest,
    +                ignore=lambda d, names: [n for n in names if n in USER_OWNED_EXCLUDE],
    +            )
    +        else:
    +            shutil.copy2(entry, dest)
    +
    +    # Emit .env.EXAMPLE from manifest if the staged tree didn't ship one
    +    if manifest.env_requires and not (target / ENV_EXAMPLE_FILENAME).exists():
    +        (target / ENV_EXAMPLE_FILENAME).write_text(
    +            _env_template_from_manifest(manifest), encoding="utf-8"
    +        )
    +
    +    # Make sure the manifest on disk reflects resolved name + source
    +    write_manifest(target, manifest)
    +
    +
    +def _bootstrap_user_dirs(target: Path) -> None:
    +    """Create the bootstrap dirs a fresh profile expects."""
    +    for d in ("memories", "sessions", "skills", "skins", "logs",
    +              "plans", "workspace", "cron", "home"):
    +        (target / d).mkdir(parents=True, exist_ok=True)
    +
    +
    +def install_distribution(
    +    source: str,
    +    name: Optional[str] = None,
    +    force: bool = False,
    +    create_alias: bool = False,
    +) -> InstallPlan:
    +    """Install a distribution from *source* into a new profile.
    +
    +    Returns the resolved :class:`InstallPlan`.  Use :func:`plan_install`
    +    first if you want to preview + prompt the user before calling this.
    +    """
    +    from hermes_cli.profiles import (
    +        check_alias_collision,
    +        create_wrapper_script,
    +    )
    +
    +    with tempfile.TemporaryDirectory(prefix="hermes_dist_install_") as tmp:
    +        plan = plan_install(source, Path(tmp), override_name=name)
    +
    +        if plan.existing and not force:
    +            raise DistributionError(
    +                f"Profile '{plan.manifest.name}' already exists at {plan.target_dir}. "
    +                "Use `hermes profile update` to upgrade in place, "
    +                "or pass --force to overwrite."
    +            )
    +
    +        # Fresh install: config.yaml comes from the distribution.
    +        _bootstrap_user_dirs(plan.target_dir)
    +        _copy_dist_payload(
    +            plan.staged_dir,
    +            plan.target_dir,
    +            plan.manifest,
    +            preserve_config=False,
    +        )
    +
    +        if create_alias:
    +            collision = check_alias_collision(plan.manifest.name)
    +            if collision is None:
    +                create_wrapper_script(plan.manifest.name)
    +
    +        return plan
    +
    +
    +def update_distribution(
    +    profile_name: str,
    +    force_config: bool = False,
    +) -> InstallPlan:
    +    """Re-pull the distribution for an existing profile and apply updates.
    +
    +    The source is read from the installed profile's ``distribution.yaml``
    +    ``source:`` field.  Distribution-owned files are overwritten; user-owned
    +    data (memories, sessions, auth) is never touched.  ``config.yaml`` is
    +    preserved unless ``force_config`` is True.
    +    """
    +    from hermes_cli.profiles import (
    +        get_profile_dir,
    +        normalize_profile_name,
    +        validate_profile_name,
    +    )
    +
    +    canon = normalize_profile_name(profile_name)
    +    validate_profile_name(canon)
    +    target = get_profile_dir(canon)
    +    if not target.is_dir():
    +        raise DistributionError(f"Profile '{canon}' does not exist.")
    +
    +    existing_manifest = read_manifest(target)
    +    if existing_manifest is None:
    +        raise DistributionError(
    +            f"Profile '{canon}' is not a distribution (no {MANIFEST_FILENAME}). "
    +            "Only profiles installed via `hermes profile install` can be updated."
    +        )
    +    if not existing_manifest.source:
    +        raise DistributionError(
    +            f"Profile '{canon}' has no recorded source.  Re-install with "
    +            "`hermes profile install <source> --name {canon} --force`."
    +        )
    +
    +    with tempfile.TemporaryDirectory(prefix="hermes_dist_update_") as tmp:
    +        plan = plan_install(
    +            existing_manifest.source,
    +            Path(tmp),
    +            override_name=canon,
    +        )
    +        plan.preserves_config = not force_config
    +
    +        _copy_dist_payload(
    +            plan.staged_dir,
    +            plan.target_dir,
    +            plan.manifest,
    +            preserve_config=plan.preserves_config,
    +        )
    +        return plan
    +
    +
    +# ---------------------------------------------------------------------------
    +# Info — render a manifest summary
    +# ---------------------------------------------------------------------------
    +
    +
    +def describe_distribution(profile_name: str) -> Dict[str, Any]:
    +    """Return a structured view of a profile's distribution metadata.
    +
    +    Returns an empty dict if the profile exists but has no manifest.
    +    Raises DistributionError if the profile itself doesn't exist.
    +    """
    +    from hermes_cli.profiles import (
    +        get_profile_dir,
    +        normalize_profile_name,
    +        validate_profile_name,
    +    )
    +
    +    canon = normalize_profile_name(profile_name)
    +    validate_profile_name(canon)
    +    target = get_profile_dir(canon)
    +    if not target.is_dir():
    +        raise DistributionError(f"Profile '{canon}' does not exist.")
    +    manifest = read_manifest(target)
    +    if manifest is None:
    +        return {}
    +    return manifest.to_dict()
    ```
  - `hermes_cli/profiles.py`

    ```diff
    @@ -205,6 +205,12 @@ def validate_profile_name(name: str) -> None:
         call :func:`normalize_profile_name` first. This separation keeps validate
         honest about what the on-disk directory name must look like, while
         ingress-point normalization handles UX flexibility (see #18498).
    +
    +    Also rejects names in :data:`_RESERVED_NAMES` (``hermes``, ``test``,
    +    ``tmp``, ``root``, ``sudo``) that would create confusing on-disk
    +    collisions (a ``hermes`` profile inside ``~/.hermes/``) or get refused
    +    at alias-creation time anyway. ``default`` is a special pass-through —
    +    it's a valid alias for the built-in root profile.
         """
         if name == "default":
             return  # special alias for ~/.hermes
    ```

    ```diff
    @@ -213,6 +219,12 @@ def validate_profile_name(name: str) -> None:
                 f"Invalid profile name {name!r}. Must match "
                 f"[a-z0-9][a-z0-9_-]{{0,63}}"
             )
    +    if name in _RESERVED_NAMES:
    +        raise ValueError(
    +            f"Profile name {name!r} is reserved — it collides with either "
    +            f"the Hermes installation itself or a common system binary.  "
    +            f"Pick a different name."
    +        )
     
     
     def get_profile_dir(name: str) -> Path:
    ```

    ```diff
    @@ -329,6 +341,35 @@ class ProfileInfo:
         has_env: bool = False
         skill_count: int = 0
         alias_path: Optional[Path] = None
    +    # Distribution metadata (None if the profile wasn't installed from a distribution).
    +    distribution_name: Optional[str] = None
    +    distribution_version: Optional[str] = None
    +    distribution_source: Optional[str] = None
    +
    +
    +def _read_distribution_meta(profile_dir: Path) -> tuple:
    +    """Return ``(name, version, source)`` from the profile's ``distribution.yaml``
    +    if present; ``(None, None, None)`` otherwise.
    +
    +    Failures (missing file, bad YAML) are swallowed — a bad manifest should
    +    never break ``hermes profile list`` for an unrelated profile.
    +    """
    +    mf_path = profile_dir / "distribution.yaml"
    +    if not mf_path.is_file():
    +        return None, None, None
    +    try:
    +        import yaml
    +        with open(mf_path, "r") as f:
    +            data = yaml.safe_load(f) or {}
    +        if not isinstance(data, dict):
    +            return None, None, None
    +        return (
    +            data.get("name"),
    +            data.get("version"),
    +            data.get("source"),
    +        )
    +    except Exception:
    +        return None, None, None
     
     
     def _read_config_model(profile_dir: Path) -> tuple:
    ```

    ```diff
    @@ -384,6 +425,7 @@ def list_profiles() -> List[ProfileInfo]:
         default_home = _get_default_hermes_home()
         if default_home.is_dir():
             model, provider = _read_config_model(default_home)
    +        dist_name, dist_version, dist_source = _read_distribution_meta(default_home)
             profiles.append(ProfileInfo(
                 name="default",
                 path=default_home,
    ```

    ```diff
    @@ -393,6 +435,9 @@ def list_profiles() -> List[ProfileInfo]:
                 provider=provider,
                 has_env=(default_home / ".env").exists(),
                 skill_count=_count_skills(default_home),
    +            distribution_name=dist_name,
    +            distribution_version=dist_version,
    +            distribution_source=dist_source,
             ))
     
         # Named profiles
    ```

    ```diff
    @@ -406,6 +451,7 @@ def list_profiles() -> List[ProfileInfo]:
                     continue
                 model, provider = _read_config_model(entry)
                 alias_path = wrapper_dir / name
    +            dist_name, dist_version, dist_source = _read_distribution_meta(entry)
                 profiles.append(ProfileInfo(
                     name=name,
                     path=entry,
    ```

    ```diff
    @@ -416,6 +462,9 @@ def list_profiles() -> List[ProfileInfo]:
                     has_env=(entry / ".env").exists(),
                     skill_count=_count_skills(entry),
                     alias_path=alias_path if alias_path.exists() else None,
    +                distribution_name=dist_name,
    +                distribution_version=dist_version,
    +                distribution_source=dist_source,
                 ))
     
         return profiles
    ```

    ```diff
    @@ -588,6 +637,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
         model, provider = _read_config_model(profile_dir)
         gw_running = _check_gateway_running(profile_dir)
         skill_count = _count_skills(profile_dir)
    +    dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir)
     
         print(f"\nProfile: {canon}")
         print(f"Path:    {profile_dir}")
    ```

    ```diff
    @@ -595,6 +645,10 @@ def delete_profile(name: str, yes: bool = False) -> Path:
             print(f"Model:   {model}" + (f" ({provider})" if provider else ""))
         if skill_count:
             print(f"Skills:  {skill_count}")
    +    if dist_name:
    +        print(f"Distribution: {dist_name}@{dist_version or '?'}")
    +        if dist_source:
    +            print(f"Installed from: {dist_source}")
     
         items = [
             "All config, API keys, memories, sessions, skills, cron jobs",
    ```
  - `tests/hermes_cli/test_profile_distribution.py`

    ```diff
    @@ -0,0 +1,584 @@
    +"""Tests for hermes_cli.profile_distribution — git-based profile installs.
    +
    +Covers manifest parsing, version requirement checks, install / update / describe
    +on local-directory sources, and guards on what can and can't be installed.
    +
    +Transport-layer tests (git clone, URL handling) are exercised through live
    +E2E runs, not unit tests — git itself is tested upstream, and subprocess-
    +mocking git would just test the mock.
    +"""
    +
    +from __future__ import annotations
    +
    +import os
    +from pathlib import Path
    +
    +import pytest
    +
    +from hermes_cli.profile_distribution import (
    +    DEFAULT_DIST_OWNED,
    +    DistributionError,
    +    DistributionManifest,
    +    EnvRequirement,
    +    MANIFEST_FILENAME,
    +    USER_OWNED_EXCLUDE,
    +    _env_template_from_manifest,
    +    _looks_like_git_url,
    +    _parse_semver,
    +    check_hermes_requires,
    +    describe_distribution,
    +    install_distribution,
    +    plan_install,
    +    read_manifest,
    +    update_distribution,
    +    write_manifest,
    +)
    +
    +
    +# ---------------------------------------------------------------------------
    +# Isolated profile env (matches tests/hermes_cli/test_profiles.py)
    +# ---------------------------------------------------------------------------
    +
    +
    [redacted:email]()
    +def profile_env(tmp_path, monkeypatch):
    +    monkeypatch.setattr(Path, "home", lambda: tmp_path)
    +    default_home = tmp_path / ".hermes"
    +    default_home.mkdir(exist_ok=True)
    +    monkeypatch.setenv("HERMES_HOME", str(default_home))
    +    return tmp_path
    +
    +
    +def _make_staging_dir(root: Path, name: str = "src", *, manifest: DistributionManifest = None) -> Path:
    +    """Build a local distribution staging directory (what a git clone would
    +    contain after .git is removed).
    +
    +    Lays down a minimal but representative tree: SOUL.md, config.yaml,
    +    mcp.json, one skill, one cron file, plus the distribution.yaml manifest.
    +    """
    +    staged = root / f"staging_{name}"
    +    staged.mkdir(parents=True, exist_ok=True)
    +    (staged / "SOUL.md").write_text("I am Source.\n")
    +    (staged / "config.yaml").write_text("model:\n  model: gpt-4\n")
    +    (staged / "mcp.json").write_text('{"servers": {}}\n')
    +    (staged / "skills").mkdir(exist_ok=True)
    +    (staged / "skills" / "demo").mkdir(exist_ok=True)
    +    (staged / "skills" / "demo" / "SKILL.md").write_text(
    +        "---\nname: demo\ndescription: test\n---\n# Demo skill\n"
    +    )
    +    (staged / "cron").mkdir(exist_ok=True)
    +    (staged / "cron" / "daily.json").write_text('{"schedule": "0 9 * * *"}')
    +
    +    mf = manifest or DistributionManifest(name=name, version="0.1.0")
    +    write_manifest(staged, mf)
    +    return staged
    +
    +
    +# ===========================================================================
    +# Manifest parsing
    +# ===========================================================================
    +
    +
    +class TestManifestParsing:
    +
    +    def test_minimal_manifest(self, tmp_path):
    +        (tmp_path / MANIFEST_FILENAME).write_text("name: minimal\n")
    +        m = read_manifest(tmp_path)
    +        assert m.name == "minimal"
    +        assert m.version == "0.1.0"
    +        assert m.env_requires == []
    +        assert m.distribution_owned == []
    +
    +    def test_full_manifest(self, tmp_path):
    +        (tmp_path / MANIFEST_FILENAME).write_text(
    +            "name: telem\n"
    +            "version: 1.2.3\n"
    +            "description: Telem monitor\n"
    +            "hermes_requires: '>=0.12.0'\n"
    +            "author: Kyle\n"
    +            "license: MIT\n"
    +            "env_requires:\n"
    +            "  - name: OPENAI_API_KEY\n"
    +            "    description: OpenAI key\n"
    +            "  - name: GRAPH_URL\n"
    +            "    required: false\n"
    +            "    default: http://127.0.0.1:8000\n"
    +            "distribution_owned:\n"
    +            "  - SOUL.md\n"
    +            "  - skills/\n"
    +        )
    +        m = read_manifest(tmp_path)
    +        assert m.name == "telem"
    +        assert m.version == "1.2.3"
    +        assert m.author == "Kyle"
    +        assert m.license == "MIT"
    +        assert len(m.env_requires) == 2
    +        assert m.env_requires[0].name == "OPENAI_API_KEY"
    +        assert m.env_requires[0].required is True
    +        assert m.env_requires[1].required is False
    +        assert m.env_requires[1].default == "http://127.0.0.1:8000"
    +        assert m.distribution_owned == ["SOUL.md", "skills"]
    +
    +    def test_missing_name_rejected(self, tmp_path):
    +        (tmp_path / MANIFEST_FILENAME).write_text("version: 1.0\n")
    +        with pytest.raises(DistributionError, match="missing 'name'"):
    +            read_manifest(tmp_path)
    +
    +    def test_env_requires_not_list_rejected(self, tmp_path):
    +        (tmp_path / MANIFEST_FILENAME).write_text(
    +            "name: bad\nenv_requires:\n  name: FOO\n"
    +        )
    +        with pytest.raises(DistributionError, match="env_requires must be a list"):
    +            read_manifest(tmp_path)
    +
    +    def test_read_manifest_returns_none_when_absent(self, tmp_path):
    +        assert read_manifest(tmp_path) is None
    +
    +    def test_owned_paths_default(self):
    +        m = DistributionManifest(name="x")
    +        assert m.owned_paths() == list(DEFAULT_DIST_OWNED)
    +
    +    def test_owned_paths_explicit(self):
    +        m = DistributionManifest(name="x", distribution_owned=["SOUL.md", "skills"])
    +        assert m.owned_paths() == ["SOUL.md", "skills"]
    +
    +    def test_roundtrip_write_read(self, tmp_path):
    +        original = DistributionManifest(
    +            name="rt",
    +            version="1.0.0",
    +            description="roundtrip",
    +            env_requires=[EnvRequirement(name="FOO", description="foo")],
    +        )
    +        write_manifest(tmp_path, original)
    +        parsed = read_manifest(tmp_path)
    +        assert parsed.name == "rt"
    +        assert parsed.env_requires[0].name == "FOO"
    +
    +
    +# ===========================================================================
    +# Version requirement checks
    +# ===========================================================================
    +
    +
    +class TestVersionRequires:
    +
    +    @pytest.mark.parametrize("spec,cur,ok", [
    +        ("", "0.1.0", True),
    +        (">=0.12.0", "0.12.0", True),
    +        (">=0.12.0", "0.13.0", True),
    +        (">=0.12.0", "0.11.9", False),
    +        ("==0.12.0", "0.12.0", True),
    +        ("==0.12.0", "0.13.0", False),
    +        ("!=0.12.0", "0.13.0", True),
    +        (">0.12.0", "0.12.1", True),
    +        (">0.12.0", "0.12.0", False),
    +        ("<0.13.0", "0.12.9", True),
    +        ("<=0.12.0", "0.12.0", True),
    +        ("0.12.0", "0.13.0", True),     # Bare = >=
    +        ("0.12.0", "0.11.0", False),    # Bare = >=
    +    ])
    +    def test_check_matrix(self, spec, cur, ok):
    +        if ok:
    +            check_hermes_requires(spec, cur)
    +        else:
    +            with pytest.raises(DistributionError, match="requires Hermes"):
    +                check_hermes_requires(spec, cur)
    +
    +    def test_parse_semver_handles_prerelease(self):
    +        assert _parse_semver("0.12.0-rc1") == (0, 12, 0)
    +        assert _parse_semver("v0.12.0+abc") == (0, 12, 0)
    +
    +    def test_parse_semver_pads(self):
    +        assert _parse_semver("1") == (1, 0, 0)
    +        assert _parse_semver("1.2") == (1, 2, 0)
    +
    +    def test_parse_semver_rejects_garbage(self):
    +        with pytest.raises(DistributionError, match="Unparseable"):
    +            _parse_semver("not-a-version")
    +
    +
    +# ===========================================================================
    +# Env template
    +# ===========================================================================
    +
    +
    +class TestEnvTemplate:
    +
    +    def test_required_is_uncommented(self):
    +        m = DistributionManifest(
    +            name="x",
    +            env_requires=[EnvRequirement(name="FOO", description="foo key")],
    +        )
    +        out = _env_template_from_manifest(m)
    +        assert "# foo key" in out
    +        assert "# (required)" in out
    +        assert "FOO=" in out
    +        # No leading `# ` before FOO=
    +        assert "\nFOO=" in out or out.startswith("FOO=") or "\nFOO=\n" in out or "FOO=\n" in out
    +
    +    def test_optional_is_commented(self):
    +        m = DistributionManifest(
    +            name="x",
    +            env_requires=[EnvRequirement(name="BAR", required=False, default="http://x")],
    +        )
    +        out = _env_template_from_manifest(m)
    +        assert "# (optional)" in out
    +        assert "# BAR=http://x" in out
    +
    +    def test_empty_env_requires_is_header_only(self):
    +        m = DistributionManifest(name="x")
    +        out = _env_template_from_manifest(m)
    +        assert "Hermes distribution" in out
    +        assert "FOO" not in out
    +
    +
    +# ===========================================================================
    +# Source URL detection
    +# ===========================================================================
    +
    +
    +class TestLooksLikeGitUrl:
    +
    +    @pytest.mark.parametrize("src", [
    +        "github.com/user/repo",
    +        "https://github.com/user/repo",
    +        "https://github.com/user/repo.git",
    +        "http://example.com/repo",
    +        "[redacted:email]:user/repo.git",
    +        "ssh://[redacted:email]/repo.git",
    +        "git://example.com/repo.git",
    +    ])
    +    def test_accepts_git_sources(self, src):
    +        assert _looks_like_git_url(src)
    +
    +    @pytest.mark.parametrize("src", [
    +        "/tmp/local/path",
    +        "./relative/dir",
    +        "~/profile",
    +        "some-random-string",
    +    ])
    +    def test_rejects_non_git(self, src):
    +        assert not _looks_like_git_url(src)
    +
    +
    +# ===========================================================================
    +# Install — fresh and force (from a local-directory source)
    +# ===========================================================================
    +
    +
    +class TestInstall:
    +
    +    def test_install_from_directory(self, profile_env):
    +        staged = _make_staging_dir(profile_env, "src")
    +        plan = install_distribution(str(staged), name="installed")
    +        assert plan.target_dir.is_dir()
    +        assert (plan.target_dir / "SOUL.md").read_text() == "I am Source.\n"
    +        assert (plan.target_dir / "skills" / "demo" / "SKILL.md").exists()
    +        assert (plan.target_dir / "mcp.json").exists()
    +        # Manifest on disk records canonical name + provenance
    +        m = read_manifest(plan.target_dir)
    +        assert m.name == "installed"
    +        assert m.source == str(staged)
    +
    +    def test_install_uses_manifest_name_when_no_override(self, profile_env):
    +        mf = DistributionManifest(name="telem", version="1.0.0")
    +        staged = _make_staging_dir(profile_env, "telem", manifest=mf)
    +        plan = install_distribution(str(staged))
    +        assert plan.manifest.name == "telem"
    +        assert plan.target_dir.name == "telem"
    +
    +    def test_install_rejects_existing_without_force(self, profile_env):
    +        staged = _make_staging_dir(profile_env, "src")
    +        install_distribution(str(staged), name="existing")
    +        with pytest.raises(DistributionError, match="already exists"):
    +            install_distribution(str(staged), name="existing")
    +
    +    def test_install_with_force_overwrites(self, profile_env):
    +        staged = _make_staging_dir(profile_env, "src")
    +        install_distribution(str(staged), name="target")
    +        # Install again with --force succeeds
    +        plan = install_distribution(str(staged), name="target", force=True)
    +        assert plan.target_dir.is_dir()
    +
    +    def test_install_rejects_default_name(self, profile_env):
    +        staged = _make_staging_dir(profile_env, "src")
    +        with pytest.raises(DistributionError, match="Cannot install"):
    +            install_distribution(str(staged), name="default")
    +
    +    def test_install_rejects_non_distribution_directory(self, profile_env, tmp_path):
    +        bogus = tmp_path / "bogus_dir"
    +        bogus.mkdir()
    +        (bogus / "some_file").write_text("hi")
    +        with pytest.raises(DistributionError, match="No distribution.yaml"):
    +            plan_install(str(bogus), tmp_path / "work", override_name="x")
    +
    +    def test_install_rejects_unknown_source(self, profile_env, tmp_path):
    +        with pytest.raises(DistributionError, match="Cannot resolve"):
    +            plan_install("definitely-not-a-thing", tmp_path / "work", override_name="x")
    +
    +    def test_install_emits_env_example_when_manifest_has_env(self, profile_env):
    +        mf = DistributionManifest(
    +            name="needs_env",
    +            version="0.1.0",
    +            env_requires=[EnvRequirement(name="OPENAI_API_KEY", description="key")],
    +        )
    +        staged = _make_staging_dir(profile_env, "needs_env", manifest=mf)
    +        plan = install_distribution(str(staged), name="needs_env")
    +        example = plan.target_dir / ".env.EXAMPLE"
    +        assert example.is_file()
    +        assert "OPENAI_API_KEY" in example.read_text()
    +
    +    def test_install_enforces_hermes_requires(self, profile_env, monkeypatch):
    +        # Pin current Hermes version to something well below the requirement
    +        import hermes_cli
    +        monkeypatch.setattr(hermes_cli, "__version__", "0.1.0", raising=False)
    +
    +        mf = DistributionManifest(
    +            name="future",
    +            version="1.0.0",
    +            hermes_requires=">=99.0.0",
    +        )
    +        staged = _make_staging_dir(profile_env, "future", manifest=mf)
    +        with pytest.raises(DistributionError, match="requires Hermes"):
    +            install_distribution(str(staged), name="future")
    +
    +
    +# ===========================================================================
    +# Update — preserves user data, preserves config by default
    +# ===========================================================================
    +
    +
    +class TestUpdate:
    +
    +    def test_update_preserves_user_data(self, profile_env):
    +        # 1. Build staging dir, install
    +        staged = _make_staging_dir(profile_env, "src")
    +        plan = install_distribution(str(staged), name="telem")
    +
    +        # 2. Add user-owned data to the installed profile
    +        (plan.target_dir / "memories").mkdir(exist_ok=True)
    +        (plan.target_dir / "memories" / "MEMORY.md").write_text("# USER MEMORY\n")
    +        (plan.target_dir / ".env").write_text("OPENAI_API_KEY=[redacted:secret-assignment]")
    +        (plan.target_dir / "auth.json").write_text('{"user": "auth"}')
    +        (plan.target_dir / "sessions").mkdir(exist_ok=True)
    +        (plan.target_dir / "sessions" / "chat.json").write_text('{"s": 1}')
    +
    +        # 3. Bump source in the staging dir
    +        (staged / "SOUL.md").write_text("I am Source v2.\n")
    +
    +        # 4. Update
    +        update_distribution("telem", force_config=False)
    +
    +        # 5. Dist-owned changed
    +        assert (plan.target_dir / "SOUL.md").read_text() == "I am Source v2.\n"
    +        # 6. User-owned preserved
    +        assert (plan.target_dir / "memories" / "MEMORY.md").read_text() == "# USER MEMORY\n"
    +        assert (plan.target_dir / ".env").read_text() == "OPENAI_API_KEY=[redacted:secret-assignment]"
    +        assert (plan.target_dir / "auth.json").read_text() == '{"user": "auth"}'
    +        assert (plan.target_dir / "sessions" / "chat.json").read_text() == '{"s": 1}'
    +
    +    def test_update_preserves_config_by_default(self, profile_env):
    +        staged = _make_staging_dir(profile_env, "src")
    +        plan = install_distribution(str(staged), name="t2")
    +
    +        # User edits config
    +        (plan.target_dir / "config.yaml").write_text(
    +            "model:\n  model: gpt-5\n# user override\n"
    +        )
    +
    +        # Bump source config
    +        (staged / "config.yaml").write_text("model:\n  model: claude\n")
    +
    +        update_distribution("t2", force_config=False)
    +        assert "gpt-5" in (plan.target_dir / "config.yaml").read_text()
    +        assert "user override" in (plan.target_dir / "config.yaml").read_text()
    +
    +    def test_update_force_config_overwrites(self, profile_env):
    +        staged = _make_staging_dir(profile_env, "src")
    +        plan = install_distribution(str(staged), name="t3")
    +
    +        (plan.target_dir / "config.yaml").write_text("model:\n  model: gpt-5\n")
    +
    +        (staged / "config.yaml").write_text("model:\n  model: claude\n")
    +
    +        update_distribution("t3", force_config=True)
    +        assert "claude" in (plan.target_dir / "config.yaml").read_text()
    +        assert "gpt-5" not in (plan.target_dir / "config.yaml").read_text()
    +
    +    def test_update_missing_manifest_errors(self, profile_env):
    +        # Make a profile without a manifest; update must refuse
    +        from hermes_cli.profiles import create_profile
    +        create_profile(name="plain", no_alias=True)
    +        with pytest.raises(DistributionError, match="not a distribution"):
    +            update_distribution("plain")
    +
    +
    +# ===========================================================================
    +# describe_distribution — info subcommand
    +# ===========================================================================
    +
    +
    +class TestDescribe:
    +
    +    def test_describe_existing_distribution(self, profile_env):
    +        mf = DistributionManifest(
    +            name="telem",
    +            version="1.0.0",
    +            description="compliance monitor",
    +            env_requires=[EnvRequirement(name="API", description="api key")],
    +        )
    +        staged = _make_staging_dir(profile_env, "telem", manifest=mf)
    +        install_distribution(str(staged), name="telem")
    +        data = describe_distribution("telem")
    +        assert data["name"] == "telem"
    +        assert data["version"] == "1.0.0"
    +        assert data["env_requires"][0]["name"] == "API"
    +
    +    def test_describe_non_distribution_returns_empty(self, profile_env):
    +        from hermes_cli.profiles import create_profile
    +        create_profile(name="plain", no_alias=True)
    +        assert describe_distribution("plain") == {}
    +
    +    def test_describe_missing_profile_raises(self, profile_env):
    +        with pytest.raises(DistributionError, match="does not exist"):
    +            describe_distribution("nonexistent")
    +
    +
    +# ===========================================================================
    +# Security — USER_OWNED_EXCLUDE covers the right paths
    +# ===========================================================================
    +
    +
    +class TestSecurity:
    +
    +    def test_user_owned_exclude_covers_credentials(self):
    +        assert "auth.json" in USER_OWNED_EXCLUDE
    +        assert ".env" in USER_OWNED_EXCLUDE
    +        assert "memories" in USER_OWNED_EXCLUDE
    +        assert "sessions" in USER_OWNED_EXCLUDE
    +        assert "local" in USER_OWNED_EXCLUDE
    +
    +    def test_install_does_not_import_credentials_from_staging(self, profile_env):
    +        """If an author accidentally ships auth.json or .env in their
    +        staging dir, the installer must NOT copy them to the target profile."""
    +        staged = _make_staging_dir(profile_env, "src")
    +        # Author leaks credentials into the staging tree (shouldn't happen, but...)
    +        (staged / "auth.json").write_text('{"leaked": true}')
    +        (staged / ".env").write_text("LEAKED=1")
    +
    +        plan = install_distribution(str(staged), name="clean")
    +        assert not (plan.target_dir / "auth.json").exists(), "auth.json leaked"
    +        # Fresh profile may have its own .env via the bootstrap; what we care
    +        # about is that the leaked content didn't land in the target.
    +        if (plan.target_dir / ".env").exists():
    +            assert "LEAKED" not in (plan.target_dir / ".env").read_text()
    +
    +
    +# ===========================================================================
    +# Install-time metadata (installed_at stamp)
    +# ===========================================================================
    +
    +
    +class TestInstalledAtStamp:
    +
    +    def test_install_stamps_installed_at(self, profile_env):
    +        staged = _make_staging_dir(profile_env, "src")
    +        plan = install_distribution(str(staged), name="stamped")
    +        mf = read_manifest(plan.target_dir)
    +        assert mf.installed_at, "installed_at should be set after install"
    +        # ISO-8601 UTC sanity: starts with 4-digit year, contains 'T', ends with '+00:00'.
    +        assert mf.installed_at[:4].isdigit()
    +        assert "T" in mf.installed_at
    +        assert mf.installed_at.endswith("+00:00")
    +
    +    def test_update_refreshes_installed_at(self, profile_env, monkeypatch):
    +        staged = _make_staging_dir(profile_env, "src")
    +        install_distribution(str(staged), name="demo")
    +        from hermes_cli.profiles import get_profile_dir
    +        first = read_manifest(get_profile_dir("demo")).installed_at
    +
    +        # Freeze `datetime.now()` to a fixed future time so we can observe that
    +        # update writes a NEW stamp (installs within the same second otherwise
    +        # collide at iso-8601 seconds resolution).
    +        import datetime as _dt
    +        class _FakeDT(_dt.datetime):
    +            @classmethod
    +            def now(cls, tz=None):
    +                return _dt.datetime(2099, 1, 1, 0, 0, 0, tzinfo=tz or _dt.timezone.utc)
    +        monkeypatch.setattr(
    +            "hermes_cli.profile_distribution.datetime", _FakeDT, raising=True
    +        )
    +
    +        from hermes_cli.profile_distribution import update_distribution
    +        update_distribution("demo")
    +        refreshed = read_manifest(get_profile_dir("demo")).installed_at
    +        assert refreshed != first, "installed_at should change on update"
    +        assert refreshed.startswith("2099-01-01"), refreshed
    +
    +
    +# ===========================================================================
    +# ProfileInfo exposes distribution metadata
    +# ===========================================================================
    +
    +
    +class TestProfileInfoDistribution:
    +
    +    def test_installed_distribution_shows_in_list(self, profile_env):
    +        staged = _make_staging_dir(
    +            profile_env, "src",
    +            manifest=DistributionManifest(name="telem", version="1.2.3"),
    +        )
    +        install_distribution(str(staged), name="telem")
    +
    +        from hermes_cli.profiles import list_profiles
    +        rows = {p.name: p for p in list_profiles()}
    +        assert "telem" in rows
    +        row = rows["telem"]
    +        assert row.distribution_name == "telem"
    +        assert row.distribution_version == "1.2.3"
    +        assert row.distribution_source  # path populated, exact value depends on fixture
    +
    +    def test_plain_profile_has_no_distribution_fields(self, profile_env):
    +        from hermes_cli.profiles import create_profile, list_profiles
    +        create_profile(name="plain", no_alias=True)
    +        rows = {p.name: p for p in list_profiles()}
    +        assert rows["plain"].distribution_name is None
    +        assert rows["plain"].distribution_version is None
    +
    +    def test_malformed_manifest_does_not_break_list(self, profile_env):
    +        from hermes_cli.profiles import create_profile, list_profiles, get_profile_dir
    +        create_profile(name="brokenmeta", no_alias=True)
    +        # Write a distribution.yaml that isn't a valid mapping
    +        (get_profile_dir("brokenmeta") / "distribution.yaml").write_text(
    +            "not: [a, valid, mapping\n"  # broken YAML
    +        )
    +        # list_profiles must NOT raise; distribution_* stay None for this row.
    +        rows = {p.name: p for p in list_profiles()}
    +        assert rows["brokenmeta"].distribution_name is None
    +
    +
    +# ===========================================================================
    +# Error surfaces: validation failures should propagate as DistributionError
    +# or ValueError (both caught and rendered cleanly by the CLI handler)
    +# ===========================================================================
    +
    +
    +class TestErrorSurfaces:
    +
    +    def test_bad_profile_name_raises_valueerror_not_traceback(self, profile_env, tmp_path):
    +        """A manifest whose 'name' can't be used as a profile identifier
    +        should raise ValueError from validate_profile_name — the CLI handler
    +        catches both DistributionError and ValueError so users see a clean
    +        'Error: ...' line instead of a Python traceback.
    +        """
    +        mf = DistributionManifest(name="Invalid Name With Spaces", version="0.1.0")
    +        staged = _make_staging_dir(profile_env, "bad", manifest=mf)
    +        with pytest.raises((ValueError, DistributionError)):
    +            plan_install(str(staged), tmp_path / "work")
    +
    +    def test_path_traversal_name_rejected(self, profile_env, tmp_path):
    +        mf = DistributionManifest(name="../../etc/passwd", version="0.1.0")
    +        staged = _make_staging_dir(profile_env, "bad", manifest=mf)
    +        with pytest.raises((ValueError, DistributionError)):
    +            plan_install(str(staged), tmp_path / "work")
    +
    ```
  - `tests/hermes_cli/test_profiles.py`

    ```diff
    @@ -113,6 +113,14 @@ def test_empty_string_rejected(self):
             with pytest.raises(ValueError):
                 validate_profile_name("")
     
    +    @pytest.mark.parametrize("name", ["hermes", "test", "tmp", "root", "sudo"])
    +    def test_reserved_names_rejected(self, name):
    +        """Reserved names collide with the Hermes install itself or with
    +        common system binaries — reject them at validate time so
    +        create/install/rename all share one gate."""
    +        with pytest.raises(ValueError, match="reserved"):
    +            validate_profile_name(name)
    +
     
     # ===================================================================
     # TestGetProfileDir
    ```
  - `website/docs/reference/cli-commands.md`

    ```diff
    @@ -1076,8 +1076,11 @@ Manage profiles — multiple isolated Hermes instances, each with its own config
     | `show <name>` | Show profile details (home directory, config, etc.). |
     | `alias <name> [--remove] [--name NAME]` | Manage wrapper scripts for quick profile access. |
     | `rename <old> <new>` | Rename a profile. |
    -| `export <name> [-o FILE]` | Export a profile to a `.tar.gz` archive. |
    -| `import <archive> [--name NAME]` | Import a profile from a `.tar.gz` archive. |
    +| `export <name> [-o FILE]` | Export a profile to a `.tar.gz` archive (local backup). |
    +| `import <archive> [--name NAME]` | Import a profile from a `.tar.gz` archive (local restore). |
    +| `install <source> [--name N] [--alias] [--force] [-y]` | Install a profile distribution from a git URL or local directory. |
    +| `update <name> [--force-config] [-y]` | Re-pull a distribution; preserves user data (memories, sessions, auth). |
    +| `info <name>` | Show a profile's distribution manifest (version, requirements, source). |
     
     Examples:
     
    ```

    ```diff
    @@ -1088,6 +1091,8 @@ hermes profile use work
     hermes profile alias work --name h-work
     hermes profile export work -o work-backup.tar.gz
     hermes profile import work-backup.tar.gz --name restored
    +hermes profile install github.com/user/my-distro --alias
    +hermes profile update work
     hermes -p work chat -q "Hello from work profile"
     ```
     
    ```
  - `website/docs/reference/profile-commands.md`

    ```diff
    @@ -243,6 +243,161 @@ hermes profile import ./work-2026-03-29.tar.gz
     hermes profile import ./work-2026-03-29.tar.gz --name work-restored
     ```
     
    +## Distribution commands
    +
    +Distributions turn a profile into a shareable, versioned artifact published
    +as a **git repository**. A recipient installs the distribution with a single
    +command and can update it in place later without touching their local
    +memories, sessions, or credentials.
    +
    +`auth.json` and `.env` are never part of a distribution — they stay on the
    +installing user's machine.
    +
    +The recipient's user data (memories, sessions, auth, their own edits to
    +`.env`) is always preserved across the initial install and subsequent
    +updates.
    +
    +:::info
    +`hermes profile export` / `import` are still the right commands for
    +**local backup and restore** of a profile on your own machine. Distribution
    +(`install` / `update` / `info`) is a separate concept: ship a profile via
    +git so someone else can install it.
    +:::
    +
    +### `hermes profile install`
    +
    +```bash
    +hermes profile install <source> [--name <name>] [--alias] [--force] [--yes]
    +```
    +
    +Installs a profile distribution from a git URL or a local directory.
    +
    +| Option | Description |
    +|--------|-------------|
    +| `<source>` | Git URL (`github.com/user/repo`, `https://...`, `git@...`, `ssh://`, `git://`) or a local directory containing `distribution.yaml` at its root. |
    +| `--name NAME` | Override the profile name from the manifest. |
    +| `--alias` | Also create a shell wrapper (e.g. `telemetry` → `hermes -p telemetry`). |
    +| `--force` | Overwrite an existing profile of the same name. User data is still preserved. |
    +| `-y`, `--yes` | Skip the manifest-preview confirmation prompt. |
    +
    +The installer shows the manifest, lists required env vars, and warns about
    +cron jobs before asking for confirmation. Required env vars go into a
    +`.env.EXAMPLE` file you copy to `.env` and fill in.
    +
    +**Examples:**
    +
    +```bash
    +# Install from a GitHub repo (shorthand)
    +hermes profile install github.com/kyle/telemetry-distribution --alias
    +
    +# Install from a full HTTPS git URL
    +hermes profile install https://github.com/kyle/telemetry-distribution.git
    +
    +# Install from SSH
    +hermes profile install [redacted:email]:kyle/telemetry-distribution.git
    +
    +# Install from a local directory during development
    +hermes profile install ./telemetry/
    +```
    +
    +### `hermes profile update`
    +
    +```bash
    +hermes profile update <name> [--force-config] [--yes]
    +```
    +
    +Re-clones the distribution from its recorded source and applies updates.
    +Distribution-owned files (SOUL.md, skills/, cron/, mcp.json) are
    +overwritten; user data (memories, sessions, auth, .env) is never touched.
    +
    +`config.yaml` is preserved by default to keep your local overrides.
    +Pass `--force-config` to reset it to the distribution's shipped config.
    +
    +### `hermes profile info`
    +
    +```bash
    +hermes profile info <name>
    +```
    +
    +Prints the profile's distribution manifest — name, version, required
    +Hermes version, author, env var requirements, the source URL/path, and
    +the `Installed:` timestamp recorded when the distribution was last
    +`install`-ed or `update`-d. Useful for checking what a shared profile
    +needs before installing it, and for spotting "this profile was installed
    +6 months ago and hasn't been updated."
    +
    +`hermes profile list` also shows the distribution name and version in a
    +`Distribution` column, and `hermes profile show <name>` / `delete <name>`
    +surface the source URL so you can tell at a glance which profiles came
    +from a git repo vs. were created locally.
    +
    +### Private distributions
    +
    +A private git repository works as a distribution source with no extra
    +configuration — the install shells out to your normal `git` binary, so
    +whatever authentication your shell is already set up for (SSH key,
    +`git credential` helper, GitHub CLI's stored HTTPS credentials) applies
    +transparently.
    +
    +```bash
    +# Uses your SSH key, the same as any other `git clone`
    +hermes profile install [redacted:email]:your-org/internal-assistant.git
    +
    +# Uses your git credential helper
    +hermes profile install https://github.com/your-org/internal-assistant.git
    +```
    +
    +If a clone prompts for credentials interactively in your terminal during
    +install, that prompt flows through. Set up your auth the way you'd
    +normally use `git clone` against the same repo first, then install.
    +
    +### Distribution manifest (`distribution.yaml`)
    +
    +Every distribution has a `distribution.yaml` at the root of its repository:
    +
    +```yaml
    +name: telemetry
    +version: 0.1.0
    +description: "Compliance monitoring harness"
    +hermes_requires: ">=0.12.0"
    +author: "Your Name"
    +license: "MIT"
    +env_requires:
    +  - name: OPENAI_API_KEY
    +    description: "OpenAI API key"
    +    required: true
    +  - name: GRAPHITI_MCP_URL
    +    description: "Memory graph URL"
    +    required: false
    +    default: "http://127.0.0.1:8000/sse"
    +distribution_owned:   # optional; defaults to SOUL.md, config.yaml,
    +                      #   mcp.json, skills/, cron/, distribution.yaml
    +  - SOUL.md
    +  - skills/compliance/
    +  - cron/
    +```
    +
    +`hermes_requires` supports `>=`, `<=`, `==`, `!=`, `>`, `<`, or a bare
    +version (treated as `>=`). Install fails with a clear error if the current
    +Hermes version doesn't satisfy the spec.
    +
    +`distribution_owned` is optional. If set, only those paths are replaced on
    +update; anything else in the profile stays user-owned. If omitted, the
    +defaults above apply.
    +
    +### Publishing a distribution
    +
    +Authoring a distribution is just a git push:
    +
    +1. In your profile directory, create `distribution.yaml` with at least `name`
    +   and `version`.
    +2. Initialize a git repo (or use an existing one) and push to GitHub /
    +   GitLab / any host Hermes can clone from.
    +3. Tell recipients to run `hermes profile install <your-repo-url>`.
    +
    +Use git tags for versioned releases — recipients who clone `HEAD` get your
    +latest state, and you can always bump `version:` in the manifest.
    +
     ## `hermes -p` / `hermes --profile`
     
     ```bash
    ```
  - `website/package.json`

    ```diff
    @@ -15,7 +15,7 @@
         "write-translations": "docusaurus write-translations",
         "write-heading-ids": "docusaurus write-heading-ids",
         "typecheck": "tsc",
    -    "lint:diagrams": "ascii-guard lint docs"
    +    "lint:diagrams": "ascii-guard lint --exclude-code-blocks docs"
       },
       "dependencies": {
         "@docusaurus/core": "3.9.2",
    ```

## Fix status

- Merged to main in PR #20831 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-08`.
- Subsystem: `apps/desktop`
