Profile export, import, and sharing features

NousResearch/hermes-agent · cluster 20456 · apps/desktop

Data as of

3 open items. Fix merged in #20831. Check it covers your case.

The short answer

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

Open issues3

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

Open PRs0

No open pull requests.

Fixes on record

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

  • #20831 feat(profile): shareable profile distributions via git

    merged to main

    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

    Show the diff
    • hermes_cli/main.py
      @@ -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 = (
      @@ -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":
      @@ -8234,6 +8245,7 @@ def cmd_profile(args):
                   _read_config_model,
                   _check_gateway_running,
                   _count_skills,
      +            _read_distribution_meta,
               )
       
               if not profile_exists(name):
      @@ -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}")
      @@ -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()
      @@ -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.
      @@ -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
      @@ -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
      @@ -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
      @@ -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:
      @@ -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:
      @@ -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,
      @@ -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
      @@ -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,
      @@ -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
      @@ -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}")
      @@ -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
      @@ -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
      @@ -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
      @@ -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:
       
      @@ -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
      @@ -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
      @@ -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",
Closed issues · 3
  • Closed#20456Feature Request: Profile Distributions (shareable, packaged Hermes profiles)
  • Closed#30661How can I export a master environment and save it to another computer
  • Closed#43784[Feature]: Shareable Profile Templates
How this cluster was grouped
  • Link evidence (partly structural links): 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 item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

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

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#20831
Closed issues
3
Attempts
0 PRs closed without merging
Fix link checked
8 May 2026

For your agent

The same facts, typed.