Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

This commit is contained in:
Brooklyn Nicholson
2026-05-08 13:06:23 -04:00
52 changed files with 5252 additions and 65 deletions
+280 -3
View File
@@ -8163,8 +8163,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 = (
@@ -8178,7 +8184,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":
@@ -8317,6 +8328,7 @@ def cmd_profile(args):
_read_config_model,
_check_gateway_running,
_count_skills,
_read_distribution_meta,
)
if not profile_exists(name):
@@ -8326,6 +8338,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}")
@@ -8340,6 +8353,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()
@@ -8420,6 +8438,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.
@@ -10669,6 +10889,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)
# =========================================================================