diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 70d15d4c0f..8ac6fe3a43 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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) # ========================================================================= diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py new file mode 100644 index 0000000000..5e6be8c609 --- /dev/null +++ b/hermes_cli/profile_distribution.py @@ -0,0 +1,702 @@ +"""Profile distributions — shareable, packaged Hermes profiles via git. + +A distribution is a Hermes profile published as a git repository (or +installed from a local directory for development). Install with one command +from a git URL, update in place, and keep your local memories / sessions / +credentials untouched. + +Where this fits relative to the existing pieces: + +* ``hermes profile export/import`` — local backup / restore for a profile + on your own machine. NOT a distribution format. Stays as-is. +* ``hermes skills install `` — 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 [--name N] [--alias] [--force] [--yes] + hermes profile update [--force-config] [--yes] + hermes profile info + +```` is one of: + +* A git URL (``github.com/user/repo``, ``https://github.com/...``, ``git@...``, + ``ssh://``, ``git://``), optionally with ``#`` 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 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 --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() diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 93928364c4..a8bc229bf9 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -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", diff --git a/tests/hermes_cli/test_profile_distribution.py b/tests/hermes_cli/test_profile_distribution.py new file mode 100644 index 0000000000..46e00e33ca --- /dev/null +++ b/tests/hermes_cli/test_profile_distribution.py @@ -0,0 +1,584 @@ +"""Tests for hermes_cli.profile_distribution — git-based profile installs. + +Covers manifest parsing, version requirement checks, install / update / describe +on local-directory sources, and guards on what can and can't be installed. + +Transport-layer tests (git clone, URL handling) are exercised through live +E2E runs, not unit tests — git itself is tested upstream, and subprocess- +mocking git would just test the mock. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from hermes_cli.profile_distribution import ( + DEFAULT_DIST_OWNED, + DistributionError, + DistributionManifest, + EnvRequirement, + MANIFEST_FILENAME, + USER_OWNED_EXCLUDE, + _env_template_from_manifest, + _looks_like_git_url, + _parse_semver, + check_hermes_requires, + describe_distribution, + install_distribution, + plan_install, + read_manifest, + update_distribution, + write_manifest, +) + + +# --------------------------------------------------------------------------- +# Isolated profile env (matches tests/hermes_cli/test_profiles.py) +# --------------------------------------------------------------------------- + + +@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") + diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 130b1c39e4..88bc09b694 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -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 diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 390204e533..a82c782ca2 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1077,8 +1077,11 @@ Manage profiles — multiple isolated Hermes instances, each with its own config | `show ` | Show profile details (home directory, config, etc.). | | `alias [--remove] [--name NAME]` | Manage wrapper scripts for quick profile access. | | `rename ` | Rename a profile. | -| `export [-o FILE]` | Export a profile to a `.tar.gz` archive. | -| `import [--name NAME]` | Import a profile from a `.tar.gz` archive. | +| `export [-o FILE]` | Export a profile to a `.tar.gz` archive (local backup). | +| `import [--name NAME]` | Import a profile from a `.tar.gz` archive (local restore). | +| `install [--name N] [--alias] [--force] [-y]` | Install a profile distribution from a git URL or local directory. | +| `update [--force-config] [-y]` | Re-pull a distribution; preserves user data (memories, sessions, auth). | +| `info ` | 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" ``` diff --git a/website/docs/reference/profile-commands.md b/website/docs/reference/profile-commands.md index e4f28e8346..d4a1409b0d 100644 --- a/website/docs/reference/profile-commands.md +++ b/website/docs/reference/profile-commands.md @@ -243,6 +243,161 @@ hermes profile import ./work-2026-03-29.tar.gz hermes profile import ./work-2026-03-29.tar.gz --name work-restored ``` +## Distribution commands + +Distributions turn a profile into a shareable, versioned artifact published +as a **git repository**. A recipient installs the distribution with a single +command and can update it in place later without touching their local +memories, sessions, or credentials. + +`auth.json` and `.env` are never part of a distribution — they stay on the +installing user's machine. + +The recipient's user data (memories, sessions, auth, their own edits to +`.env`) is always preserved across the initial install and subsequent +updates. + +:::info +`hermes profile export` / `import` are still the right commands for +**local backup and restore** of a profile on your own machine. Distribution +(`install` / `update` / `info`) is a separate concept: ship a profile via +git so someone else can install it. +::: + +### `hermes profile install` + +```bash +hermes profile install [--name ] [--alias] [--force] [--yes] +``` + +Installs a profile distribution from a git URL or a local directory. + +| Option | Description | +|--------|-------------| +| `` | 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 [--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 +``` + +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 ` / `delete ` +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 `. + +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 diff --git a/website/package.json b/website/package.json index e3aa70fc47..fc21cd60a7 100644 --- a/website/package.json +++ b/website/package.json @@ -15,7 +15,7 @@ "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "typecheck": "tsc", - "lint:diagrams": "ascii-guard lint docs" + "lint:diagrams": "ascii-guard lint --exclude-code-blocks docs" }, "dependencies": { "@docusaurus/core": "3.9.2",