`hermes profile delete` removes the profile directory and tears its runtime down, but the name is also baked into durable identity the delete path never touches — `agent:<name>:*` routing keys, `gateway_heartbeats.profile` and `delivery_obligations`. An inbound event on a chat keyed to the dead name then enters the routing index, resolves a profile whose directory is gone, and logs `Profile '<name>' does not exist` on every event for the life of the store (the #111926 flood, reached from a *deleted* rather than a renamed profile). The delete side is now symmetric with the rename rekey (`rekey_profile_state` / `rekey_profile_routing` / `migrate-profile-identity`), with the same ownership rule: - `SessionDB.purge_profile_state(name)` — the mirror of `rekey_profile_state`, in one `_execute_write` transaction. Routing keys, heartbeat rows and the telegram topic rows the rekey also owns are hard-deleted (a binding is matched by `profile_name` OR its `session_key` namespace, because the rename rewrites both); `delivery_obligations` rows are terminalized (`state='abandoned'`) rather than dropped, so pending delivery state is not lost silently. - `SessionStore.purge_profile_routing(name)` — the mirror of `rekey_profile_routing`: drops the in-memory entries and persists the drop. Mandatory, not belt-and-braces — the owning process writes its in-memory copy back, so a durable delete made elsewhere is undone by its next save. - A delete-only control verb `purge-profile-identity`, deliberately NOT inside `_unserve_profile()`: that hook also unserves a rename's old name, whose identity the rekey still has to migrate. `hermes profile delete` requires the owner's `{"ok": true}` answer and reports a partial settlement (naming the retry) instead of a clean success. - The retry is the new `hermes profile purge-identity <name>`. It refuses a name that is a live profile again: the purge keys off the name alone, so `delete foo` (settlement pending) → `create foo` → `purge-identity foo` would otherwise delete the NEW incarnation's identity. The delete path tombstones the directory before it purges, so the guard never blocks the delete. - `sessions` rows are not deleted by the purge: it settles identity, not history. What a delete leaves of a profile's conversation record is `delete_profile`'s business — it removes the profile's own home, `state.db` included. Tests (`scripts/run_tests.sh`, red on base → green): `tests/hermes_state/test_purge_profile_state.py`, `tests/gateway/test_purge_profile_routing.py`, `tests/gateway/test_profile_identity_purge.py`, `tests/hermes_cli/test_profile_identity_purge_cmd.py` and `TestDeleteProfile` in `tests/hermes_cli/test_profiles.py` — 95 passed, 0 failed across those five files.
165 lines
9.3 KiB
Python
165 lines
9.3 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(
|
|
"--sync-imports", action="store_true",
|
|
help="With --clone/--clone-from: also carry over the `hermes import-agent` sync manifest so "
|
|
"the new profile stays registered against the same Claude Code / Codex trees "
|
|
"(`hermes -p <name> import-agent --sync`). Never syncs config from the source profile.")
|
|
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_purge = profile_subparsers.add_parser(
|
|
"purge-identity",
|
|
help="Retry a deleted profile's session/routing identity purge",
|
|
description="Re-run the session/routing identity purge that `hermes profile delete` performs "
|
|
"automatically. The profile directory is already gone when this is needed: state still "
|
|
"keyed by the deleted profile name (routing keys, heartbeats, routing/delivery rows) is "
|
|
"deleted. Run it after restarting the gateway (which reloads the routing index from the "
|
|
"DB) or after stopping it. Idempotent.")
|
|
profile_purge.add_argument("profile_name", help="Deleted profile name")
|
|
|
|
profile_migrate = profile_subparsers.add_parser(
|
|
"migrate-identity",
|
|
help="Retry a renamed profile's session/routing identity migration",
|
|
description="Re-run the session/routing identity migration that `hermes profile rename` "
|
|
"performs automatically. The rename has already happened when this is needed, so pass "
|
|
"the OLD and NEW names: state still keyed by the old profile name (session keys, "
|
|
"profile_name, heartbeats, routing/delivery rows) is rekeyed to the new one. Run it "
|
|
"after restarting the gateway (which reloads the routing index from the DB) or after "
|
|
"stopping it. Idempotent.")
|
|
profile_migrate.add_argument("old_name", help="Profile name before the rename")
|
|
profile_migrate.add_argument("new_name", help="Profile name after the rename")
|
|
|
|
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, mcp.json) and the skills and cron jobs "
|
|
"the distribution ships; skills or cron jobs you added yourself stay in place. "
|
|
"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)
|