Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
+280
-3
@@ -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)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user