Files
hermes-agent/hermes_cli/profile_channels.py
teknium1 3eac7065ab fix: a channel-less profile clone drops Home Assistant identity while its plugin is absent
Profile cloning learns which env keys and config blocks belong to a messaging channel from core's
platform registry. Moving Home Assistant into a plugin left that ownership only on the loaded
plugin, so while the migration is pending (catalog miss, offline, lazy installs off, declined),
`hermes profile create X --clone` kept HASS_TOKEN/HASS_URL and the gateway.homeassistant block in
the clone while printing "Messaging channels were NOT cloned (homeassistant)". The copied token
then reads as Home Assistant in use, so the clone would migrate onto the source's HA identity.

LEFT_CORE rows now declare the channel ownership core had for their platform: enable_env (the env
that enabled the adapter) and channel_env_prefixes (prefixes shared with the plugin's tools).
profile_channels includes those platforms in platform_ids, the shared-prefix rule, the enable-gate
and the duplicate-credential inventory regardless of plugin installation, so the source-runs-the-
adapter rule is exactly core's: enabled (config or HASS_TOKEN) -> stripped; explicitly disabled,
tool-only -> HASS_* kept as a tool credential.
2026-10-04 00:08:20 -07:00

534 lines
25 KiB
Python

"""Messaging-channel settings a profile clone must NOT inherit.
A ``--clone``d profile that keeps the source's bot tokens, allowlists and platform state makes two
gateways fight over one bot (standalone) or blocks ``hermes gateway migrate --multiplex`` with a
duplicate-credential finding per platform.
The inventory is OWNERSHIP-based and evaluated in the SOURCE profile's plugin scope: every adapter
(built-in ``Platform`` member, a platform that left core — its ``LEFT_CORE`` row, plugin installed or
not — or a plugin registered under the source's ``HERMES_HOME``) owns the env
keys it declares outright (``required_env``, allowlist / allow-all / home-channel names, the gateway
env-override table ``gateway.config_env._ENV_STEPS`` / ``_ENV_ENABLE_CREDENTIALS``) plus every key
under its canonical ``<PLATFORM>_`` prefix and its historical alias prefixes. Gateway-wide channel
policy (``GATEWAY_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOWED_USERS``) and relay enrollment identity
(``GATEWAY_RELAY_*``) are channel settings too. A prefix an adapter SHARES with a non-channel
capability (``TWILIO_*`` is also the telephony skill, ``EMAIL_*`` mail-sending scripts, and a plugin
platform's ``shared_env_prefixes`` its own tools) is stripped only when the source actually runs that adapter — the credential is
then the bot's identity; otherwise it is a tool key and survives. Model/provider keys, tool keys,
memory and general config are never touched.
"""
from __future__ import annotations
import contextlib
import logging
import re
from functools import partial
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Set, Tuple
logger = logging.getLogger(__name__)
# Historical env prefixes that do not match a platform's config id. They SUPPLEMENT the canonical
# ``<PLATFORM>_`` prefix (``WECOM_DM_POLICY`` and ``SMS_WEBHOOK_PORT`` are wecom / sms keys too).
_PLATFORM_ENV_PREFIX_ALIASES: dict[str, tuple[str, ...]] = {
"email": ("EMAIL_",),
"qqbot": ("QQ_",),
"relay": ("GATEWAY_RELAY_",),
"sms": ("TWILIO_",),
}
# Pseudo-platform for gateway-wide channel policy: not an adapter, but a clone that inherits the
# source's allow-all / allowlist boundary hands a freshly configured bot the source's authorization.
GATEWAY_POLICY_ID = "gateway"
_GATEWAY_POLICY_KEYS = ("GATEWAY_ALLOW_ALL_USERS", "GATEWAY_ALLOWED_USERS")
# Prefixes a messaging adapter shares with a NON-channel capability. Their credentials belong to the
# channel only while the source runs that adapter; the adapter's policy keys are channel-only always.
_SHARED_WITH_TOOLS: dict[str, tuple[str, ...]] = {
"sms": ("TWILIO_",), # telephony skill reads TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN
"email": ("EMAIL_",), # mail-sending skills read EMAIL_ADDRESS / EMAIL_PASSWORD / EMAIL_SMTP_*
}
_POLICY_MARKERS = ("_ALLOWED_USERS", "_ALLOW_ALL_USERS", "_ALLOWED_CHATS", "_HOME_CHANNEL", "_HOME_ADDRESS")
# Multiplexer-owner settings: a clone of the default that inherits them and is then started
# standalone tries to be a second multiplexer for every profile on the host.
_GATEWAY_OWNER_KEYS = ("multiplex_profiles", "profile_routes")
_ENV_LINE_RE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
@contextlib.contextmanager
def _plugin_scope(source_dir: Optional[Path]):
"""Evaluate adapter discovery in ``source_dir``'s plugin scope (its private ``plugins/``), not the
ambient profile's. ``None`` keeps the ambient scope."""
if source_dir is None:
yield
return
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
token = set_hermes_home_override(str(source_dir))
try:
yield
finally:
reset_hermes_home_override(token)
def platform_env_prefixes(platform_id: str) -> tuple[str, ...]:
"""Env-var prefixes owned by one messaging platform: canonical ``<PLATFORM>_`` plus aliases."""
canonical = platform_id.upper().replace("-", "_") + "_"
return (canonical, *_PLATFORM_ENV_PREFIX_ALIASES.get(platform_id, ()))
def _registry_entries() -> list:
with contextlib.suppress(Exception):
from hermes_cli.plugins import discover_plugins
discover_plugins() # idempotent per profile scope
from gateway.platform_registry import platform_registry
return list(platform_registry.all_entries())
return []
def _left_core_platforms() -> list:
"""Rows of platforms that left core for a catalog plugin (``hermes_cli.left_core_migration``).
Their channel ownership comes from the row, so it holds while the plugin is absent."""
from hermes_cli.left_core_migration import LEFT_CORE
return [feature for feature in LEFT_CORE if feature.platform]
def _shared_with_tools(source_dir: Optional[Path] = None) -> Dict[str, Tuple[str, ...]]:
"""``_SHARED_WITH_TOOLS``, left-core platforms' prefixes, and every plugin platform's
``shared_env_prefixes`` in ``source_dir``'s scope."""
shared = dict(_SHARED_WITH_TOOLS)
shared.update({f.platform: f.channel_env_prefixes for f in _left_core_platforms() if f.channel_env_prefixes})
with _plugin_scope(source_dir):
for entry in _registry_entries():
prefixes = tuple(getattr(entry, "shared_env_prefixes", ()) or ())
if prefixes:
shared[entry.name] = prefixes
return shared
def platform_ids(source_dir: Optional[Path] = None) -> List[str]:
"""Every messaging platform id: built-in ``Platform`` members, platforms that left core, plus the
plugin adapters registered in ``source_dir``'s scope (ambient scope when ``None``)."""
from gateway.config import Platform
ids = {m.value for m in Platform.__members__.values() if m.value != "local"}
ids.update(feature.platform for feature in _left_core_platforms())
with _plugin_scope(source_dir):
ids.update(entry.name for entry in _registry_entries())
return sorted(ids)
def _cred_row_envs(row) -> Set[str]:
"""Every env name a ``gateway.config_env._Cred`` row reads."""
names: Set[str] = set()
def _flatten(spec) -> None:
if isinstance(spec, str):
names.add(spec)
elif isinstance(spec, (tuple, list)):
for item in spec:
_flatten(item)
_flatten(row.creds)
if row.token:
names.add(row.token)
for key_env in (*row.fixed, *row.optional, *row.optional_stripped):
_flatten(key_env[1])
if row.warn_missing:
names.add(row.warn_missing[0])
if row.home:
names.update({row.home, f"{row.home}_NAME", f"{row.home}_THREAD_ID"})
return names
def config_env_table_keys() -> Dict[str, str]:
"""``{ENV_KEY: platform_id}`` for the names the gateway env-override table reads outright: the
enable-credential sets and every ``_Cred`` row. Registry-free, so it is cheap at import time;
raises if the table cannot be read (callers decide whether that is fatal)."""
from gateway import config_env
keys: Dict[str, str] = {}
for platform, names in config_env._ENV_ENABLE_CREDENTIALS.items():
keys.update(dict.fromkeys(names, platform.value))
for step in config_env._ENV_STEPS:
if isinstance(step, config_env._Cred):
keys.update(dict.fromkeys(_cred_row_envs(step), step.platform.value))
return keys
def declared_channel_env_keys(source_dir: Optional[Path] = None) -> Dict[str, str]:
"""``{ENV_KEY: platform_id}`` for every env name an adapter declares outright (registry entry
fields, the gateway env-override table) plus gateway-wide channel policy. Prefix matching covers
the rest."""
keys: Dict[str, str] = dict.fromkeys(_GATEWAY_POLICY_KEYS, GATEWAY_POLICY_ID)
for feature in _left_core_platforms():
keys.update(dict.fromkeys(feature.enable_env, feature.platform))
with _plugin_scope(source_dir):
for entry in _registry_entries():
for name in (*entry.required_env, entry.allowed_users_env, entry.allow_all_env, entry.cron_deliver_env_var):
if name:
keys[name] = entry.name
with contextlib.suppress(Exception):
from gateway import config_env
keys.update(config_env_table_keys())
for step in config_env._ENV_STEPS:
if isinstance(step, partial):
platform = step.keywords.get("platform")
for kw in ("env", "env_base"):
if step.keywords.get(kw) and platform is not None:
keys[step.keywords[kw]] = platform.value
return keys
def _policy_env_keys(source_dir: Optional[Path] = None) -> Set[str]:
"""Allowlist / allow-all / home-channel names adapters declare — channel-only by nature."""
names: Set[str] = set(_GATEWAY_POLICY_KEYS)
with _plugin_scope(source_dir):
for entry in _registry_entries():
names.update(n for n in (entry.allowed_users_env, entry.allow_all_env, entry.cron_deliver_env_var) if n)
with contextlib.suppress(Exception):
from gateway import config_env
for step in config_env._ENV_STEPS:
if isinstance(step, config_env._Cred) and step.home:
names.update({step.home, f"{step.home}_NAME", f"{step.home}_THREAD_ID"})
elif isinstance(step, partial) and step.keywords.get("env_base"):
base = step.keywords["env_base"]
names.update({base, f"{base}_NAME", f"{base}_THREAD_ID"})
return names
_CREDENTIAL_SUFFIXES = (
"_TOKEN", "_SECRET", "_KEY", "_PASSWORD", "_APP_ID", "_CLIENT_ID", "_BOT_ID", "_ACCOUNT_SID",
"_SERVICE_ACCOUNT_JSON", "_PROJECT_ID",
)
def credential_env_keys() -> Dict[str, str]:
"""``{ENV_KEY: platform_id}`` for the keys that make an adapter CONNECT AS a bot (token / app id /
client id / secret — the shape ``GatewayRunner._adapter_credential_fingerprint`` hashes). Enable
flags, URLs and hosts are excluded: two profiles pointing at one Mattermost server collide only
when they also share the token."""
keys: Dict[str, str] = {}
for feature in _left_core_platforms():
keys.update(dict.fromkeys(feature.enable_env, feature.platform))
for entry in _registry_entries():
keys.update(dict.fromkeys(entry.required_env, entry.name))
with contextlib.suppress(Exception):
from gateway import config_env
for platform, names in config_env._ENV_ENABLE_CREDENTIALS.items():
keys.update(dict.fromkeys(names, platform.value))
for step in config_env._ENV_STEPS:
if isinstance(step, config_env._Cred):
creds: Set[str] = set()
for group in step.creds:
creds.update((group,) if isinstance(group, str) else group)
if step.token:
creds.add(step.token)
keys.update(dict.fromkeys(creds, step.platform.value))
return {key: pid for key, pid in keys.items() if key.endswith(_CREDENTIAL_SUFFIXES)}
def _env_values(env_path: Path, wanted: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""Non-blank assignments in ``env_path`` (restricted to ``wanted`` keys when given)."""
values: Dict[str, str] = {}
if not env_path.is_file():
return values
from dotenv import dotenv_values
with contextlib.suppress(Exception):
for key, value in (dotenv_values(env_path, encoding="utf-8-sig") or {}).items():
if (wanted is None or key in wanted) and value and value.strip():
values[key] = value.strip()
return values
def _explicit_enabled(raw: dict, pid: str) -> Optional[bool]:
"""``platforms.<pid>.enabled`` (either nesting) when the source config states it, else None."""
gateway = raw["gateway"] if isinstance(raw.get("gateway"), dict) else {}
for section in (raw.get("platforms"), gateway.get("platforms")):
block = section.get(pid) if isinstance(section, dict) else None
if isinstance(block, dict) and "enabled" in block:
return bool(block["enabled"])
return None
def _shared_adapters_active(source_dir: Optional[Path], shared: Dict[str, Tuple[str, ...]]) -> Set[str]:
"""Shared-prefix platforms the SOURCE runs as a channel: explicitly enabled in its config.yaml, or
auto-enabled by a complete credential set in its ``.env`` and not explicitly disabled — the same
gate ``gateway.config_env._Cred`` applies at gateway start."""
if source_dir is None:
return set(shared) # no source to consult: the historical (strip) behaviour
raw: dict = {}
if (source_dir / "config.yaml").is_file():
from hermes_cli.config import read_user_config_raw
with contextlib.suppress(Exception):
raw = read_user_config_raw(source_dir / "config.yaml") or {}
env = _env_values(source_dir / ".env")
creds_by_platform: Dict[str, Set[str]] = {}
with contextlib.suppress(Exception):
from gateway import config_env
for platform, names in config_env._ENV_ENABLE_CREDENTIALS.items():
creds_by_platform[platform.value] = set(names)
for feature in _left_core_platforms():
creds_by_platform.setdefault(feature.platform, set(feature.enable_env))
with _plugin_scope(source_dir):
for entry in _registry_entries():
creds_by_platform.setdefault(entry.name, set(entry.required_env))
active: Set[str] = set()
for pid in shared:
explicit = _explicit_enabled(raw, pid)
if explicit is not None:
if explicit:
active.add(pid)
continue
creds = creds_by_platform.get(pid) or set()
if creds and all(env.get(name) for name in creds):
active.add(pid)
return active
class ChannelKeyIndex:
"""Resolves an env key to the messaging platform that owns it (``None`` = not a channel key),
evaluated in ``source_dir``'s plugin scope with the ownership rule for shared prefixes."""
def __init__(self, source_dir: Optional[Path] = None) -> None:
self.source_dir = source_dir
self.platforms = platform_ids(source_dir)
self.declared = declared_channel_env_keys(source_dir)
self.policy = _policy_env_keys(source_dir)
self.shared = _shared_with_tools(source_dir)
self.shared_active = _shared_adapters_active(source_dir, self.shared)
self._prefixes: List[Tuple[str, str]] = sorted(
((prefix, pid) for pid in self.platforms
for prefix in dict.fromkeys((*platform_env_prefixes(pid), *self.shared.get(pid, ())))),
key=lambda item: -len(item[0]), # longest prefix wins: WECOM_CALLBACK_ before WECOM_
)
def platform_for(self, key: str) -> Optional[str]:
pid = self.declared.get(key) or next((pid for prefix, pid in self._prefixes if key.startswith(prefix)), None)
if pid is None:
return None
shared = self.shared.get(pid)
if shared and key.startswith(shared) and pid not in self.shared_active and not self._is_policy(key):
return None # a tool credential the source never used as a bot: keep it
return pid
def _is_policy(self, key: str) -> bool:
return key in self.policy or any(marker in key for marker in _POLICY_MARKERS)
def _env_key_of_line(line: str) -> Optional[str]:
match = _ENV_LINE_RE.match(line)
return match.group(1) if match else None
def strip_channel_env_file(env_path: Path, index: Optional[ChannelKeyIndex] = None) -> Dict[str, List[str]]:
"""Drop every messaging-channel assignment from ``env_path`` in place; comments, blank lines and
every other key survive verbatim. Returns ``{platform: [keys removed]}``."""
if not env_path.is_file():
return {}
index = index or ChannelKeyIndex()
removed: Dict[str, List[str]] = {}
kept: List[str] = []
text = env_path.read_text(encoding="utf-8-sig", errors="replace")
for line in text.splitlines():
key = _env_key_of_line(line)
platform = index.platform_for(key) if key else None
if key is None or platform is None:
kept.append(line)
else:
removed.setdefault(platform, []).append(key)
if removed:
env_path.write_text("\n".join(kept) + ("\n" if text.endswith("\n") or kept else ""), encoding="utf-8")
return removed
def _channel_config_paths(raw: dict, platforms: Iterable[str]) -> List[Tuple[str, ...]]:
"""Dotted paths in a raw config.yaml mapping that hold platform identity: ``platforms``, every
top-level ``<platform>:`` block, ``gateway.platforms`` / ``gateway.<platform>``, and the
multiplexer-owner keys (both spellings the gateway loader accepts)."""
paths: List[Tuple[str, ...]] = []
gateway: dict = raw["gateway"] if isinstance(raw.get("gateway"), dict) else {}
if "platforms" in raw:
paths.append(("platforms",))
if "platforms" in gateway:
paths.append(("gateway", "platforms"))
for key in _GATEWAY_OWNER_KEYS:
if key in raw:
paths.append((key,))
if key in gateway:
paths.append(("gateway", key))
for pid in platforms:
if pid in raw:
paths.append((pid,))
if pid in gateway:
paths.append(("gateway", pid))
return paths
def strip_channel_config(config_path: Path, index: Optional[ChannelKeyIndex] = None) -> List[str]:
"""Remove platform sections from a raw ``config.yaml`` in place. Returns the dotted paths removed."""
if not config_path.is_file():
return []
from hermes_cli.config import atomic_config_replace, read_user_config_raw
index = index or ChannelKeyIndex()
raw = read_user_config_raw(config_path)
paths = _channel_config_paths(raw, index.platforms)
if not paths:
return []
for path in paths:
node = raw
for seg in path[:-1]:
node = node[seg]
node.pop(path[-1], None)
if isinstance(raw.get("gateway"), dict) and not raw["gateway"]:
raw.pop("gateway")
atomic_config_replace(config_path, raw)
return [".".join(path) for path in paths]
def channel_state_entries(root: Path, index: Optional[ChannelKeyIndex] = None) -> List[Path]:
"""Root entries of a profile that hold per-bot runtime identity: pairing approvals and the
WhatsApp device session (``platforms/`` + legacy dirs), the gateway's per-platform ledgers,
channel directories and every ``<platform>_*`` file OR directory an adapter writes beside
config.yaml (Google Chat keeps user tokens in ``google_chat_user_tokens/``)."""
if not root.is_dir():
return []
index = index or ChannelKeyIndex()
fixed = {"platforms", "pairing", "whatsapp", "gateway", "channel_directory.json", "channel_aliases.json"}
prefixes = tuple(f"{pid}_" for pid in index.platforms)
return sorted(entry for entry in root.iterdir() if entry.name in fixed or entry.name.startswith(prefixes))
def _remove_entry(entry: Path) -> None:
"""Remove a state entry without following symlinks (``rmtree`` refuses a symlinked dir and would
otherwise leave the copied link in place; unlinking the link never touches the source)."""
import shutil
if entry.is_symlink() or not entry.is_dir():
entry.unlink(missing_ok=True)
else:
shutil.rmtree(entry, ignore_errors=True)
def strip_channel_settings(profile_dir: Path, *, include_state: bool, source_dir: Optional[Path] = None) -> Dict[str, List[str]]:
"""Strip channel credentials/identity from a freshly cloned profile, judged in ``source_dir``'s
plugin scope. ``include_state`` also drops the runtime state ``--clone-all`` copied. Returns
``{platform|"config"|"state": [what]}``."""
index = ChannelKeyIndex(source_dir)
stripped: Dict[str, List[str]] = dict(strip_channel_env_file(profile_dir / ".env", index))
config_paths = strip_channel_config(profile_dir / "config.yaml", index)
if config_paths:
stripped["config"] = config_paths
if include_state:
dropped = []
for entry in channel_state_entries(profile_dir, index):
_remove_entry(entry)
dropped.append(entry.name)
if dropped:
stripped["state"] = dropped
return stripped
def channel_platforms_configured(profile_dir: Path) -> List[str]:
"""Platform ids with any channel setting in ``profile_dir`` (.env keys or config.yaml sections) —
what a channel-less clone of it leaves behind. Pure read, in ``profile_dir``'s plugin scope."""
index = ChannelKeyIndex(profile_dir)
found: Set[str] = set()
env_path = profile_dir / ".env"
if env_path.is_file():
for line in env_path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
key = _env_key_of_line(line)
platform = index.platform_for(key) if key else None
if platform and platform != GATEWAY_POLICY_ID:
found.add(platform)
config_path = profile_dir / "config.yaml"
if config_path.is_file():
from hermes_cli.config import read_user_config_raw
raw = read_user_config_raw(config_path)
for path in _channel_config_paths(raw, index.platforms):
node = raw
for seg in path:
node = node[seg]
if path[-1] == "platforms" and isinstance(node, dict):
found.update(str(k) for k in node)
elif path[-1] in index.platforms:
found.add(path[-1])
return sorted(found)
def clone_channels_refusal(source_dir: Path, source_label: str) -> Optional[str]:
"""Why ``--clone-channels`` must be refused for ``source_dir``: a live multiplexer already serves
the source, so the copied bot would be parked as a duplicate credential at once (the same finding
the migrate preflight reports). ``None`` when the copy is allowed. Shared by CLI, REST and TUI
through :func:`hermes_cli.profiles.create_profile`."""
from hermes_cli.gateway_multiplex_served import recorded_served_profiles
from hermes_cli.profiles import normalize_profile_name
served = recorded_served_profiles()
if not served or len(served) < 2 or normalize_profile_name(source_label) not in {
normalize_profile_name(p) for p in served
}:
return None
platforms = channel_platforms_configured(source_dir)
if not platforms:
return None
return (
f"--clone-channels would copy {', '.join(platforms)} from '{source_label}', which the running "
"multiplexed gateway already serves: the bot can only belong to one profile, so the copy would be "
"parked as a duplicate credential. Clone without --clone-channels and give the new profile its own bot "
"(hermes -p <name> setup), or route its chats with gateway.profile_routes instead."
)
def _config_platform_tokens(config_path: Path) -> Dict[str, str]:
"""``{platform: token}`` from ``platforms.<p>.token|api_key`` (both nesting spellings)."""
tokens: Dict[str, str] = {}
if not config_path.is_file():
return tokens
from hermes_cli.config import read_user_config_raw
raw = read_user_config_raw(config_path)
gateway: dict = raw["gateway"] if isinstance(raw.get("gateway"), dict) else {}
for section in (raw.get("platforms"), gateway.get("platforms")):
if not isinstance(section, dict):
continue
for pid, block in section.items():
if isinstance(block, dict):
token = block.get("token") or block.get("api_key")
if isinstance(token, str) and token.strip():
tokens[str(pid)] = token.strip()
return tokens
def shared_channel_credentials(profile_dir: Path, source_dir: Path) -> List[str]:
"""Platforms whose CONNECTING credential (bot token / app id / account) in ``profile_dir`` is
byte-identical to ``source_dir``'s — the bots that will collide. Pure file reads: no secret
manager, no gateway config load, so ``hermes profile list`` can afford it per profile."""
wanted = credential_env_keys()
mine = _env_values(profile_dir / ".env", wanted)
theirs = _env_values(source_dir / ".env", wanted)
shared = {wanted[key] for key in mine if theirs.get(key) == mine[key]}
mine_cfg = _config_platform_tokens(profile_dir / "config.yaml")
theirs_cfg = _config_platform_tokens(source_dir / "config.yaml")
shared.update(pid for pid, token in mine_cfg.items() if theirs_cfg.get(pid) == token)
return sorted(shared)
def shared_credential_warning(profile: str, platforms: List[str], source: str = "default") -> str:
return (
f"⚠ Profile '{profile}' shares its {', '.join(platforms)} credential with {source}: the bot can "
f"only belong to one profile. Give '{profile}' its own bot (hermes -p {profile} setup, or the "
f"dashboard Messaging page) or remove the token from '{profile}'; a multiplexed gateway parks "
f"the duplicate and `hermes gateway migrate --multiplex` refuses until it is gone."
)
def format_stripped_notice(profile: str, platforms: List[str], clone_flag: str = "--clone") -> List[str]:
"""Lines printed after a channel-less clone so the user knows what was left behind and how to
configure the new profile's own bots."""
if not platforms:
return []
return [
f"Messaging channels were NOT cloned ({', '.join(platforms)}): a copied bot token or allowlist "
"would make two gateways fight over one bot.",
f" Configure this profile's own bots: hermes -p {profile} setup (or the dashboard Messaging page)",
f" To copy the source's channels anyway: hermes profile create {profile} {clone_flag} --clone-channels",
]