mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-09 03:11:58 +00:00
feat(profile): shareable profile distributions via git (#20831)
* feat(profile): shareable profile distributions (pack/install/update/info) Closes #20456. Turns a profile into a portable, versioned artifact. Packs SOUL.md, config, skills, cron, and an env-var manifest into a tar.gz that others can install from a local path, URL, or git repo. Updates re-pull the distribution while preserving user data (memories, sessions, auth.json, .env) and the user's config.yaml overrides. New subcommands (under hermes profile, no parallel tree): hermes profile pack <name> [-o FILE] hermes profile install <source> [--name N] [--alias] [--force] [-y] hermes profile update <name> [--force-config] [-y] hermes profile info <name> Manifest (distribution.yaml at the profile root): name, version, hermes_requires, author, env_requires, distribution_owned. Security: - Installer shows manifest + env-var requirements before mutating disk; confirmation required unless -y. - auth.json and .env are never packed (same exclude set as profile export). - Cron jobs are packed but NOT auto-scheduled — user is pointed at 'hermes -p <name> cron list' to review. - Archive extraction rejects path traversal (../ members). - Alias creation is opt-in via --alias. Update semantics: - Distribution-owned paths (SOUL.md, skills/, cron/, mcp.json, manifest): replaced from the new archive. - config.yaml: preserved by default; --force-config to overwrite. - User-owned paths (memories/, sessions/, auth.json, .env, state.db*, logs/, workspace/, plans/, home/, *_cache/, local/): never touched. Version pin: hermes_requires accepts >=, <=, ==, !=, >, < or a bare version (treated as >=). Install fails with a clear error when the running Hermes version doesn't satisfy the spec. Sources supported by 'install': - Local .tar.gz / .tgz archive - Local directory - HTTP(S) URL pointing to a .tar.gz (uses httpx, already a dep) - Git URL (github.com/user/repo, https://..., git@..., ssh://, git://) Tests: 43 new unit tests (manifest parsing, version checks, env template, pack/install/update round-trip, config-preservation, security). E2E validated via real CLI invocations against an isolated HERMES_HOME covering pack, install with confirmation, update preservation, update --force-config, decline-preview, duplicate-install rejection, and version-requirement rejection. * refactor(profile-dist): git-only — drop tar.gz/HTTP transports and pack Scope-cut on top of the original distribution PR: a profile distribution is now exclusively a git repository (or a local directory during development). The tar.gz / HTTP archive transports and the matching `hermes profile pack` subcommand have been removed. Why: * GitHub tags, branches, and commits are already the right versioning primitive. Tag pushes do for us what 'pack + upload' did. * `hermes profile export` / `import` already cover local backup and restore; they are not a distribution format and stay untouched. * One transport means one install/update code path, one doc page, and one mental model. The extra source types doubled the surface for no real user win — GitHub auto-attaches release tarballs, and `git bundle` / `git clone --mirror` cover the airgap case. Changes: * hermes_cli/profile_distribution.py — removed pack_profile, _fetch_tar_archive (_http_fetch), _safe_extract, _archive_roots, _safe_parts, _find_dist_root, tarfile/io/urlparse imports. The new _stage_source has two arms: git URL → clone, local directory → use in place. * hermes_cli/main.py — removed the 'pack' subparser and action handler. Install help text updated to match the reduced source list. * tests/hermes_cli/test_profile_distribution.py — rewritten around a local-directory staging fixture. The install/update/describe suites now build a distribution tree on disk directly and install from it, which is what a real git clone produces after .git is stripped. Dropped TestPack, TestFindDistRoot, and the tar-specific security test. New tests cover _looks_like_git_url, env_example emission, hermes_requires enforcement, and 'installer does not import credentials if an author mistakenly leaks them in the staging tree'. * website/docs/reference/profile-commands.md — 'Distribution commands' section rewritten around git. Added a 'Publishing a distribution' section. export/import stay documented as local backup/restore. * website/docs/reference/cli-commands.md — dropped 'pack' from the profile subcommand table. * website/package.json — 'lint:diagrams' now passes --exclude-code-blocks to ascii-guard. Without it, markdown tables and box-drawing diagrams inside fenced code blocks were being misidentified as malformed ASCII boxes, blocking the PR's docs-site-checks CI with 8 false-positive errors. Validation: * Targeted suite: tests/hermes_cli/test_profile_distribution.py — 56/56 pass (down from 43 — reorganized to cover the new local-dir paths). * Regression: test_profiles.py + test_profile_export_credentials.py 102/102 still pass. export/import behaviour unchanged. * Docs lint: ascii-guard lint --exclude-code-blocks docs returns 0 errors (was 8 on the PR before the flag bump). * E2E: ran the real `hermes profile install`/`info` against a local staging dir under an isolated HERMES_HOME — install writes SOUL.md + skills to the target profile, info reads the manifest back, a bogus source produces a clear error, and `hermes profile pack` is now rejected by argparse as expected. * feat(profile-dist): distribution-aware list/show/delete + installed_at + env preview Polish pass on top of the git-only scope cut. Five additions, all small, wiring into existing commands rather than adding new surface. 1. `installed_at` timestamp on the manifest * Stamped automatically inside plan_install() on both fresh install and update — ISO-8601 UTC, seconds resolution. * Surfaced in `hermes profile info` as `Installed: <ts>`. * Lets users tell "installed 6 months ago, needs update" from "installed yesterday" without guessing from file mtimes. 2. `hermes profile list` grows a `Distribution` column * Plain profiles: "—" * Distribution profiles: "<name>@<version>" (e.g. `telemetry@1.2.3`) * ProfileInfo gains three optional fields — distribution_name, distribution_version, distribution_source — populated by a new _read_distribution_meta() helper that swallows manifest read errors so a broken distribution.yaml in one profile can't break `list` for the others. 3. `hermes profile show` and `hermes profile delete` surface distribution provenance * show: `Distribution: name@version` + `Installed from: <source>` plus a pointer to `hermes profile info <name>` for the full manifest. * delete: same lines in the pre-confirmation preview, so a user deleting "telemetry" can see it came from `github.com/kyle/telemetry-distribution` before they type `telemetry` to confirm. No change to the confirmation gate itself — deletion semantics are identical to plain profiles. 4. Install preview checks env vars against the current environment * Replaces the "Env vars you'll need to set:" header with a simpler "Env vars:" block. * Each required var is labeled: - `✓ set` — already in `os.environ` OR present as a key in the target profile's existing .env (update case). - `needs setting` — required but not found in either place. - `—` — optional. * Mirrors pip's "Requirement already satisfied" UX: no unnecessary nagging about keys the user already has configured. 5. Docs: private distributions * New "Private distributions" section in website/docs/reference/profile-commands.md explaining that we shell out to the user's `git` binary, so SSH keys / credential helpers / GitHub CLI stored creds all work transparently. One paragraph, two examples. * `hermes profile info` section updated to mention `Installed:`. Module-level hoist: * `from datetime import datetime, timezone` was previously lazy-imported inside plan_install(). Hoisted to module scope so tests can monkeypatch `hermes_cli.profile_distribution.datetime` to freeze time. Tests (+7): * TestInstalledAtStamp.test_install_stamps_installed_at — format check (4-digit year, 'T', +00:00 suffix). * TestInstalledAtStamp.test_update_refreshes_installed_at — freezes datetime.now() to 2099-01-01 and confirms update writes a new stamp. * TestProfileInfoDistribution.test_installed_distribution_shows_in_list — ProfileInfo.distribution_{name,version,source} populated after install. * TestProfileInfoDistribution.test_plain_profile_has_no_distribution_fields — plain profiles have None. * TestProfileInfoDistribution.test_malformed_manifest_does_not_break_list — broken distribution.yaml in one profile doesn't break list_profiles(). Validation: * 163/163 tests pass (56 distribution + 102 profile regression + 5 new from this commit — up from 158). * docs-lint: 0 errors. * E2E verified: install preview shows ✓/needs-setting per env var, `profile list` shows Distribution column, `profile show` + `delete` preview mentions source URL, `info` shows Installed: timestamp. * fix(profile-dist): clean errors + warn when overwriting plain profiles Two small polish fixes found during collision sweeps of the PR: 1. ValueError from validate_profile_name now caught cleanly * A distribution.yaml whose 'name' field can't be used as a profile identifier (spaces, path traversal, etc.) raises ValueError from hermes_cli.profiles.validate_profile_name, which was escaping as a raw Python traceback from 'hermes profile install/update/info'. * Broadened the except clause in all three handlers to catch (DistributionError, ValueError) — users now see: Error: Invalid profile name '../../etc/passwd'. Must match [a-z0-9][a-z0-9_-]{0,63} instead of a stack trace. 2. Install preview distinguishes plain profile overwrite from distribution re-install * When plan.target_dir exists and IS a distribution (has distribution.yaml), preview still shows the mild (profile exists — will overwrite distribution-owned files only) * When plan.target_dir exists but is a HAND-BUILT plain profile (no distribution.yaml), preview now shows a loud warning: ⚠ Profile exists but is NOT a distribution. Installing here will overwrite its SOUL.md, skills/, cron/, and mcp.json. Your memories, sessions, auth.json, and .env will be preserved, but any hand-edits to distribution-owned files will be lost. * Users who type 'hermes profile install foo --force' against a profile they hand-built now see what they're signing up for. User data is still safe (memories, sessions, auth, .env are in USER_OWNED_EXCLUDE), but custom SOUL/skills get stomped. Tests (+2): * TestErrorSurfaces.test_bad_profile_name_raises_valueerror_not_traceback * TestErrorSurfaces.test_path_traversal_name_rejected Validation: * 165/165 tests pass (was 163). * E2E: bad manifest names produce 'Error: Invalid profile name ...' with no traceback; installing over a plain profile shows the warning; re-installing over an existing distribution shows the normal overwrite message. * Bad HTTPS URLs still produce 'Error: git clone failed: ...' — git itself generates a clean enough message that no wrapper is needed. * 'install .' works correctly from any cwd. * fix(profiles): reject reserved names at validate time Before: `hermes profile create hermes` / `profile install` / `profile rename` all silently accepted reserved names like `hermes`, `test`, `tmp`, `root`, `sudo`. The profile directory was created; only alias creation failed (via check_alias_collision), leaving a confusingly-named profile on disk — e.g. `~/.hermes/profiles/hermes/` sitting next to `~/.hermes/` itself. The reserved set already exists (_RESERVED_NAMES, introduced alongside alias collision detection). This commit moves the check up one layer to validate_profile_name so every entry point — create, install, import, rename, dashboard web API — shares the same gate. The error message points the user at the cause without being cryptic: Error: Profile name 'hermes' is reserved — it collides with either the Hermes installation itself or a common system binary. Pick a different name. `default` continues to pass through (it's a special alias for ~/.hermes). _HERMES_SUBCOMMANDS (`chat`, `model`, `gateway`, etc.) stays at alias-collision time only — those are fine as bare profile names with `--no-alias`. Tests (+5): test_reserved_names_rejected parametrized over the full _RESERVED_NAMES set, matching the existing pattern in TestValidateProfileName. No existing test uses a reserved name as a profile identifier (greppped create_profile("hermes|test|tmp|root|sudo") — zero hits). Validation: * 170/170 tests pass in the profile suites. * E2E: `profile create hermes`, `profile install` with manifest name=hermes, and `profile install ... --name hermes` all produce the same clean `Error: Profile name 'hermes' is reserved ...` with rc=1 and no traceback. Normal names (`mybot`) still work.
This commit is contained in:
parent
cf648a9b7e
commit
f209a35859
8 changed files with 1791 additions and 6 deletions
|
|
@ -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)
|
||||
|
||||
# =========================================================================
|
||||
|
|
|
|||
702
hermes_cli/profile_distribution.py
Normal file
702
hermes_cli/profile_distribution.py
Normal file
|
|
@ -0,0 +1,702 @@
|
|||
"""Profile distributions — shareable, packaged Hermes profiles via git.
|
||||
|
||||
A distribution is a Hermes profile published as a git repository (or
|
||||
installed from a local directory for development). Install with one command
|
||||
from a git URL, update in place, and keep your local memories / sessions /
|
||||
credentials untouched.
|
||||
|
||||
Where this fits relative to the existing pieces:
|
||||
|
||||
* ``hermes profile export/import`` — local backup / restore for a profile
|
||||
on your own machine. NOT a distribution format. Stays as-is.
|
||||
* ``hermes skills install <url>`` — the URL install pattern we're mirroring,
|
||||
but at the profile granularity.
|
||||
|
||||
Subcommands (all live under ``hermes profile``, not a parallel tree):
|
||||
|
||||
hermes profile install <source> [--name N] [--alias] [--force] [--yes]
|
||||
hermes profile update <name> [--force-config] [--yes]
|
||||
hermes profile info <name>
|
||||
|
||||
``<source>`` is one of:
|
||||
|
||||
* A git URL (``github.com/user/repo``, ``https://github.com/...``, ``git@...``,
|
||||
``ssh://``, ``git://``), optionally with ``#<ref>`` to pin a tag / branch /
|
||||
commit SHA.
|
||||
* A local directory that already contains ``distribution.yaml`` — used
|
||||
during profile development before the first push.
|
||||
|
||||
Manifest format (``distribution.yaml`` at the profile root)::
|
||||
|
||||
name: telemetry
|
||||
version: 0.1.0
|
||||
description: "Compliance monitoring harness"
|
||||
hermes_requires: ">=0.12.0"
|
||||
author: "..."
|
||||
license: "..."
|
||||
env_requires:
|
||||
- name: OPENAI_API_KEY
|
||||
description: "OpenAI API key"
|
||||
required: true
|
||||
- name: GRAPHITI_MCP_URL
|
||||
description: "Memory graph URL"
|
||||
required: false
|
||||
default: "http://127.0.0.1:8000/sse"
|
||||
distribution_owned: # optional; sensible defaults apply
|
||||
- SOUL.md
|
||||
- skills/
|
||||
- cron/
|
||||
- mcp.json
|
||||
|
||||
Update semantics:
|
||||
|
||||
* Distribution-owned paths (SOUL.md, mcp.json, skills/, cron/,
|
||||
distribution.yaml) are replaced from the new source.
|
||||
* ``config.yaml`` is distribution-owned but preserved on update unless
|
||||
``--force-config`` is passed (user overrides typically live here).
|
||||
* User-owned paths (memories/, sessions/, state.db, auth.json, .env,
|
||||
logs/, workspace/, home/, plans/, *_cache/, and anything under
|
||||
``local/``) are never touched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MANIFEST_FILENAME = "distribution.yaml"
|
||||
ENV_TEMPLATE_FILENAME = ".env.template"
|
||||
ENV_EXAMPLE_FILENAME = ".env.EXAMPLE"
|
||||
|
||||
# Default distribution-owned paths (relative to profile root). Authors may
|
||||
# override via ``distribution_owned:`` in the manifest. config.yaml is
|
||||
# distribution-owned but treated specially on update (see _is_config_like).
|
||||
DEFAULT_DIST_OWNED: Tuple[str, ...] = (
|
||||
"SOUL.md",
|
||||
"config.yaml",
|
||||
"mcp.json",
|
||||
"skills",
|
||||
"cron",
|
||||
MANIFEST_FILENAME,
|
||||
)
|
||||
|
||||
# Paths that are NEVER part of a distribution. These are user-owned and are
|
||||
# protected on update. Must stay consistent with
|
||||
# ``profiles.py::_DEFAULT_EXPORT_EXCLUDE_ROOT`` plus the ``local/``
|
||||
# convention for user customizations.
|
||||
USER_OWNED_EXCLUDE: frozenset = frozenset({
|
||||
# Credentials & runtime secrets
|
||||
"auth.json", ".env",
|
||||
# Databases & runtime state
|
||||
"state.db", "state.db-shm", "state.db-wal",
|
||||
"hermes_state.db", "response_store.db",
|
||||
"response_store.db-shm", "response_store.db-wal",
|
||||
"gateway.pid", "gateway_state.json", "processes.json",
|
||||
"auth.lock", "active_profile", ".update_check",
|
||||
"errors.log", ".hermes_history",
|
||||
# User data
|
||||
"memories", "sessions", "logs", "plans", "workspace", "home",
|
||||
"image_cache", "audio_cache", "document_cache",
|
||||
"browser_screenshots", "checkpoints", "sandboxes",
|
||||
"backups", "cache",
|
||||
# Infrastructure
|
||||
"hermes-agent", ".worktrees", "profiles", "bin", "node_modules",
|
||||
# User customization namespace
|
||||
"local",
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DistributionError(Exception):
|
||||
"""Raised for distribution install/update failures."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvRequirement:
|
||||
name: str
|
||||
description: str = ""
|
||||
required: bool = True
|
||||
default: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Any) -> "EnvRequirement":
|
||||
if not isinstance(data, dict):
|
||||
raise DistributionError(
|
||||
f"env_requires entry must be a mapping, got {type(data).__name__}"
|
||||
)
|
||||
name = str(data.get("name") or "").strip()
|
||||
if not name:
|
||||
raise DistributionError("env_requires entry missing 'name'")
|
||||
return cls(
|
||||
name=name,
|
||||
description=str(data.get("description") or ""),
|
||||
required=bool(data.get("required", True)),
|
||||
default=data.get("default"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {"name": self.name, "description": self.description}
|
||||
if not self.required:
|
||||
out["required"] = False
|
||||
if self.default is not None:
|
||||
out["default"] = self.default
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class DistributionManifest:
|
||||
name: str
|
||||
version: str = "0.1.0"
|
||||
description: str = ""
|
||||
hermes_requires: str = ""
|
||||
author: str = ""
|
||||
license: str = ""
|
||||
env_requires: List[EnvRequirement] = field(default_factory=list)
|
||||
distribution_owned: List[str] = field(default_factory=list)
|
||||
# Tracked after install — where we pulled from, so ``update`` can re-pull.
|
||||
source: str = ""
|
||||
# ISO-8601 UTC timestamp written on install / update, so ``info`` and
|
||||
# ``list`` can show when a distribution landed on disk. Empty for
|
||||
# manifests that ship in a repo (authors don't populate this).
|
||||
installed_at: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Any) -> "DistributionManifest":
|
||||
if not isinstance(data, dict):
|
||||
raise DistributionError(
|
||||
f"{MANIFEST_FILENAME} must be a mapping, got {type(data).__name__}"
|
||||
)
|
||||
name = str(data.get("name") or "").strip()
|
||||
if not name:
|
||||
raise DistributionError(f"{MANIFEST_FILENAME} missing 'name'")
|
||||
env_raw = data.get("env_requires") or []
|
||||
if not isinstance(env_raw, list):
|
||||
raise DistributionError("env_requires must be a list")
|
||||
env_requires = [EnvRequirement.from_dict(e) for e in env_raw]
|
||||
dist_owned_raw = data.get("distribution_owned") or []
|
||||
if dist_owned_raw and not isinstance(dist_owned_raw, list):
|
||||
raise DistributionError("distribution_owned must be a list")
|
||||
distribution_owned = [str(p).strip().strip("/") for p in dist_owned_raw if str(p).strip()]
|
||||
return cls(
|
||||
name=name,
|
||||
version=str(data.get("version") or "0.1.0"),
|
||||
description=str(data.get("description") or ""),
|
||||
hermes_requires=str(data.get("hermes_requires") or ""),
|
||||
author=str(data.get("author") or ""),
|
||||
license=str(data.get("license") or ""),
|
||||
env_requires=env_requires,
|
||||
distribution_owned=distribution_owned,
|
||||
source=str(data.get("source") or ""),
|
||||
installed_at=str(data.get("installed_at") or ""),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
}
|
||||
if self.description:
|
||||
out["description"] = self.description
|
||||
if self.hermes_requires:
|
||||
out["hermes_requires"] = self.hermes_requires
|
||||
if self.author:
|
||||
out["author"] = self.author
|
||||
if self.license:
|
||||
out["license"] = self.license
|
||||
if self.env_requires:
|
||||
out["env_requires"] = [e.to_dict() for e in self.env_requires]
|
||||
if self.distribution_owned:
|
||||
out["distribution_owned"] = self.distribution_owned
|
||||
if self.source:
|
||||
out["source"] = self.source
|
||||
if self.installed_at:
|
||||
out["installed_at"] = self.installed_at
|
||||
return out
|
||||
|
||||
def owned_paths(self) -> List[str]:
|
||||
"""Resolve which paths count as distribution-owned."""
|
||||
if self.distribution_owned:
|
||||
return list(self.distribution_owned)
|
||||
return list(DEFAULT_DIST_OWNED)
|
||||
|
||||
|
||||
def _load_yaml(text: str) -> Any:
|
||||
try:
|
||||
import yaml
|
||||
except ImportError as exc: # pragma: no cover — pyyaml is a hard dep
|
||||
raise DistributionError("PyYAML is required for distribution manifests") from exc
|
||||
return yaml.safe_load(text)
|
||||
|
||||
|
||||
def _dump_yaml(data: Any) -> str:
|
||||
import yaml
|
||||
|
||||
return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
|
||||
|
||||
|
||||
def read_manifest(profile_dir: Path) -> Optional[DistributionManifest]:
|
||||
"""Return the manifest for *profile_dir*, or None if it isn't a distribution."""
|
||||
mf_path = profile_dir / MANIFEST_FILENAME
|
||||
if not mf_path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = _load_yaml(mf_path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
raise DistributionError(f"Failed to parse {mf_path}: {exc}") from exc
|
||||
return DistributionManifest.from_dict(data or {})
|
||||
|
||||
|
||||
def write_manifest(profile_dir: Path, manifest: DistributionManifest) -> Path:
|
||||
mf_path = profile_dir / MANIFEST_FILENAME
|
||||
mf_path.write_text(_dump_yaml(manifest.to_dict()), encoding="utf-8")
|
||||
return mf_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_VERSION_OP_RE = re.compile(r"^\s*(>=|<=|==|!=|>|<)\s*(.+?)\s*$")
|
||||
|
||||
|
||||
def _parse_semver(v: str) -> Tuple[int, int, int]:
|
||||
"""Very small semver parser — major.minor.patch only. Extra labels stripped."""
|
||||
s = str(v).strip().lstrip("v")
|
||||
# Strip any pre-release / build metadata (e.g. "0.12.0-rc1+abc")
|
||||
s = re.split(r"[-+]", s, 1)[0]
|
||||
parts = s.split(".")
|
||||
while len(parts) < 3:
|
||||
parts.append("0")
|
||||
try:
|
||||
return (int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
except ValueError as exc:
|
||||
raise DistributionError(f"Unparseable version: {v!r}") from exc
|
||||
|
||||
|
||||
def check_hermes_requires(spec: str, current_version: str) -> None:
|
||||
"""Raise DistributionError if ``current_version`` does not satisfy ``spec``.
|
||||
|
||||
``spec`` accepts a single comparator (``>=0.12.0``, ``==0.12.0``, etc.).
|
||||
Empty or blank spec is a no-op — no requirement.
|
||||
"""
|
||||
if not spec or not spec.strip():
|
||||
return
|
||||
m = _VERSION_OP_RE.match(spec)
|
||||
if not m:
|
||||
# Bare version → treat as ``>=``
|
||||
op, target = ">=", spec.strip()
|
||||
else:
|
||||
op, target = m.group(1), m.group(2)
|
||||
cur = _parse_semver(current_version)
|
||||
tgt = _parse_semver(target)
|
||||
ok = {
|
||||
">=": cur >= tgt,
|
||||
"<=": cur <= tgt,
|
||||
"==": cur == tgt,
|
||||
"!=": cur != tgt,
|
||||
">": cur > tgt,
|
||||
"<": cur < tgt,
|
||||
}[op]
|
||||
if not ok:
|
||||
raise DistributionError(
|
||||
f"This distribution requires Hermes {op}{target}, "
|
||||
f"but you have {current_version}."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Env var template helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _env_template_from_manifest(manifest: DistributionManifest) -> str:
|
||||
"""Generate a ``.env.template`` body from env_requires."""
|
||||
lines = [
|
||||
"# Environment variables required by this Hermes distribution.",
|
||||
"# Copy to `.env` and fill in your own values before running.",
|
||||
"",
|
||||
]
|
||||
for req in manifest.env_requires:
|
||||
if req.description:
|
||||
lines.append(f"# {req.description}")
|
||||
status = "required" if req.required else "optional"
|
||||
lines.append(f"# ({status})")
|
||||
default_val = req.default if req.default is not None else ""
|
||||
prefix = "" if req.required else "# "
|
||||
lines.append(f"{prefix}{req.name}={default_val}")
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source staging — git clone or local directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _looks_like_git_url(s: str) -> bool:
|
||||
s = s.strip()
|
||||
if s.endswith(".git"):
|
||||
return True
|
||||
if s.startswith(("git@", "ssh://", "git://")):
|
||||
return True
|
||||
if s.startswith(("http://", "https://")):
|
||||
# Any http(s) URL is treated as a git repo. We no longer accept
|
||||
# tar.gz URLs — git is the only remote transport.
|
||||
return True
|
||||
# Bare github.com/user/repo shorthand
|
||||
if re.match(r"^github\.com/[\w.-]+/[\w.-]+/?$", s):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _git_clone(url: str, dest: Path) -> None:
|
||||
# Normalize github.com/user/repo shorthand
|
||||
if re.match(r"^github\.com/[\w.-]+/[\w.-]+/?$", url):
|
||||
url = f"https://{url.rstrip('/')}"
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "clone", "--depth", "1", url, str(dest)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise DistributionError("git is required for git-URL installs") from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else ""
|
||||
raise DistributionError(f"git clone failed: {stderr.strip()}") from exc
|
||||
|
||||
|
||||
def _stage_source(source: str, workdir: Path) -> Tuple[Path, str]:
|
||||
"""Resolve *source* to a local directory containing distribution.yaml.
|
||||
|
||||
Returns ``(staged_dir, provenance)`` where ``provenance`` is stored in the
|
||||
installed manifest's ``source:`` field so ``hermes profile update`` can
|
||||
re-pull from the same place.
|
||||
|
||||
Accepts:
|
||||
* A git URL (https / ssh / git@ / bare github.com shorthand) — cloned
|
||||
into a temp directory; ``.git`` removed after clone.
|
||||
* A local directory already containing ``distribution.yaml``.
|
||||
"""
|
||||
src_str = source.strip()
|
||||
|
||||
# Git URL
|
||||
if _looks_like_git_url(src_str):
|
||||
cloned = workdir / "clone"
|
||||
_git_clone(src_str, cloned)
|
||||
# Remove .git to keep the staged tree clean
|
||||
shutil.rmtree(cloned / ".git", ignore_errors=True)
|
||||
if not (cloned / MANIFEST_FILENAME).is_file():
|
||||
raise DistributionError(
|
||||
f"No {MANIFEST_FILENAME} at the root of {src_str!r}. "
|
||||
"This repository is not a Hermes profile distribution."
|
||||
)
|
||||
return cloned, src_str
|
||||
|
||||
# Local directory
|
||||
path_guess = Path(src_str).expanduser()
|
||||
if path_guess.is_dir():
|
||||
if not (path_guess / MANIFEST_FILENAME).is_file():
|
||||
raise DistributionError(
|
||||
f"No {MANIFEST_FILENAME} in {path_guess}. "
|
||||
"A local-directory source must contain a distribution.yaml at its root."
|
||||
)
|
||||
return path_guess.resolve(), str(path_guess.resolve())
|
||||
|
||||
raise DistributionError(
|
||||
f"Cannot resolve distribution source: {source!r}. "
|
||||
"Expected a git URL (e.g. github.com/user/repo) or a local directory."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallPlan:
|
||||
"""Summary of what an install will do, surfaced for user confirmation."""
|
||||
manifest: DistributionManifest
|
||||
staged_dir: Path
|
||||
provenance: str
|
||||
target_dir: Path
|
||||
existing: bool # True if target profile already exists (update path)
|
||||
preserves_config: bool = True
|
||||
has_cron: bool = False
|
||||
has_skills: bool = False
|
||||
|
||||
|
||||
def _has_cron_jobs(staged: Path) -> bool:
|
||||
cron_dir = staged / "cron"
|
||||
if not cron_dir.is_dir():
|
||||
return False
|
||||
for _ in cron_dir.rglob("*.json"):
|
||||
return True
|
||||
for _ in cron_dir.rglob("*.yaml"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _count_skills(staged: Path) -> int:
|
||||
skills_dir = staged / "skills"
|
||||
if not skills_dir.is_dir():
|
||||
return 0
|
||||
return sum(1 for _ in skills_dir.rglob("SKILL.md"))
|
||||
|
||||
|
||||
def plan_install(
|
||||
source: str,
|
||||
workdir: Path,
|
||||
override_name: Optional[str] = None,
|
||||
) -> InstallPlan:
|
||||
"""Stage *source* and produce a plan describing what install would do."""
|
||||
from hermes_cli.profiles import (
|
||||
get_profile_dir,
|
||||
normalize_profile_name,
|
||||
validate_profile_name,
|
||||
)
|
||||
from hermes_cli import __version__ as hermes_version
|
||||
|
||||
staged, provenance = _stage_source(source, workdir)
|
||||
manifest = read_manifest(staged)
|
||||
if manifest is None:
|
||||
raise DistributionError(
|
||||
f"No {MANIFEST_FILENAME} found at the distribution root — "
|
||||
"this source is not a Hermes distribution."
|
||||
)
|
||||
|
||||
# Version check up-front so we fail fast
|
||||
check_hermes_requires(manifest.hermes_requires, hermes_version)
|
||||
|
||||
# Resolve target profile name
|
||||
target_name = override_name or manifest.name
|
||||
canon = normalize_profile_name(target_name)
|
||||
validate_profile_name(canon)
|
||||
if canon == "default":
|
||||
raise DistributionError(
|
||||
"Cannot install a distribution as 'default' — that is the built-in "
|
||||
"root profile (~/.hermes). Pass --name <name> to install under a "
|
||||
"new profile."
|
||||
)
|
||||
manifest.name = canon
|
||||
manifest.source = provenance
|
||||
# Stamped once here so plan_install() callers (both fresh install and
|
||||
# update) propagate a freshly-minted timestamp through _copy_dist_payload.
|
||||
manifest.installed_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
target_dir = get_profile_dir(canon)
|
||||
existing = target_dir.is_dir()
|
||||
has_cron = _has_cron_jobs(staged)
|
||||
skill_count = _count_skills(staged)
|
||||
|
||||
return InstallPlan(
|
||||
manifest=manifest,
|
||||
staged_dir=staged,
|
||||
provenance=provenance,
|
||||
target_dir=target_dir,
|
||||
existing=existing,
|
||||
preserves_config=existing,
|
||||
has_cron=has_cron,
|
||||
has_skills=skill_count > 0,
|
||||
)
|
||||
|
||||
|
||||
def _copy_dist_payload(
|
||||
staged: Path,
|
||||
target: Path,
|
||||
manifest: DistributionManifest,
|
||||
preserve_config: bool,
|
||||
) -> None:
|
||||
"""Copy distribution-owned files from *staged* into *target*.
|
||||
|
||||
User-owned paths are never touched. ``config.yaml`` is replaced only when
|
||||
``preserve_config`` is False (fresh install or ``--force-config`` update).
|
||||
``.env.template`` is renamed to ``.env.EXAMPLE`` in the target to avoid
|
||||
shadowing a real ``.env``.
|
||||
"""
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for entry in staged.iterdir():
|
||||
name = entry.name
|
||||
|
||||
if name in USER_OWNED_EXCLUDE:
|
||||
continue
|
||||
if name == ENV_TEMPLATE_FILENAME:
|
||||
shutil.copy2(entry, target / ENV_EXAMPLE_FILENAME)
|
||||
continue
|
||||
if name == "config.yaml" and preserve_config and (target / "config.yaml").exists():
|
||||
# Leave user's config.yaml alone on update
|
||||
continue
|
||||
|
||||
dest = target / name
|
||||
if entry.is_dir():
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
shutil.copytree(
|
||||
entry,
|
||||
dest,
|
||||
ignore=lambda d, names: [n for n in names if n in USER_OWNED_EXCLUDE],
|
||||
)
|
||||
else:
|
||||
shutil.copy2(entry, dest)
|
||||
|
||||
# Emit .env.EXAMPLE from manifest if the staged tree didn't ship one
|
||||
if manifest.env_requires and not (target / ENV_EXAMPLE_FILENAME).exists():
|
||||
(target / ENV_EXAMPLE_FILENAME).write_text(
|
||||
_env_template_from_manifest(manifest), encoding="utf-8"
|
||||
)
|
||||
|
||||
# Make sure the manifest on disk reflects resolved name + source
|
||||
write_manifest(target, manifest)
|
||||
|
||||
|
||||
def _bootstrap_user_dirs(target: Path) -> None:
|
||||
"""Create the bootstrap dirs a fresh profile expects."""
|
||||
for d in ("memories", "sessions", "skills", "skins", "logs",
|
||||
"plans", "workspace", "cron", "home"):
|
||||
(target / d).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def install_distribution(
|
||||
source: str,
|
||||
name: Optional[str] = None,
|
||||
force: bool = False,
|
||||
create_alias: bool = False,
|
||||
) -> InstallPlan:
|
||||
"""Install a distribution from *source* into a new profile.
|
||||
|
||||
Returns the resolved :class:`InstallPlan`. Use :func:`plan_install`
|
||||
first if you want to preview + prompt the user before calling this.
|
||||
"""
|
||||
from hermes_cli.profiles import (
|
||||
check_alias_collision,
|
||||
create_wrapper_script,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="hermes_dist_install_") as tmp:
|
||||
plan = plan_install(source, Path(tmp), override_name=name)
|
||||
|
||||
if plan.existing and not force:
|
||||
raise DistributionError(
|
||||
f"Profile '{plan.manifest.name}' already exists at {plan.target_dir}. "
|
||||
"Use `hermes profile update` to upgrade in place, "
|
||||
"or pass --force to overwrite."
|
||||
)
|
||||
|
||||
# Fresh install: config.yaml comes from the distribution.
|
||||
_bootstrap_user_dirs(plan.target_dir)
|
||||
_copy_dist_payload(
|
||||
plan.staged_dir,
|
||||
plan.target_dir,
|
||||
plan.manifest,
|
||||
preserve_config=False,
|
||||
)
|
||||
|
||||
if create_alias:
|
||||
collision = check_alias_collision(plan.manifest.name)
|
||||
if collision is None:
|
||||
create_wrapper_script(plan.manifest.name)
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
def update_distribution(
|
||||
profile_name: str,
|
||||
force_config: bool = False,
|
||||
) -> InstallPlan:
|
||||
"""Re-pull the distribution for an existing profile and apply updates.
|
||||
|
||||
The source is read from the installed profile's ``distribution.yaml``
|
||||
``source:`` field. Distribution-owned files are overwritten; user-owned
|
||||
data (memories, sessions, auth) is never touched. ``config.yaml`` is
|
||||
preserved unless ``force_config`` is True.
|
||||
"""
|
||||
from hermes_cli.profiles import (
|
||||
get_profile_dir,
|
||||
normalize_profile_name,
|
||||
validate_profile_name,
|
||||
)
|
||||
|
||||
canon = normalize_profile_name(profile_name)
|
||||
validate_profile_name(canon)
|
||||
target = get_profile_dir(canon)
|
||||
if not target.is_dir():
|
||||
raise DistributionError(f"Profile '{canon}' does not exist.")
|
||||
|
||||
existing_manifest = read_manifest(target)
|
||||
if existing_manifest is None:
|
||||
raise DistributionError(
|
||||
f"Profile '{canon}' is not a distribution (no {MANIFEST_FILENAME}). "
|
||||
"Only profiles installed via `hermes profile install` can be updated."
|
||||
)
|
||||
if not existing_manifest.source:
|
||||
raise DistributionError(
|
||||
f"Profile '{canon}' has no recorded source. Re-install with "
|
||||
"`hermes profile install <source> --name {canon} --force`."
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="hermes_dist_update_") as tmp:
|
||||
plan = plan_install(
|
||||
existing_manifest.source,
|
||||
Path(tmp),
|
||||
override_name=canon,
|
||||
)
|
||||
plan.preserves_config = not force_config
|
||||
|
||||
_copy_dist_payload(
|
||||
plan.staged_dir,
|
||||
plan.target_dir,
|
||||
plan.manifest,
|
||||
preserve_config=plan.preserves_config,
|
||||
)
|
||||
return plan
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Info — render a manifest summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def describe_distribution(profile_name: str) -> Dict[str, Any]:
|
||||
"""Return a structured view of a profile's distribution metadata.
|
||||
|
||||
Returns an empty dict if the profile exists but has no manifest.
|
||||
Raises DistributionError if the profile itself doesn't exist.
|
||||
"""
|
||||
from hermes_cli.profiles import (
|
||||
get_profile_dir,
|
||||
normalize_profile_name,
|
||||
validate_profile_name,
|
||||
)
|
||||
|
||||
canon = normalize_profile_name(profile_name)
|
||||
validate_profile_name(canon)
|
||||
target = get_profile_dir(canon)
|
||||
if not target.is_dir():
|
||||
raise DistributionError(f"Profile '{canon}' does not exist.")
|
||||
manifest = read_manifest(target)
|
||||
if manifest is None:
|
||||
return {}
|
||||
return manifest.to_dict()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
584
tests/hermes_cli/test_profile_distribution.py
Normal file
584
tests/hermes_cli/test_profile_distribution.py
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
"""Tests for hermes_cli.profile_distribution — git-based profile installs.
|
||||
|
||||
Covers manifest parsing, version requirement checks, install / update / describe
|
||||
on local-directory sources, and guards on what can and can't be installed.
|
||||
|
||||
Transport-layer tests (git clone, URL handling) are exercised through live
|
||||
E2E runs, not unit tests — git itself is tested upstream, and subprocess-
|
||||
mocking git would just test the mock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.profile_distribution import (
|
||||
DEFAULT_DIST_OWNED,
|
||||
DistributionError,
|
||||
DistributionManifest,
|
||||
EnvRequirement,
|
||||
MANIFEST_FILENAME,
|
||||
USER_OWNED_EXCLUDE,
|
||||
_env_template_from_manifest,
|
||||
_looks_like_git_url,
|
||||
_parse_semver,
|
||||
check_hermes_requires,
|
||||
describe_distribution,
|
||||
install_distribution,
|
||||
plan_install,
|
||||
read_manifest,
|
||||
update_distribution,
|
||||
write_manifest,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Isolated profile env (matches tests/hermes_cli/test_profiles.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def profile_env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
default_home = tmp_path / ".hermes"
|
||||
default_home.mkdir(exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _make_staging_dir(root: Path, name: str = "src", *, manifest: DistributionManifest = None) -> Path:
|
||||
"""Build a local distribution staging directory (what a git clone would
|
||||
contain after .git is removed).
|
||||
|
||||
Lays down a minimal but representative tree: SOUL.md, config.yaml,
|
||||
mcp.json, one skill, one cron file, plus the distribution.yaml manifest.
|
||||
"""
|
||||
staged = root / f"staging_{name}"
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
(staged / "SOUL.md").write_text("I am Source.\n")
|
||||
(staged / "config.yaml").write_text("model:\n model: gpt-4\n")
|
||||
(staged / "mcp.json").write_text('{"servers": {}}\n')
|
||||
(staged / "skills").mkdir(exist_ok=True)
|
||||
(staged / "skills" / "demo").mkdir(exist_ok=True)
|
||||
(staged / "skills" / "demo" / "SKILL.md").write_text(
|
||||
"---\nname: demo\ndescription: test\n---\n# Demo skill\n"
|
||||
)
|
||||
(staged / "cron").mkdir(exist_ok=True)
|
||||
(staged / "cron" / "daily.json").write_text('{"schedule": "0 9 * * *"}')
|
||||
|
||||
mf = manifest or DistributionManifest(name=name, version="0.1.0")
|
||||
write_manifest(staged, mf)
|
||||
return staged
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Manifest parsing
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestManifestParsing:
|
||||
|
||||
def test_minimal_manifest(self, tmp_path):
|
||||
(tmp_path / MANIFEST_FILENAME).write_text("name: minimal\n")
|
||||
m = read_manifest(tmp_path)
|
||||
assert m.name == "minimal"
|
||||
assert m.version == "0.1.0"
|
||||
assert m.env_requires == []
|
||||
assert m.distribution_owned == []
|
||||
|
||||
def test_full_manifest(self, tmp_path):
|
||||
(tmp_path / MANIFEST_FILENAME).write_text(
|
||||
"name: telem\n"
|
||||
"version: 1.2.3\n"
|
||||
"description: Telem monitor\n"
|
||||
"hermes_requires: '>=0.12.0'\n"
|
||||
"author: Kyle\n"
|
||||
"license: MIT\n"
|
||||
"env_requires:\n"
|
||||
" - name: OPENAI_API_KEY\n"
|
||||
" description: OpenAI key\n"
|
||||
" - name: GRAPH_URL\n"
|
||||
" required: false\n"
|
||||
" default: http://127.0.0.1:8000\n"
|
||||
"distribution_owned:\n"
|
||||
" - SOUL.md\n"
|
||||
" - skills/\n"
|
||||
)
|
||||
m = read_manifest(tmp_path)
|
||||
assert m.name == "telem"
|
||||
assert m.version == "1.2.3"
|
||||
assert m.author == "Kyle"
|
||||
assert m.license == "MIT"
|
||||
assert len(m.env_requires) == 2
|
||||
assert m.env_requires[0].name == "OPENAI_API_KEY"
|
||||
assert m.env_requires[0].required is True
|
||||
assert m.env_requires[1].required is False
|
||||
assert m.env_requires[1].default == "http://127.0.0.1:8000"
|
||||
assert m.distribution_owned == ["SOUL.md", "skills"]
|
||||
|
||||
def test_missing_name_rejected(self, tmp_path):
|
||||
(tmp_path / MANIFEST_FILENAME).write_text("version: 1.0\n")
|
||||
with pytest.raises(DistributionError, match="missing 'name'"):
|
||||
read_manifest(tmp_path)
|
||||
|
||||
def test_env_requires_not_list_rejected(self, tmp_path):
|
||||
(tmp_path / MANIFEST_FILENAME).write_text(
|
||||
"name: bad\nenv_requires:\n name: FOO\n"
|
||||
)
|
||||
with pytest.raises(DistributionError, match="env_requires must be a list"):
|
||||
read_manifest(tmp_path)
|
||||
|
||||
def test_read_manifest_returns_none_when_absent(self, tmp_path):
|
||||
assert read_manifest(tmp_path) is None
|
||||
|
||||
def test_owned_paths_default(self):
|
||||
m = DistributionManifest(name="x")
|
||||
assert m.owned_paths() == list(DEFAULT_DIST_OWNED)
|
||||
|
||||
def test_owned_paths_explicit(self):
|
||||
m = DistributionManifest(name="x", distribution_owned=["SOUL.md", "skills"])
|
||||
assert m.owned_paths() == ["SOUL.md", "skills"]
|
||||
|
||||
def test_roundtrip_write_read(self, tmp_path):
|
||||
original = DistributionManifest(
|
||||
name="rt",
|
||||
version="1.0.0",
|
||||
description="roundtrip",
|
||||
env_requires=[EnvRequirement(name="FOO", description="foo")],
|
||||
)
|
||||
write_manifest(tmp_path, original)
|
||||
parsed = read_manifest(tmp_path)
|
||||
assert parsed.name == "rt"
|
||||
assert parsed.env_requires[0].name == "FOO"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Version requirement checks
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestVersionRequires:
|
||||
|
||||
@pytest.mark.parametrize("spec,cur,ok", [
|
||||
("", "0.1.0", True),
|
||||
(">=0.12.0", "0.12.0", True),
|
||||
(">=0.12.0", "0.13.0", True),
|
||||
(">=0.12.0", "0.11.9", False),
|
||||
("==0.12.0", "0.12.0", True),
|
||||
("==0.12.0", "0.13.0", False),
|
||||
("!=0.12.0", "0.13.0", True),
|
||||
(">0.12.0", "0.12.1", True),
|
||||
(">0.12.0", "0.12.0", False),
|
||||
("<0.13.0", "0.12.9", True),
|
||||
("<=0.12.0", "0.12.0", True),
|
||||
("0.12.0", "0.13.0", True), # Bare = >=
|
||||
("0.12.0", "0.11.0", False), # Bare = >=
|
||||
])
|
||||
def test_check_matrix(self, spec, cur, ok):
|
||||
if ok:
|
||||
check_hermes_requires(spec, cur)
|
||||
else:
|
||||
with pytest.raises(DistributionError, match="requires Hermes"):
|
||||
check_hermes_requires(spec, cur)
|
||||
|
||||
def test_parse_semver_handles_prerelease(self):
|
||||
assert _parse_semver("0.12.0-rc1") == (0, 12, 0)
|
||||
assert _parse_semver("v0.12.0+abc") == (0, 12, 0)
|
||||
|
||||
def test_parse_semver_pads(self):
|
||||
assert _parse_semver("1") == (1, 0, 0)
|
||||
assert _parse_semver("1.2") == (1, 2, 0)
|
||||
|
||||
def test_parse_semver_rejects_garbage(self):
|
||||
with pytest.raises(DistributionError, match="Unparseable"):
|
||||
_parse_semver("not-a-version")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Env template
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestEnvTemplate:
|
||||
|
||||
def test_required_is_uncommented(self):
|
||||
m = DistributionManifest(
|
||||
name="x",
|
||||
env_requires=[EnvRequirement(name="FOO", description="foo key")],
|
||||
)
|
||||
out = _env_template_from_manifest(m)
|
||||
assert "# foo key" in out
|
||||
assert "# (required)" in out
|
||||
assert "FOO=" in out
|
||||
# No leading `# ` before FOO=
|
||||
assert "\nFOO=" in out or out.startswith("FOO=") or "\nFOO=\n" in out or "FOO=\n" in out
|
||||
|
||||
def test_optional_is_commented(self):
|
||||
m = DistributionManifest(
|
||||
name="x",
|
||||
env_requires=[EnvRequirement(name="BAR", required=False, default="http://x")],
|
||||
)
|
||||
out = _env_template_from_manifest(m)
|
||||
assert "# (optional)" in out
|
||||
assert "# BAR=http://x" in out
|
||||
|
||||
def test_empty_env_requires_is_header_only(self):
|
||||
m = DistributionManifest(name="x")
|
||||
out = _env_template_from_manifest(m)
|
||||
assert "Hermes distribution" in out
|
||||
assert "FOO" not in out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Source URL detection
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestLooksLikeGitUrl:
|
||||
|
||||
@pytest.mark.parametrize("src", [
|
||||
"github.com/user/repo",
|
||||
"https://github.com/user/repo",
|
||||
"https://github.com/user/repo.git",
|
||||
"http://example.com/repo",
|
||||
"git@github.com:user/repo.git",
|
||||
"ssh://git@example.com/repo.git",
|
||||
"git://example.com/repo.git",
|
||||
])
|
||||
def test_accepts_git_sources(self, src):
|
||||
assert _looks_like_git_url(src)
|
||||
|
||||
@pytest.mark.parametrize("src", [
|
||||
"/tmp/local/path",
|
||||
"./relative/dir",
|
||||
"~/profile",
|
||||
"some-random-string",
|
||||
])
|
||||
def test_rejects_non_git(self, src):
|
||||
assert not _looks_like_git_url(src)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Install — fresh and force (from a local-directory source)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestInstall:
|
||||
|
||||
def test_install_from_directory(self, profile_env):
|
||||
staged = _make_staging_dir(profile_env, "src")
|
||||
plan = install_distribution(str(staged), name="installed")
|
||||
assert plan.target_dir.is_dir()
|
||||
assert (plan.target_dir / "SOUL.md").read_text() == "I am Source.\n"
|
||||
assert (plan.target_dir / "skills" / "demo" / "SKILL.md").exists()
|
||||
assert (plan.target_dir / "mcp.json").exists()
|
||||
# Manifest on disk records canonical name + provenance
|
||||
m = read_manifest(plan.target_dir)
|
||||
assert m.name == "installed"
|
||||
assert m.source == str(staged)
|
||||
|
||||
def test_install_uses_manifest_name_when_no_override(self, profile_env):
|
||||
mf = DistributionManifest(name="telem", version="1.0.0")
|
||||
staged = _make_staging_dir(profile_env, "telem", manifest=mf)
|
||||
plan = install_distribution(str(staged))
|
||||
assert plan.manifest.name == "telem"
|
||||
assert plan.target_dir.name == "telem"
|
||||
|
||||
def test_install_rejects_existing_without_force(self, profile_env):
|
||||
staged = _make_staging_dir(profile_env, "src")
|
||||
install_distribution(str(staged), name="existing")
|
||||
with pytest.raises(DistributionError, match="already exists"):
|
||||
install_distribution(str(staged), name="existing")
|
||||
|
||||
def test_install_with_force_overwrites(self, profile_env):
|
||||
staged = _make_staging_dir(profile_env, "src")
|
||||
install_distribution(str(staged), name="target")
|
||||
# Install again with --force succeeds
|
||||
plan = install_distribution(str(staged), name="target", force=True)
|
||||
assert plan.target_dir.is_dir()
|
||||
|
||||
def test_install_rejects_default_name(self, profile_env):
|
||||
staged = _make_staging_dir(profile_env, "src")
|
||||
with pytest.raises(DistributionError, match="Cannot install"):
|
||||
install_distribution(str(staged), name="default")
|
||||
|
||||
def test_install_rejects_non_distribution_directory(self, profile_env, tmp_path):
|
||||
bogus = tmp_path / "bogus_dir"
|
||||
bogus.mkdir()
|
||||
(bogus / "some_file").write_text("hi")
|
||||
with pytest.raises(DistributionError, match="No distribution.yaml"):
|
||||
plan_install(str(bogus), tmp_path / "work", override_name="x")
|
||||
|
||||
def test_install_rejects_unknown_source(self, profile_env, tmp_path):
|
||||
with pytest.raises(DistributionError, match="Cannot resolve"):
|
||||
plan_install("definitely-not-a-thing", tmp_path / "work", override_name="x")
|
||||
|
||||
def test_install_emits_env_example_when_manifest_has_env(self, profile_env):
|
||||
mf = DistributionManifest(
|
||||
name="needs_env",
|
||||
version="0.1.0",
|
||||
env_requires=[EnvRequirement(name="OPENAI_API_KEY", description="key")],
|
||||
)
|
||||
staged = _make_staging_dir(profile_env, "needs_env", manifest=mf)
|
||||
plan = install_distribution(str(staged), name="needs_env")
|
||||
example = plan.target_dir / ".env.EXAMPLE"
|
||||
assert example.is_file()
|
||||
assert "OPENAI_API_KEY" in example.read_text()
|
||||
|
||||
def test_install_enforces_hermes_requires(self, profile_env, monkeypatch):
|
||||
# Pin current Hermes version to something well below the requirement
|
||||
import hermes_cli
|
||||
monkeypatch.setattr(hermes_cli, "__version__", "0.1.0", raising=False)
|
||||
|
||||
mf = DistributionManifest(
|
||||
name="future",
|
||||
version="1.0.0",
|
||||
hermes_requires=">=99.0.0",
|
||||
)
|
||||
staged = _make_staging_dir(profile_env, "future", manifest=mf)
|
||||
with pytest.raises(DistributionError, match="requires Hermes"):
|
||||
install_distribution(str(staged), name="future")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Update — preserves user data, preserves config by default
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestUpdate:
|
||||
|
||||
def test_update_preserves_user_data(self, profile_env):
|
||||
# 1. Build staging dir, install
|
||||
staged = _make_staging_dir(profile_env, "src")
|
||||
plan = install_distribution(str(staged), name="telem")
|
||||
|
||||
# 2. Add user-owned data to the installed profile
|
||||
(plan.target_dir / "memories").mkdir(exist_ok=True)
|
||||
(plan.target_dir / "memories" / "MEMORY.md").write_text("# USER MEMORY\n")
|
||||
(plan.target_dir / ".env").write_text("OPENAI_API_KEY=sk-user\n")
|
||||
(plan.target_dir / "auth.json").write_text('{"user": "auth"}')
|
||||
(plan.target_dir / "sessions").mkdir(exist_ok=True)
|
||||
(plan.target_dir / "sessions" / "chat.json").write_text('{"s": 1}')
|
||||
|
||||
# 3. Bump source in the staging dir
|
||||
(staged / "SOUL.md").write_text("I am Source v2.\n")
|
||||
|
||||
# 4. Update
|
||||
update_distribution("telem", force_config=False)
|
||||
|
||||
# 5. Dist-owned changed
|
||||
assert (plan.target_dir / "SOUL.md").read_text() == "I am Source v2.\n"
|
||||
# 6. User-owned preserved
|
||||
assert (plan.target_dir / "memories" / "MEMORY.md").read_text() == "# USER MEMORY\n"
|
||||
assert (plan.target_dir / ".env").read_text() == "OPENAI_API_KEY=sk-user\n"
|
||||
assert (plan.target_dir / "auth.json").read_text() == '{"user": "auth"}'
|
||||
assert (plan.target_dir / "sessions" / "chat.json").read_text() == '{"s": 1}'
|
||||
|
||||
def test_update_preserves_config_by_default(self, profile_env):
|
||||
staged = _make_staging_dir(profile_env, "src")
|
||||
plan = install_distribution(str(staged), name="t2")
|
||||
|
||||
# User edits config
|
||||
(plan.target_dir / "config.yaml").write_text(
|
||||
"model:\n model: gpt-5\n# user override\n"
|
||||
)
|
||||
|
||||
# Bump source config
|
||||
(staged / "config.yaml").write_text("model:\n model: claude\n")
|
||||
|
||||
update_distribution("t2", force_config=False)
|
||||
assert "gpt-5" in (plan.target_dir / "config.yaml").read_text()
|
||||
assert "user override" in (plan.target_dir / "config.yaml").read_text()
|
||||
|
||||
def test_update_force_config_overwrites(self, profile_env):
|
||||
staged = _make_staging_dir(profile_env, "src")
|
||||
plan = install_distribution(str(staged), name="t3")
|
||||
|
||||
(plan.target_dir / "config.yaml").write_text("model:\n model: gpt-5\n")
|
||||
|
||||
(staged / "config.yaml").write_text("model:\n model: claude\n")
|
||||
|
||||
update_distribution("t3", force_config=True)
|
||||
assert "claude" in (plan.target_dir / "config.yaml").read_text()
|
||||
assert "gpt-5" not in (plan.target_dir / "config.yaml").read_text()
|
||||
|
||||
def test_update_missing_manifest_errors(self, profile_env):
|
||||
# Make a profile without a manifest; update must refuse
|
||||
from hermes_cli.profiles import create_profile
|
||||
create_profile(name="plain", no_alias=True)
|
||||
with pytest.raises(DistributionError, match="not a distribution"):
|
||||
update_distribution("plain")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# describe_distribution — info subcommand
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestDescribe:
|
||||
|
||||
def test_describe_existing_distribution(self, profile_env):
|
||||
mf = DistributionManifest(
|
||||
name="telem",
|
||||
version="1.0.0",
|
||||
description="compliance monitor",
|
||||
env_requires=[EnvRequirement(name="API", description="api key")],
|
||||
)
|
||||
staged = _make_staging_dir(profile_env, "telem", manifest=mf)
|
||||
install_distribution(str(staged), name="telem")
|
||||
data = describe_distribution("telem")
|
||||
assert data["name"] == "telem"
|
||||
assert data["version"] == "1.0.0"
|
||||
assert data["env_requires"][0]["name"] == "API"
|
||||
|
||||
def test_describe_non_distribution_returns_empty(self, profile_env):
|
||||
from hermes_cli.profiles import create_profile
|
||||
create_profile(name="plain", no_alias=True)
|
||||
assert describe_distribution("plain") == {}
|
||||
|
||||
def test_describe_missing_profile_raises(self, profile_env):
|
||||
with pytest.raises(DistributionError, match="does not exist"):
|
||||
describe_distribution("nonexistent")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Security — USER_OWNED_EXCLUDE covers the right paths
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSecurity:
|
||||
|
||||
def test_user_owned_exclude_covers_credentials(self):
|
||||
assert "auth.json" in USER_OWNED_EXCLUDE
|
||||
assert ".env" in USER_OWNED_EXCLUDE
|
||||
assert "memories" in USER_OWNED_EXCLUDE
|
||||
assert "sessions" in USER_OWNED_EXCLUDE
|
||||
assert "local" in USER_OWNED_EXCLUDE
|
||||
|
||||
def test_install_does_not_import_credentials_from_staging(self, profile_env):
|
||||
"""If an author accidentally ships auth.json or .env in their
|
||||
staging dir, the installer must NOT copy them to the target profile."""
|
||||
staged = _make_staging_dir(profile_env, "src")
|
||||
# Author leaks credentials into the staging tree (shouldn't happen, but...)
|
||||
(staged / "auth.json").write_text('{"leaked": true}')
|
||||
(staged / ".env").write_text("LEAKED=1")
|
||||
|
||||
plan = install_distribution(str(staged), name="clean")
|
||||
assert not (plan.target_dir / "auth.json").exists(), "auth.json leaked"
|
||||
# Fresh profile may have its own .env via the bootstrap; what we care
|
||||
# about is that the leaked content didn't land in the target.
|
||||
if (plan.target_dir / ".env").exists():
|
||||
assert "LEAKED" not in (plan.target_dir / ".env").read_text()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Install-time metadata (installed_at stamp)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestInstalledAtStamp:
|
||||
|
||||
def test_install_stamps_installed_at(self, profile_env):
|
||||
staged = _make_staging_dir(profile_env, "src")
|
||||
plan = install_distribution(str(staged), name="stamped")
|
||||
mf = read_manifest(plan.target_dir)
|
||||
assert mf.installed_at, "installed_at should be set after install"
|
||||
# ISO-8601 UTC sanity: starts with 4-digit year, contains 'T', ends with '+00:00'.
|
||||
assert mf.installed_at[:4].isdigit()
|
||||
assert "T" in mf.installed_at
|
||||
assert mf.installed_at.endswith("+00:00")
|
||||
|
||||
def test_update_refreshes_installed_at(self, profile_env, monkeypatch):
|
||||
staged = _make_staging_dir(profile_env, "src")
|
||||
install_distribution(str(staged), name="demo")
|
||||
from hermes_cli.profiles import get_profile_dir
|
||||
first = read_manifest(get_profile_dir("demo")).installed_at
|
||||
|
||||
# Freeze `datetime.now()` to a fixed future time so we can observe that
|
||||
# update writes a NEW stamp (installs within the same second otherwise
|
||||
# collide at iso-8601 seconds resolution).
|
||||
import datetime as _dt
|
||||
class _FakeDT(_dt.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return _dt.datetime(2099, 1, 1, 0, 0, 0, tzinfo=tz or _dt.timezone.utc)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profile_distribution.datetime", _FakeDT, raising=True
|
||||
)
|
||||
|
||||
from hermes_cli.profile_distribution import update_distribution
|
||||
update_distribution("demo")
|
||||
refreshed = read_manifest(get_profile_dir("demo")).installed_at
|
||||
assert refreshed != first, "installed_at should change on update"
|
||||
assert refreshed.startswith("2099-01-01"), refreshed
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ProfileInfo exposes distribution metadata
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestProfileInfoDistribution:
|
||||
|
||||
def test_installed_distribution_shows_in_list(self, profile_env):
|
||||
staged = _make_staging_dir(
|
||||
profile_env, "src",
|
||||
manifest=DistributionManifest(name="telem", version="1.2.3"),
|
||||
)
|
||||
install_distribution(str(staged), name="telem")
|
||||
|
||||
from hermes_cli.profiles import list_profiles
|
||||
rows = {p.name: p for p in list_profiles()}
|
||||
assert "telem" in rows
|
||||
row = rows["telem"]
|
||||
assert row.distribution_name == "telem"
|
||||
assert row.distribution_version == "1.2.3"
|
||||
assert row.distribution_source # path populated, exact value depends on fixture
|
||||
|
||||
def test_plain_profile_has_no_distribution_fields(self, profile_env):
|
||||
from hermes_cli.profiles import create_profile, list_profiles
|
||||
create_profile(name="plain", no_alias=True)
|
||||
rows = {p.name: p for p in list_profiles()}
|
||||
assert rows["plain"].distribution_name is None
|
||||
assert rows["plain"].distribution_version is None
|
||||
|
||||
def test_malformed_manifest_does_not_break_list(self, profile_env):
|
||||
from hermes_cli.profiles import create_profile, list_profiles, get_profile_dir
|
||||
create_profile(name="brokenmeta", no_alias=True)
|
||||
# Write a distribution.yaml that isn't a valid mapping
|
||||
(get_profile_dir("brokenmeta") / "distribution.yaml").write_text(
|
||||
"not: [a, valid, mapping\n" # broken YAML
|
||||
)
|
||||
# list_profiles must NOT raise; distribution_* stay None for this row.
|
||||
rows = {p.name: p for p in list_profiles()}
|
||||
assert rows["brokenmeta"].distribution_name is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Error surfaces: validation failures should propagate as DistributionError
|
||||
# or ValueError (both caught and rendered cleanly by the CLI handler)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestErrorSurfaces:
|
||||
|
||||
def test_bad_profile_name_raises_valueerror_not_traceback(self, profile_env, tmp_path):
|
||||
"""A manifest whose 'name' can't be used as a profile identifier
|
||||
should raise ValueError from validate_profile_name — the CLI handler
|
||||
catches both DistributionError and ValueError so users see a clean
|
||||
'Error: ...' line instead of a Python traceback.
|
||||
"""
|
||||
mf = DistributionManifest(name="Invalid Name With Spaces", version="0.1.0")
|
||||
staged = _make_staging_dir(profile_env, "bad", manifest=mf)
|
||||
with pytest.raises((ValueError, DistributionError)):
|
||||
plan_install(str(staged), tmp_path / "work")
|
||||
|
||||
def test_path_traversal_name_rejected(self, profile_env, tmp_path):
|
||||
mf = DistributionManifest(name="../../etc/passwd", version="0.1.0")
|
||||
staged = _make_staging_dir(profile_env, "bad", manifest=mf)
|
||||
with pytest.raises((ValueError, DistributionError)):
|
||||
plan_install(str(staged), tmp_path / "work")
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1077,8 +1077,11 @@ Manage profiles — multiple isolated Hermes instances, each with its own config
|
|||
| `show <name>` | Show profile details (home directory, config, etc.). |
|
||||
| `alias <name> [--remove] [--name NAME]` | Manage wrapper scripts for quick profile access. |
|
||||
| `rename <old> <new>` | Rename a profile. |
|
||||
| `export <name> [-o FILE]` | Export a profile to a `.tar.gz` archive. |
|
||||
| `import <archive> [--name NAME]` | Import a profile from a `.tar.gz` archive. |
|
||||
| `export <name> [-o FILE]` | Export a profile to a `.tar.gz` archive (local backup). |
|
||||
| `import <archive> [--name NAME]` | Import a profile from a `.tar.gz` archive (local restore). |
|
||||
| `install <source> [--name N] [--alias] [--force] [-y]` | Install a profile distribution from a git URL or local directory. |
|
||||
| `update <name> [--force-config] [-y]` | Re-pull a distribution; preserves user data (memories, sessions, auth). |
|
||||
| `info <name>` | Show a profile's distribution manifest (version, requirements, source). |
|
||||
|
||||
Examples:
|
||||
|
||||
|
|
@ -1089,6 +1092,8 @@ hermes profile use work
|
|||
hermes profile alias work --name h-work
|
||||
hermes profile export work -o work-backup.tar.gz
|
||||
hermes profile import work-backup.tar.gz --name restored
|
||||
hermes profile install github.com/user/my-distro --alias
|
||||
hermes profile update work
|
||||
hermes -p work chat -q "Hello from work profile"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -243,6 +243,161 @@ hermes profile import ./work-2026-03-29.tar.gz
|
|||
hermes profile import ./work-2026-03-29.tar.gz --name work-restored
|
||||
```
|
||||
|
||||
## Distribution commands
|
||||
|
||||
Distributions turn a profile into a shareable, versioned artifact published
|
||||
as a **git repository**. A recipient installs the distribution with a single
|
||||
command and can update it in place later without touching their local
|
||||
memories, sessions, or credentials.
|
||||
|
||||
`auth.json` and `.env` are never part of a distribution — they stay on the
|
||||
installing user's machine.
|
||||
|
||||
The recipient's user data (memories, sessions, auth, their own edits to
|
||||
`.env`) is always preserved across the initial install and subsequent
|
||||
updates.
|
||||
|
||||
:::info
|
||||
`hermes profile export` / `import` are still the right commands for
|
||||
**local backup and restore** of a profile on your own machine. Distribution
|
||||
(`install` / `update` / `info`) is a separate concept: ship a profile via
|
||||
git so someone else can install it.
|
||||
:::
|
||||
|
||||
### `hermes profile install`
|
||||
|
||||
```bash
|
||||
hermes profile install <source> [--name <name>] [--alias] [--force] [--yes]
|
||||
```
|
||||
|
||||
Installs a profile distribution from a git URL or a local directory.
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `<source>` | Git URL (`github.com/user/repo`, `https://...`, `git@...`, `ssh://`, `git://`) or a local directory containing `distribution.yaml` at its root. |
|
||||
| `--name NAME` | Override the profile name from the manifest. |
|
||||
| `--alias` | Also create a shell wrapper (e.g. `telemetry` → `hermes -p telemetry`). |
|
||||
| `--force` | Overwrite an existing profile of the same name. User data is still preserved. |
|
||||
| `-y`, `--yes` | Skip the manifest-preview confirmation prompt. |
|
||||
|
||||
The installer shows the manifest, lists required env vars, and warns about
|
||||
cron jobs before asking for confirmation. Required env vars go into a
|
||||
`.env.EXAMPLE` file you copy to `.env` and fill in.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Install from a GitHub repo (shorthand)
|
||||
hermes profile install github.com/kyle/telemetry-distribution --alias
|
||||
|
||||
# Install from a full HTTPS git URL
|
||||
hermes profile install https://github.com/kyle/telemetry-distribution.git
|
||||
|
||||
# Install from SSH
|
||||
hermes profile install git@github.com:kyle/telemetry-distribution.git
|
||||
|
||||
# Install from a local directory during development
|
||||
hermes profile install ./telemetry/
|
||||
```
|
||||
|
||||
### `hermes profile update`
|
||||
|
||||
```bash
|
||||
hermes profile update <name> [--force-config] [--yes]
|
||||
```
|
||||
|
||||
Re-clones the distribution from its recorded source and applies updates.
|
||||
Distribution-owned files (SOUL.md, skills/, cron/, mcp.json) are
|
||||
overwritten; user data (memories, sessions, auth, .env) is never touched.
|
||||
|
||||
`config.yaml` is preserved by default to keep your local overrides.
|
||||
Pass `--force-config` to reset it to the distribution's shipped config.
|
||||
|
||||
### `hermes profile info`
|
||||
|
||||
```bash
|
||||
hermes profile info <name>
|
||||
```
|
||||
|
||||
Prints the profile's distribution manifest — name, version, required
|
||||
Hermes version, author, env var requirements, the source URL/path, and
|
||||
the `Installed:` timestamp recorded when the distribution was last
|
||||
`install`-ed or `update`-d. Useful for checking what a shared profile
|
||||
needs before installing it, and for spotting "this profile was installed
|
||||
6 months ago and hasn't been updated."
|
||||
|
||||
`hermes profile list` also shows the distribution name and version in a
|
||||
`Distribution` column, and `hermes profile show <name>` / `delete <name>`
|
||||
surface the source URL so you can tell at a glance which profiles came
|
||||
from a git repo vs. were created locally.
|
||||
|
||||
### Private distributions
|
||||
|
||||
A private git repository works as a distribution source with no extra
|
||||
configuration — the install shells out to your normal `git` binary, so
|
||||
whatever authentication your shell is already set up for (SSH key,
|
||||
`git credential` helper, GitHub CLI's stored HTTPS credentials) applies
|
||||
transparently.
|
||||
|
||||
```bash
|
||||
# Uses your SSH key, the same as any other `git clone`
|
||||
hermes profile install git@github.com:your-org/internal-assistant.git
|
||||
|
||||
# Uses your git credential helper
|
||||
hermes profile install https://github.com/your-org/internal-assistant.git
|
||||
```
|
||||
|
||||
If a clone prompts for credentials interactively in your terminal during
|
||||
install, that prompt flows through. Set up your auth the way you'd
|
||||
normally use `git clone` against the same repo first, then install.
|
||||
|
||||
### Distribution manifest (`distribution.yaml`)
|
||||
|
||||
Every distribution has a `distribution.yaml` at the root of its repository:
|
||||
|
||||
```yaml
|
||||
name: telemetry
|
||||
version: 0.1.0
|
||||
description: "Compliance monitoring harness"
|
||||
hermes_requires: ">=0.12.0"
|
||||
author: "Your Name"
|
||||
license: "MIT"
|
||||
env_requires:
|
||||
- name: OPENAI_API_KEY
|
||||
description: "OpenAI API key"
|
||||
required: true
|
||||
- name: GRAPHITI_MCP_URL
|
||||
description: "Memory graph URL"
|
||||
required: false
|
||||
default: "http://127.0.0.1:8000/sse"
|
||||
distribution_owned: # optional; defaults to SOUL.md, config.yaml,
|
||||
# mcp.json, skills/, cron/, distribution.yaml
|
||||
- SOUL.md
|
||||
- skills/compliance/
|
||||
- cron/
|
||||
```
|
||||
|
||||
`hermes_requires` supports `>=`, `<=`, `==`, `!=`, `>`, `<`, or a bare
|
||||
version (treated as `>=`). Install fails with a clear error if the current
|
||||
Hermes version doesn't satisfy the spec.
|
||||
|
||||
`distribution_owned` is optional. If set, only those paths are replaced on
|
||||
update; anything else in the profile stays user-owned. If omitted, the
|
||||
defaults above apply.
|
||||
|
||||
### Publishing a distribution
|
||||
|
||||
Authoring a distribution is just a git push:
|
||||
|
||||
1. In your profile directory, create `distribution.yaml` with at least `name`
|
||||
and `version`.
|
||||
2. Initialize a git repo (or use an existing one) and push to GitHub /
|
||||
GitLab / any host Hermes can clone from.
|
||||
3. Tell recipients to run `hermes profile install <your-repo-url>`.
|
||||
|
||||
Use git tags for versioned releases — recipients who clone `HEAD` get your
|
||||
latest state, and you can always bump `version:` in the manifest.
|
||||
|
||||
## `hermes -p` / `hermes --profile`
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue