hermes-agent/hermes_cli/profile_distribution.py
Teknium f209a35859
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.
2026-05-08 10:04:32 -07:00

702 lines
24 KiB
Python

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