Files
hermes-agent/hermes_cli/subcommands/profile.py
T
teknium1 acbecf588a fix(profiles): --clone leaves messaging channels behind; --clone-channels opts in
A cloned profile carried the source's TELEGRAM_BOT_TOKEN, DISCORD_BOT_TOKEN,
allowlists, WHATSAPP_ENABLED, API_SERVER_KEY and the platforms:/telegram:/
discord: config sections byte-for-byte. Standalone, that made two gateways
fight over one bot's long-poll; under multiplex it blocked
`hermes gateway migrate --multiplex` with one duplicate-credential finding
per platform per clone (18 on a real 10-profile install).

Every clone entry point (CLI --clone/--clone-from/--clone-all, dashboard
POST /api/profiles, TUI/Desktop profiles.create incl. its mirror_credentials
.env copy) now strips channel settings after the copy. The key set is derived
from the adapters — Platform enum + plugin registry (required_env,
allowed_users_env, allow_all_env, cron_deliver_env_var), the gateway env table
(gateway.config_env._ENV_STEPS / _ENV_ENABLE_CREDENTIALS) and each platform's
env prefix — so a new adapter is covered without a hand list. --clone-all also
drops pairing/WhatsApp-session/gateway ledgers. Provider and tool keys, the
model block, memory, skills and SOUL.md are untouched.

`--clone-channels` (REST/RPC: clone_channels) keeps them; it is refused when a
live multiplexer already serves the source and otherwise warns which
platforms are now shared. `hermes profile list` prints the same warning for
existing clones whose bot credential is byte-identical to the default's.

The dashboard's per-platform env-prefix table moves into profile_channels so
Channels-page cards and the clone stripper share one definition.
2026-09-12 18:35:21 -07:00

137 lines
7.4 KiB
Python

"""``hermes profile`` subcommand parser."""
from __future__ import annotations
from typing import Callable
def build_profile_parser(subparsers, *, cmd_profile: Callable) -> None:
"""Attach the ``profile`` subcommand to ``subparsers``."""
profile_parser = subparsers.add_parser(
"profile", help="Manage profiles — multiple isolated Hermes instances")
profile_subparsers = profile_parser.add_subparsers(dest="profile_action")
profile_subparsers.add_parser("list", help="List all profiles")
profile_use = profile_subparsers.add_parser("use", help="Set sticky default profile")
profile_use.add_argument("profile_name", help="Profile name (or 'default')")
profile_create = profile_subparsers.add_parser("create", help="Create a new profile")
profile_create.add_argument("profile_name", help="Profile name (lowercase, alphanumeric)")
profile_create.add_argument(
"--clone", action="store_true",
help="Copy config.yaml, .env, SOUL.md, and skills from active profile "
"(messaging bot tokens/allowlists are left behind; see --clone-channels)")
profile_create.add_argument(
"--clone-all", action="store_true",
help="Full copy of active profile (all state, excluding per-profile history and messaging channels)")
profile_create.add_argument(
"--clone-from", metavar="SOURCE",
help="Source profile to clone from; implies --clone unless --clone-all is set")
profile_create.add_argument(
"--clone-channels", action="store_true",
help="Also copy the source's messaging channels (bot tokens, allowlists, platform sections). "
"Two profiles holding one bot token collide; refused when the source is served by a live "
"multiplexed gateway.")
profile_create.add_argument(
"--no-alias", action="store_true", help="Skip wrapper script creation")
profile_create.add_argument(
"--no-skills", action="store_true",
help="Create an empty profile with no bundled skills (opts out of `hermes update` skill sync)",
)
profile_create.add_argument(
"--description", default=None,
help="One- or two-sentence description of what this profile is good at. "
"Used by the kanban decomposer to route tasks based on role instead "
"of profile name alone. Skip and add later via `hermes profile describe`.")
profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile")
profile_delete.add_argument("profile_name", help="Profile to delete")
profile_delete.add_argument("-y", "--yes", action="store_true", help="Skip confirmation prompt")
profile_describe = profile_subparsers.add_parser(
"describe", help="Read or set a profile's description (used by the kanban orchestrator)")
profile_describe.add_argument(
"profile_name", nargs="?", default=None,
help="Profile to describe (omit + use --all --auto to sweep)")
profile_describe.add_argument(
"--text", default=None,
help="Set description to this exact text (overwrites any existing description)")
profile_describe.add_argument(
"--auto", action="store_true",
help="Auto-generate description via the auxiliary LLM "
"(uses auxiliary.profile_describer)")
profile_describe.add_argument(
"--overwrite", action="store_true",
help="With --auto, replace user-authored descriptions too (default: only "
"fill in missing or previously-auto descriptions)")
profile_describe.add_argument(
"--all", dest="all_missing", action="store_true",
help="With --auto, run on every profile missing a description")
profile_show = profile_subparsers.add_parser("show", help="Show profile details")
profile_show.add_argument("profile_name", help="Profile to show")
profile_alias = profile_subparsers.add_parser("alias", help="Manage wrapper scripts")
profile_alias.add_argument("profile_name", help="Profile name")
profile_alias.add_argument("--remove", action="store_true", help="Remove the wrapper script")
profile_alias.add_argument(
"--name", dest="alias_name", metavar="NAME",
help="Custom alias name (default: profile name)")
profile_rename = profile_subparsers.add_parser(
"rename", help="Rename a profile ('default': sets a display name; id unchanged)")
profile_rename.add_argument("old_name", help="Current profile name")
profile_rename.add_argument(
"new_name",
help="New profile name (for 'default': a display name — the canonical id stays 'default')")
profile_export = profile_subparsers.add_parser("export", help="Export a profile to archive")
profile_export.add_argument("profile_name", help="Profile to export")
profile_export.add_argument(
"-o", "--output", default=None,
help="Output file (default: a managed profile-exports/<name>-<timestamp>.tar.gz "
"under the default Hermes home)")
profile_import = profile_subparsers.add_parser("import", help="Import a profile from archive")
profile_import.add_argument("archive", help="Path to .tar.gz archive")
profile_import.add_argument(
"--name", dest="import_name", metavar="NAME",
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)