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

* feat(profile): shareable profile distributions (pack/install/update/info)

Closes #20456.

Turns a profile into a portable, versioned artifact. Packs SOUL.md, config,
skills, cron, and an env-var manifest into a tar.gz that others can install
from a local path, URL, or git repo. Updates re-pull the distribution while
preserving user data (memories, sessions, auth.json, .env) and the user's
config.yaml overrides.

New subcommands (under hermes profile, no parallel tree):
  hermes profile pack    <name> [-o FILE]
  hermes profile install <source> [--name N] [--alias] [--force] [-y]
  hermes profile update  <name> [--force-config] [-y]
  hermes profile info    <name>

Manifest (distribution.yaml at the profile root): name, version,
hermes_requires, author, env_requires, distribution_owned.

Security:
  - Installer shows manifest + env-var requirements before mutating disk;
    confirmation required unless -y.
  - auth.json and .env are never packed (same exclude set as profile export).
  - Cron jobs are packed but NOT auto-scheduled — user is pointed at
    'hermes -p <name> cron list' to review.
  - Archive extraction rejects path traversal (../ members).
  - Alias creation is opt-in via --alias.

Update semantics:
  - Distribution-owned paths (SOUL.md, skills/, cron/, mcp.json, manifest):
    replaced from the new archive.
  - config.yaml: preserved by default; --force-config to overwrite.
  - User-owned paths (memories/, sessions/, auth.json, .env, state.db*,
    logs/, workspace/, plans/, home/, *_cache/, local/): never touched.

Version pin:
  hermes_requires accepts >=, <=, ==, !=, >, < or a bare version (treated
  as >=). Install fails with a clear error when the running Hermes version
  doesn't satisfy the spec.

Sources supported by 'install':
  - Local .tar.gz / .tgz archive
  - Local directory
  - HTTP(S) URL pointing to a .tar.gz (uses httpx, already a dep)
  - Git URL (github.com/user/repo, https://..., git@..., ssh://, git://)

Tests: 43 new unit tests (manifest parsing, version checks, env template,
pack/install/update round-trip, config-preservation, security).
E2E validated via real CLI invocations against an isolated HERMES_HOME
covering pack, install with confirmation, update preservation, update
--force-config, decline-preview, duplicate-install rejection, and
version-requirement rejection.

* refactor(profile-dist): git-only — drop tar.gz/HTTP transports and pack

Scope-cut on top of the original distribution PR: a profile distribution
is now exclusively a git repository (or a local directory during
development). The tar.gz / HTTP archive transports and the matching
`hermes profile pack` subcommand have been removed.

Why:
* GitHub tags, branches, and commits are already the right versioning
  primitive. Tag pushes do for us what 'pack + upload' did.
* `hermes profile export` / `import` already cover local backup and
  restore; they are not a distribution format and stay untouched.
* One transport means one install/update code path, one doc page,
  and one mental model. The extra source types doubled the surface
  for no real user win — GitHub auto-attaches release tarballs, and
  `git bundle` / `git clone --mirror` cover the airgap case.

Changes:
* hermes_cli/profile_distribution.py — removed pack_profile,
  _fetch_tar_archive (_http_fetch), _safe_extract, _archive_roots,
  _safe_parts, _find_dist_root, tarfile/io/urlparse imports. The
  new _stage_source has two arms: git URL → clone, local directory
  → use in place.
* hermes_cli/main.py — removed the 'pack' subparser and action
  handler. Install help text updated to match the reduced source list.
* tests/hermes_cli/test_profile_distribution.py — rewritten around a
  local-directory staging fixture. The install/update/describe suites
  now build a distribution tree on disk directly and install from it,
  which is what a real git clone produces after .git is stripped.
  Dropped TestPack, TestFindDistRoot, and the tar-specific security
  test. New tests cover _looks_like_git_url, env_example emission,
  hermes_requires enforcement, and 'installer does not import
  credentials if an author mistakenly leaks them in the staging tree'.
* website/docs/reference/profile-commands.md — 'Distribution commands'
  section rewritten around git. Added a 'Publishing a distribution'
  section. export/import stay documented as local backup/restore.
* website/docs/reference/cli-commands.md — dropped 'pack' from the
  profile subcommand table.
* website/package.json — 'lint:diagrams' now passes
  --exclude-code-blocks to ascii-guard. Without it, markdown tables
  and box-drawing diagrams inside fenced code blocks were being
  misidentified as malformed ASCII boxes, blocking the PR's
  docs-site-checks CI with 8 false-positive errors.

Validation:
* Targeted suite: tests/hermes_cli/test_profile_distribution.py —
  56/56 pass (down from 43 — reorganized to cover the new
  local-dir paths).
* Regression: test_profiles.py + test_profile_export_credentials.py
  102/102 still pass. export/import behaviour unchanged.
* Docs lint: ascii-guard lint --exclude-code-blocks docs returns
  0 errors (was 8 on the PR before the flag bump).
* E2E: ran the real `hermes profile install`/`info` against a
  local staging dir under an isolated HERMES_HOME — install writes
  SOUL.md + skills to the target profile, info reads the manifest
  back, a bogus source produces a clear error, and `hermes profile
  pack` is now rejected by argparse as expected.

* feat(profile-dist): distribution-aware list/show/delete + installed_at + env preview

Polish pass on top of the git-only scope cut. Five additions, all small,
wiring into existing commands rather than adding new surface.

1. `installed_at` timestamp on the manifest
   * Stamped automatically inside plan_install() on both fresh install
     and update — ISO-8601 UTC, seconds resolution.
   * Surfaced in `hermes profile info` as `Installed:    <ts>`.
   * Lets users tell "installed 6 months ago, needs update" from
     "installed yesterday" without guessing from file mtimes.

2. `hermes profile list` grows a `Distribution` column
   * Plain profiles: "—"
   * Distribution profiles: "<name>@<version>" (e.g. `telemetry@1.2.3`)
   * ProfileInfo gains three optional fields — distribution_name,
     distribution_version, distribution_source — populated by a new
     _read_distribution_meta() helper that swallows manifest read errors
     so a broken distribution.yaml in one profile can't break `list`
     for the others.

3. `hermes profile show` and `hermes profile delete` surface
   distribution provenance
   * show: `Distribution: name@version` + `Installed from: <source>`
     plus a pointer to `hermes profile info <name>` for the full
     manifest.
   * delete: same lines in the pre-confirmation preview, so a user
     deleting "telemetry" can see it came from
     `github.com/kyle/telemetry-distribution` before they type
     `telemetry` to confirm. No change to the confirmation gate itself —
     deletion semantics are identical to plain profiles.

4. Install preview checks env vars against the current environment
   * Replaces the "Env vars you'll need to set:" header with a simpler
     "Env vars:" block.
   * Each required var is labeled:
     - `✓ set` — already in `os.environ` OR present as a key in the
       target profile's existing .env (update case).
     - `needs setting` — required but not found in either place.
     - `—` — optional.
   * Mirrors pip's "Requirement already satisfied" UX: no unnecessary
     nagging about keys the user already has configured.

5. Docs: private distributions
   * New "Private distributions" section in
     website/docs/reference/profile-commands.md explaining that we
     shell out to the user's `git` binary, so SSH keys / credential
     helpers / GitHub CLI stored creds all work transparently. One
     paragraph, two examples.
   * `hermes profile info` section updated to mention `Installed:`.

Module-level hoist:
* `from datetime import datetime, timezone` was previously lazy-imported
  inside plan_install(). Hoisted to module scope so tests can monkeypatch
  `hermes_cli.profile_distribution.datetime` to freeze time.

Tests (+7):
* TestInstalledAtStamp.test_install_stamps_installed_at — format check
  (4-digit year, 'T', +00:00 suffix).
* TestInstalledAtStamp.test_update_refreshes_installed_at — freezes
  datetime.now() to 2099-01-01 and confirms update writes a new stamp.
* TestProfileInfoDistribution.test_installed_distribution_shows_in_list
  — ProfileInfo.distribution_{name,version,source} populated after install.
* TestProfileInfoDistribution.test_plain_profile_has_no_distribution_fields
  — plain profiles have None.
* TestProfileInfoDistribution.test_malformed_manifest_does_not_break_list
  — broken distribution.yaml in one profile doesn't break list_profiles().

Validation:
* 163/163 tests pass (56 distribution + 102 profile regression +
  5 new from this commit — up from 158).
* docs-lint: 0 errors.
* E2E verified: install preview shows ✓/needs-setting per env var,
  `profile list` shows Distribution column, `profile show` + `delete`
  preview mentions source URL, `info` shows Installed: timestamp.

* fix(profile-dist): clean errors + warn when overwriting plain profiles

Two small polish fixes found during collision sweeps of the PR:

1. ValueError from validate_profile_name now caught cleanly
   * A distribution.yaml whose 'name' field can't be used as a profile
     identifier (spaces, path traversal, etc.) raises ValueError from
     hermes_cli.profiles.validate_profile_name, which was escaping as a
     raw Python traceback from 'hermes profile install/update/info'.
   * Broadened the except clause in all three handlers to catch
     (DistributionError, ValueError) — users now see:
       Error: Invalid profile name '../../etc/passwd'. Must match
              [a-z0-9][a-z0-9_-]{0,63}
     instead of a stack trace.

2. Install preview distinguishes plain profile overwrite from
   distribution re-install
   * When plan.target_dir exists and IS a distribution (has
     distribution.yaml), preview still shows the mild
       (profile exists — will overwrite distribution-owned files only)
   * When plan.target_dir exists but is a HAND-BUILT plain profile (no
     distribution.yaml), preview now shows a loud warning:
       ⚠ Profile exists but is NOT a distribution.  Installing here will
         overwrite its SOUL.md, skills/, cron/, and mcp.json.
         Your memories, sessions, auth.json, and .env will be preserved,
         but any hand-edits to distribution-owned files will be lost.
   * Users who type 'hermes profile install foo --force' against a
     profile they hand-built now see what they're signing up for. User
     data is still safe (memories, sessions, auth, .env are in
     USER_OWNED_EXCLUDE), but custom SOUL/skills get stomped.

Tests (+2):
* TestErrorSurfaces.test_bad_profile_name_raises_valueerror_not_traceback
* TestErrorSurfaces.test_path_traversal_name_rejected

Validation:
* 165/165 tests pass (was 163).
* E2E: bad manifest names produce 'Error: Invalid profile name ...'
  with no traceback; installing over a plain profile shows the warning;
  re-installing over an existing distribution shows the normal
  overwrite message.
* Bad HTTPS URLs still produce 'Error: git clone failed: ...' — git
  itself generates a clean enough message that no wrapper is needed.
* 'install .' works correctly from any cwd.

* fix(profiles): reject reserved names at validate time

Before: `hermes profile create hermes` / `profile install` / `profile rename`
all silently accepted reserved names like `hermes`, `test`, `tmp`, `root`,
`sudo`. The profile directory was created; only alias creation failed (via
check_alias_collision), leaving a confusingly-named profile on disk — e.g.
`~/.hermes/profiles/hermes/` sitting next to `~/.hermes/` itself.

The reserved set already exists (_RESERVED_NAMES, introduced alongside alias
collision detection). This commit moves the check up one layer to
validate_profile_name so every entry point — create, install, import,
rename, dashboard web API — shares the same gate.

The error message points the user at the cause without being cryptic:
  Error: Profile name 'hermes' is reserved — it collides with either the
  Hermes installation itself or a common system binary.  Pick a different
  name.

`default` continues to pass through (it's a special alias for ~/.hermes).
_HERMES_SUBCOMMANDS (`chat`, `model`, `gateway`, etc.) stays at
alias-collision time only — those are fine as bare profile names with
`--no-alias`.

Tests (+5): test_reserved_names_rejected parametrized over the full
_RESERVED_NAMES set, matching the existing pattern in TestValidateProfileName.

No existing test uses a reserved name as a profile identifier (greppped
create_profile("hermes|test|tmp|root|sudo") — zero hits).

Validation:
* 170/170 tests pass in the profile suites.
* E2E: `profile create hermes`, `profile install` with manifest
  name=hermes, and `profile install ... --name hermes` all produce the
  same clean `Error: Profile name 'hermes' is reserved ...` with rc=1
  and no traceback. Normal names (`mybot`) still work.
This commit is contained in:
Teknium 2026-05-08 10:04:32 -07:00 committed by GitHub
parent cf648a9b7e
commit f209a35859
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1791 additions and 6 deletions

View file

@ -8157,8 +8157,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 = (
@ -8172,7 +8178,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":
@ -8311,6 +8322,7 @@ def cmd_profile(args):
_read_config_model,
_check_gateway_running,
_count_skills,
_read_distribution_meta,
)
if not profile_exists(name):
@ -8320,6 +8332,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}")
@ -8334,6 +8347,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()
@ -8414,6 +8432,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.
@ -10663,6 +10883,63 @@ Examples:
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)
# =========================================================================

View file

@ -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()

View file

@ -221,6 +221,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
@ -229,6 +235,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:
@ -345,6 +357,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:
@ -400,6 +441,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,
@ -409,6 +451,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
@ -422,6 +467,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,
@ -432,6 +478,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
@ -640,6 +689,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}")
@ -647,6 +697,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",

View file

@ -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)
# ---------------------------------------------------------------------------
@pytest.fixture()
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",
"git@github.com:user/repo.git",
"ssh://git@example.com/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=sk-user\n")
(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=sk-user\n"
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")

View file

@ -116,6 +116,14 @@ class TestValidateProfileName:
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

View file

@ -1077,8 +1077,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:
@ -1089,6 +1092,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"
```

View file

@ -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 git@github.com: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 git@github.com: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

View file

@ -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",