diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 99ef669d45..b9c9761125 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -32,99 +32,41 @@ logger = logging.getLogger(__name__) _PROFILE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _WARNED_MISSING_ALLOWLIST_ENTRIES: set[tuple[str, ...]] = set() -# Directories bootstrapped inside every new profile -_PROFILE_DIRS = [ - "memories", - "sessions", - "skills", - "skins", - "logs", - "plans", - "workspace", - "cron", - # Back-compat/Docker HOME for tool subprocesses. Host subprocesses keep - # the user's real HOME by default so normal CLI credentials remain visible; - # containers still use this directory for persistent HOME state. - # See hermes_constants.get_subprocess_home(). - "home", -] +# Directories bootstrapped inside every new profile. ``home`` is the back-compat/Docker +# HOME for tool subprocesses (host subprocesses keep the real HOME so CLI credentials +# stay visible; containers persist HOME state here). See hermes_constants.get_subprocess_home(). +_PROFILE_DIRS = ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron", "home"] -# Files copied during --clone (if they exist in the source) -_CLONE_CONFIG_FILES = [ - "config.yaml", - ".env", - "SOUL.md", -] +# Files copied during --clone (if they exist in the source). +_CLONE_CONFIG_FILES = ["config.yaml", ".env", "SOUL.md"] +# Subdirectory files copied during --clone: memory files are part of the agent's curated +# identity, as important as SOUL.md for continuity. +_CLONE_SUBDIR_FILES = ["memories/MEMORY.md", "memories/USER.md"] -# Subdirectory files copied during --clone (path relative to profile root). -# Memory files are part of the agent's curated identity — just as important -# as SOUL.md for continuity when cloning a profile. -_CLONE_SUBDIR_FILES = [ - "memories/MEMORY.md", - "memories/USER.md", -] +# Runtime files stripped after --clone-all. A post-copy step rather than an ignore filter +# because they are created dynamically and may be absent at copy time. +_CLONE_ALL_STRIP: list[str] = ["gateway.pid", "gateway_state.json", "processes.json"] -# Runtime files stripped after --clone-all (shouldn't carry over). -# Kept as a post-copy step rather than in the ignore filter because they -# are created dynamically during normal use and may be absent at copy time. -_CLONE_ALL_STRIP: list[str] = [ - "gateway.pid", - "gateway_state.json", - "processes.json", -] - -# Infrastructure artifacts excluded from --clone-all when the source is the -# default profile (``~/.hermes``). Named profiles never contain these -# directories at root, so the exclusion is gated to avoid silently dropping -# user data from a named-profile source. -# -# Rationale per item: -# hermes-agent — git repo checkout (~84 MB source + ~3 GB venv) -# .worktrees — git worktrees -# profiles — sibling named profiles (recursive copy never intended) -# bin — installed binaries (tirith etc., ~10 MB) shared per-host -# node_modules — npm packages (hundreds of MB) -# -# Export uses a root allow-list instead (``_DEFAULT_EXPORT_INCLUDE_ROOT``): the -# archive is a portable snapshot, while a clone must keep working immediately. +# Infrastructure excluded from --clone-all ONLY when the source is the default profile +# (``~/.hermes``): git checkout (+ ~3 GB venv), worktrees, sibling profiles, shared bins, +# npm packages. Named profiles never hold these at root, so the gate avoids silently +# dropping user data from a named-profile source. Export uses a root allow-list instead +# (``_DEFAULT_EXPORT_INCLUDE_ROOT``): an archive is a portable snapshot, a clone must run. _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: frozenset[str] = frozenset({ - "hermes-agent", - ".worktrees", - "profiles", - "bin", - "node_modules", + "hermes-agent", ".worktrees", "profiles", "bin", "node_modules", }) -# Per-profile history artifacts excluded from --clone-all regardless of the -# source profile. A new profile is a fresh workspace — inheriting the source -# profile's session history, backup archives, or quick-backup snapshots is -# never useful (restoring one inside the clone would resurrect the SOURCE -# profile's state) and can balloon the copy by tens of GB. Unlike -# ``_CLONE_ALL_DEFAULT_EXCLUDE_ROOT`` this set is NOT gated on the default -# profile: named profiles accumulate the same artifacts. -# -# Rationale per item: -# state.db (+wal/shm) — SQLite session store (can reach many GB) -# sessions — per-session transcript/data dirs -# backups — `hermes backup` archives -# state-snapshots — quick-backup snapshot trees -# checkpoints — session checkpoint data +# Per-profile history excluded from --clone-all for ANY source: SQLite session store +# (+wal/shm, can reach many GB), session dirs, `hermes backup` archives, quick-backup +# snapshots, checkpoints. Inheriting them is never useful (restoring one inside the +# clone would resurrect the SOURCE profile's state) and can balloon the copy by tens of GB. _CLONE_ALL_HISTORY_EXCLUDE_ROOT: frozenset[str] = frozenset({ - "state.db", - "state.db-wal", - "state.db-shm", - "sessions", - "backups", - "state-snapshots", - "checkpoints", + "state.db", "state.db-wal", "state.db-shm", "sessions", "backups", "state-snapshots", "checkpoints", }) -# Marker file written by `hermes profile create --no-skills`. When present in -# a profile's root, callers of seed_profile_skills() (fresh-create, `hermes -# update`'s all-profile sync, the web dashboard) skip bundled-skill seeding -# for that profile. The user can still install skills manually via -# `hermes skills install` or drop SKILL.md files into the profile's skills/. -# Delete the marker file to opt back in. +# Marker written by `hermes profile create --no-skills`. When present at a profile root, +# seed_profile_skills() callers (fresh-create, `hermes update` all-profile sync, the +# dashboard) skip bundled-skill seeding. Delete the file to opt back in. NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills" # Header seeded into a profile's empty .env so it owns a credentials file from day one. @@ -136,35 +78,22 @@ _PLACEHOLDER_ENV = ( def _clone_all_copytree_ignore(source_dir: Path): - """Exclude infrastructure artifacts when cloning a profile via --clone-all. - - Three categories: 1. Root-level entries in ``_CLONE_ALL_HISTORY_EXCLUDE_ROOT`` — session - history, backups, and snapshots that belong to the SOURCE profile and should never carry into a - fresh clone. Applies to any source. 2. - - The export-side ignore (``_default_export_ignore``) uses a root-level allow-list instead - because the export archive is a portable snapshot rather than a live clone. - """ + """copytree ignore for --clone-all: history artifacts for any source, infrastructure + only when the source is the default profile (see the two exclude sets above).""" source_resolved = source_dir.resolve() - is_default_source = source_resolved == _get_default_hermes_home().resolve() - - # History artifacts are excluded for ANY source; infrastructure only - # when the source is the default profile (named profiles never have it). root_exclude = set(_CLONE_ALL_HISTORY_EXCLUDE_ROOT) - if is_default_source: + if source_resolved == _get_default_hermes_home().resolve(): root_exclude |= _CLONE_ALL_DEFAULT_EXCLUDE_ROOT def _ignore(directory: str, names: List[str]) -> List[str]: try: at_root = Path(directory).resolve() == source_resolved except (OSError, ValueError): - # ``resolve()`` can fail on unusual FS layouts (broken - # symlinks, missing parents). Fail open — better to - # over-copy than silently drop user data. + # resolve() can fail on odd FS layouts (broken symlinks, missing parents). + # Fail open — better to over-copy than silently drop user data. at_root = False return [ entry for entry in names - # Universal exclusions at any depth. if entry == "__pycache__" or entry.endswith((".pyc", ".pyo", ".sock", ".tmp")) or (at_root and entry in root_exclude) @@ -173,21 +102,17 @@ def _clone_all_copytree_ignore(source_dir: Path): return _ignore -# Allow-list for ``export_profile("default")``: when HERMES_HOME equals the -# cwd (Docker/custom deployments), the default profile home is the working -# directory and contains arbitrary user files that should NOT be bundled -# into the export. The set below identifies the *known Hermes profile -# artifacts* at the root of HERMES_HOME; everything else is excluded. -# Sensitive runtime infrastructure (``state.db``, ``logs/``, ``auth.*``, -# other profiles) is intentionally *not* in this list so the export stays -# a portable, credential-free snapshot of the user-facing surface -# (#58394). Add new artifacts here when introduced in ``hermes_constants``. +# Allow-list for ``export_profile("default")``: when HERMES_HOME equals the cwd +# (Docker/custom deployments) the default home holds arbitrary user files that must NOT +# be bundled. Only known Hermes profile artifacts at the root survive; sensitive runtime +# infrastructure (``state.db``, ``logs/``, ``auth.*``, other profiles) is deliberately +# absent so the export stays a portable, credential-free snapshot. Add new artifacts here +# when introduced in ``hermes_constants``. _DEFAULT_EXPORT_INCLUDE_ROOT = frozenset({ # Configuration / persona "config.yaml", "SOUL.md", "MEMORY.md", "USER.md", "todo.json", "system_prompt.md", "AGENTS.md", "CLAUDE.md", ".cursorrules", - # Desktop appearance/interface overlay (written by the desktop app's - # profile export; applied by its import — see desktop.json handling). + # Desktop appearance overlay (written/applied by the desktop app's export/import). "desktop.json", # User-facing skill, cron, and session artifacts "skills", "cron", "scripts", "sessions", @@ -196,9 +121,7 @@ _DEFAULT_EXPORT_INCLUDE_ROOT = frozenset({ }) # Names that cannot be used as profile aliases -_RESERVED_NAMES = frozenset({ - "hermes", "default", "test", "tmp", "root", "sudo", -}) +_RESERVED_NAMES = frozenset({"hermes", "default", "test", "tmp", "root", "sudo"}) # Hermes subcommands that cannot be used as profile names/aliases _HERMES_SUBCOMMANDS = frozenset({ @@ -214,31 +137,23 @@ _HERMES_SUBCOMMANDS = frozenset({ # --------------------------------------------------------------------------- def _get_profiles_root() -> Path: - """Return the directory where named profiles are stored. - - Anchored to the hermes root, NOT to the current HERMES_HOME (which may itself be a profile). - This ensures ``coder profile list`` can see all profiles. - """ + """Named-profiles root, anchored to the hermes root (NOT the current HERMES_HOME, which + may itself be a profile) so ``coder profile list`` sees all profiles.""" return _get_default_hermes_home() / "profiles" def _get_default_hermes_home() -> Path: - """Return the default (pre-profile) HERMES_HOME path. - - Normally ``~/.hermes``; in Docker/custom deployments where HERMES_HOME lives elsewhere - (e.g. ``/opt/data``) returns HERMES_HOME itself. - """ + """Default (pre-profile) HERMES_HOME: ``~/.hermes``, or HERMES_HOME itself in + Docker/custom deployments (e.g. ``/opt/data``).""" from hermes_constants import get_default_hermes_root return get_default_hermes_root() def _get_active_profile_path() -> Path: - """Return the path to the sticky active_profile file.""" return _get_default_hermes_home() / "active_profile" def _get_wrapper_dir() -> Path: - """Return the directory for wrapper scripts.""" return Path.home() / ".local" / "bin" @@ -255,17 +170,21 @@ def _is_our_wrapper(path: Path) -> bool: return False +def _missing_profile_error(canon: str) -> FileNotFoundError: + return FileNotFoundError( + f"Profile '{canon}' does not exist. " + f"Create it with: hermes profile create {canon}" + ) + + # --------------------------------------------------------------------------- # Validation # --------------------------------------------------------------------------- def normalize_profile_name(name: str) -> str: - """Return the canonical profile id used on disk and in CLI ``-p`` argv. - - Named profiles are stored lowercase under ``profiles//``; ``default`` matches - case-insensitively. Dashboards/tools may pass title-cased labels, so normalize before - validation, assignment, and subprocess spawn. - """ + """Canonical profile id used on disk and in ``-p`` argv: lowercase, ``default`` matched + case-insensitively. Dashboards/tools may pass title-cased labels — normalize before + validation, assignment, and subprocess spawn.""" if not isinstance(name, str): name = str(name) stripped = name.strip() @@ -277,13 +196,8 @@ def normalize_profile_name(name: str) -> str: def validate_profile_name(name: str) -> None: - """Raise ``ValueError`` if *name* is not a valid profile identifier. - - Strict lowercase match as-given -- callers taking mixed-case user input must call - ``normalize_profile_name`` first, so this stays honest about the on-disk directory name. - Also rejects ``_RESERVED_NAMES`` (``hermes``, ``test``, ``tmp``, ``root``, ``sudo``) that - would collide on disk or be refused at alias creation; ``default`` passes through. - """ + """Raise ``ValueError`` unless *name* is a valid profile id (strict as-given lowercase — + normalize mixed-case input first) and not in ``_RESERVED_NAMES``; ``default`` passes.""" if name == "default": return # special alias for ~/.hermes if not _PROFILE_ID_RE.match(name): @@ -300,13 +214,8 @@ def validate_profile_name(name: str) -> None: def validate_alias_name(name: str) -> None: - """Raise ``ValueError`` if *name* is not a safe wrapper-alias identifier. - - The alias is used verbatim as a filename under :func:`_get_wrapper_dir` (``~/.local/bin``), so - it must be a single safe command name with no path separators or traversal segments — otherwise - a value like ``../../.bashrc`` would escape the wrapper directory and clobber arbitrary user - files. - """ + """Raise ``ValueError`` unless *name* is a safe wrapper filename: it is used verbatim + under ``~/.local/bin``, so ``../../.bashrc`` must never escape the wrapper dir.""" if not _PROFILE_ID_RE.match(name): raise ValueError( f"Invalid alias name {name!r}. Must match " @@ -314,6 +223,13 @@ def validate_alias_name(name: str) -> None: ) +def _canon_valid(name: str) -> str: + """normalize + validate in one step; returns the canonical id.""" + canon = normalize_profile_name(name) + validate_profile_name(canon) + return canon + + def get_profile_dir(name: str) -> Path: """Resolve a profile name to its HERMES_HOME directory.""" canon = normalize_profile_name(name) @@ -332,12 +248,11 @@ def profile_exists(name: str) -> bool: def profile_matches_home(name: str, home: "Path | None" = None) -> bool: - """Return True when *name* refers to the profile served from *home*. + """True when *name* refers to the profile served from *home* (default: current home). Lets single-profile gateways decide whether a ``/p//`` URL prefix is self-referential (safe on the bare route) or names a different profile, which must fail - closed rather than silently resolve the owner's config. Invalid names return False. - """ + closed rather than silently resolve the owner's config. Invalid names return False.""" try: target = get_profile_dir(name) if home is None: @@ -353,10 +268,7 @@ def profile_matches_home(name: str, home: "Path | None" = None) -> bool: def _iter_named_profile_dirs(*, live_only: bool = True) -> List[Path]: - """Sorted named-profile dirs under the profiles root (valid ids, never ``default``). - - ``live_only`` additionally skips tombstoned (deleted) profiles. - """ + """Sorted named-profile dirs (valid ids, never ``default``); ``live_only`` skips tombstones.""" profiles_root = _get_profiles_root() if not profiles_root.is_dir(): return [] @@ -370,11 +282,8 @@ def _iter_named_profile_dirs(*, live_only: bool = True) -> List[Path]: def list_profile_names() -> List[str]: - """Cheap name-only profile listing: ``default`` plus profile dirs. - - Unlike :func:`list_profiles` this reads NO per-profile config/metadata — it is a directory scan, - safe to call from hot paths (cron delivery-target listings, create-time validation). - """ + """Cheap name-only listing (``default`` + profile dirs). Unlike :func:`list_profiles` this + reads NO per-profile config — safe for hot paths (cron target listings, create validation).""" names = ["default"] try: names.extend(entry.name for entry in _iter_named_profile_dirs(live_only=False)) @@ -399,7 +308,6 @@ def check_alias_collision(name: str) -> Optional[str]: if canon in _HERMES_SUBCOMMANDS: return f"'{canon}' conflicts with a hermes subcommand" - # Check existing commands in PATH try: result = subprocess.run( ["where" if sys.platform == "win32" else "which", canon], @@ -407,10 +315,9 @@ def check_alias_collision(name: str) -> Optional[str]: ) if result.returncode == 0: existing_path = result.stdout.strip().splitlines()[0] - # Allow overwriting our own wrappers expected = _wrapper_path(canon) if existing_path == str(expected) and _is_our_wrapper(expected): - return None # it's our wrapper, safe to overwrite + return None # our own wrapper, safe to overwrite return f"'{canon}' conflicts with an existing command ({existing_path})" except (FileNotFoundError, subprocess.TimeoutExpired): pass @@ -419,23 +326,15 @@ def check_alias_collision(name: str) -> Optional[str]: def _is_wrapper_dir_in_path() -> bool: - """Check if ~/.local/bin is in PATH.""" - wrapper_dir = str(_get_wrapper_dir()) - return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep) + return str(_get_wrapper_dir()) in os.environ.get("PATH", "").split(os.pathsep) def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]: - """Create a shell wrapper script at ~/.local/bin/. - - The wrapper file is named after ``name`` (the alias). The profile it activates is ``target`` if - given, otherwise ``name`` — this lets a custom alias name point at a differently-named profile - without a post-hoc rewrite. - """ + """Create ``~/.local/bin/`` activating profile *target* (default: *name*), so a + custom alias can point at a differently-named profile without a post-hoc rewrite.""" canon = normalize_profile_name(name) profile = normalize_profile_name(target) if target else canon - # The alias is used verbatim as a filename under the wrapper dir; reject - # any value that isn't a single safe identifier so it can't traverse out. - validate_alias_name(canon) + validate_alias_name(canon) # alias is a verbatim filename: no traversal wrapper_dir = _get_wrapper_dir() try: wrapper_dir.mkdir(parents=True, exist_ok=True) @@ -460,20 +359,18 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P def remove_wrapper_script(name: str) -> bool: """Remove the wrapper script for a profile. Returns True if removed.""" canon = normalize_profile_name(name) - # A traversal-shaped name could point unlink() at a file outside the - # wrapper dir; refuse it rather than acting on an arbitrary path. + # A traversal-shaped name could point unlink() outside the wrapper dir; refuse it. try: validate_alias_name(canon) except ValueError: return False - # Check both the extensionless path (POSIX) and .bat (Windows) + # Both the extensionless path (POSIX) and .bat (Windows) candidates = [_get_wrapper_dir() / canon] if sys.platform == "win32": candidates.insert(0, _get_wrapper_dir() / f"{canon}.bat") for wrapper_path in candidates: - # Verify it's our wrapper before removing if wrapper_path.exists() and _is_our_wrapper(wrapper_path): try: wrapper_path.unlink() @@ -484,14 +381,9 @@ def remove_wrapper_script(name: str) -> bool: def _migrate_profile_config_if_outdated(profile_dir: Path) -> None: - """Bring a copied profile config.yaml up to the current schema. - - A cloned config may predate schema tracking or be older than the running Hermes; left alone, - the first desktop/doctor view of the new profile shows a scary ``v0 -> latest`` warning. - Runs the normal migration pipeline scoped to the new profile, non-interactively. - """ - config_path = profile_dir / "config.yaml" - if not config_path.exists(): + """Migrate a copied config.yaml to the current schema (non-interactive, scoped to the new + profile); otherwise the first desktop/doctor view shows a scary ``v0 -> latest`` warning.""" + if not (profile_dir / "config.yaml").exists(): return try: @@ -506,43 +398,31 @@ def _migrate_profile_config_if_outdated(profile_dir: Path) -> None: finally: reset_hermes_home_override(token) except Exception: - # Profile creation should not fail because an old copied config could - # not be migrated. The next `hermes doctor --fix` can still surface the - # detailed error in the target profile. + # Creation must not fail over an unmigratable old config; `hermes doctor --fix` + # surfaces the detailed error in the target profile. pass def find_alias_for_profile(profile_name: str) -> Optional[str]: - """Return the alias name of the wrapper that activates *profile_name*, or None. - - A wrapper created by :func:`create_wrapper_script` is a file named after the alias whose body - invokes ``hermes -p ``. - - For listing ALL profiles at once, prefer :func:`build_alias_map` — calling this per-profile re- - reads every wrapper file N times (O(N*M)); on a wrapper dir like ``~/.local/bin`` that also - holds large unrelated binaries (ffmpeg etc.) that meant multi-second ``list_profiles`` latency - and desktop timeouts. - """ + """Alias name of the wrapper activating *profile_name*, or None. For listing ALL profiles + prefer :func:`build_alias_map`: per-profile calls re-read every wrapper N times (O(N*M)), + which on a ``~/.local/bin`` full of large binaries meant multi-second ``list_profiles``.""" return build_alias_map().get(normalize_profile_name(profile_name)) -# Cap how much of a wrapper file we read when reverse-looking-up its profile. -# Real wrappers are a few hundred bytes of shell; the needle (``hermes -p X``) -# sits near the top. The wrapper dir (e.g. ``~/.local/bin``) commonly also holds -# large unrelated binaries (ffmpeg, node, …) — reading those whole, N times, was -# the dominant cost in ``list_profiles`` (~4.5s). Reading a small head slice and -# skipping NUL-bearing (binary) content keeps the scan to a single cheap pass. +# Cap on how much of a wrapper file is read when reverse-looking-up its profile. Real +# wrappers are a few hundred bytes with the ``hermes -p X`` needle near the top; the wrapper +# dir commonly also holds large binaries (ffmpeg, node, …) whose whole-file reads, N times, +# dominated ``list_profiles`` (~4.5s). _WRAPPER_READ_LIMIT = 8192 def build_alias_map() -> dict[str, str]: """Single-pass reverse map ``{canonical_profile -> alias_name}``. - Scans the wrapper dir ONCE (vs. :func:`find_alias_for_profile` per profile) and reads only a - small head slice of each candidate wrapper, skipping binaries. A custom alias (file name != - profile) wins over the profile-named wrapper, matching ``find_alias_for_profile``'s preference; - deterministic via sorted iteration. - """ + Scans the wrapper dir ONCE, reading only a head slice of each candidate and skipping + binaries. A custom alias (file name != profile) wins over the profile-named wrapper; + deterministic via sorted iteration.""" wrapper_dir = _get_wrapper_dir() result: dict[str, str] = {} if not wrapper_dir.is_dir(): @@ -553,7 +433,7 @@ def build_alias_map() -> dict[str, str]: for entry in sorted(wrapper_dir.iterdir()): if not entry.is_file(): continue - # Only our own wrappers are named with the alias and (on Windows) .bat. + # Our wrappers are named after the alias and (on Windows only) carry .bat. if is_windows and entry.suffix != ".bat": continue if not is_windows and entry.suffix: @@ -562,8 +442,7 @@ def build_alias_map() -> dict[str, str]: with open(entry, "r", encoding="utf-8", errors="strict") as f: content = f.read(_WRAPPER_READ_LIMIT) except (OSError, UnicodeDecodeError): - # UnicodeDecodeError = a binary on PATH (ffmpeg etc.) — not a wrapper. - continue + continue # UnicodeDecodeError = a binary on PATH, not a wrapper idx = content.find(prefix) if idx == -1: continue @@ -574,10 +453,8 @@ def build_alias_map() -> dict[str, str]: continue canon = normalize_profile_name(canon) alias = entry.stem if is_windows else entry.name - # Custom alias (name != profile) preferred; otherwise keep the - # profile-named wrapper. Don't overwrite a custom alias already found. if alias == canon: - result.setdefault(canon, alias) + result.setdefault(canon, alias) # never overwrite a custom alias already found else: result[canon] = alias return result @@ -599,27 +476,20 @@ class ProfileInfo: has_env: bool = False skill_count: int = 0 alias_path: Optional[Path] = None - # Custom alias name (the wrapper file name) when it differs from ``name``; - # falls back to ``name`` when a profile-named wrapper exists. None if no - # wrapper points at this profile. See ``find_alias_for_profile``. + # Custom alias (wrapper file name) when it differs from ``name``; ``name`` when a + # profile-named wrapper exists; None if no wrapper points here. alias_name: Optional[str] = None # Distribution metadata (None if the profile wasn't installed from a distribution). distribution_name: Optional[str] = None distribution_version: Optional[str] = None distribution_source: Optional[str] = None - # Free-form description (1-2 sentences) of what this profile is good - # at. Persisted in ``/profile.yaml``. Empty when the - # user has not described the profile (legacy profiles, fresh - # installs). Surfaced to the kanban decomposer so it can route work - # to the right profile based on role rather than name alone. + # 1-2 sentence role description from ``profile.yaml``; empty when never described. + # Surfaced to the kanban decomposer so it routes work by role rather than name. description: str = "" - # When True, ``description`` was auto-generated by the LLM - # describer and has not been confirmed by the user. The dashboard - # surfaces a "review" badge in this case so the user can edit or - # accept. + # True when ``description`` was LLM-generated and not yet user-confirmed (dashboard + # shows a "review" badge). description_auto: bool = False - # Optional user-facing display name from profile.yaml. Presentation - # only — resolution/comparison/spawn paths always use ``name``. + # Presentation-only display name; resolution/comparison/spawn always use ``name``. display_name: str = "" @@ -637,9 +507,7 @@ def _load_yaml_dict(path: Path) -> Optional[dict]: def _read_distribution_meta(profile_dir: Path) -> tuple: - """Return ``(name, version, source)`` from the profile's ``distribution.yaml`` if present; ``(None, - None, None)`` otherwise. - """ + """``(name, version, source)`` from ``distribution.yaml``; ``(None, None, None)`` if absent.""" data = _load_yaml_dict(profile_dir / "distribution.yaml") if data is None: return None, None, None @@ -652,8 +520,7 @@ def _read_config_model(profile_dir: Path) -> tuple: if not config_path.exists(): return None, None try: - # Multi-profile display read: load_config() targets the ACTIVE - # profile's home, so read THIS profile's file via the raw primitive. + # load_config() targets the ACTIVE profile's home; read THIS profile's file raw. from hermes_cli.config import read_user_config_raw cfg = read_user_config_raw(config_path) model_cfg = cfg.get("model", {}) @@ -667,11 +534,8 @@ def _read_config_model(profile_dir: Path) -> tuple: def _seed_model_config(profile_dir: Path) -> None: - """Give a profile created without a clone source a usable model block. - - This is a copy, not a link: profiles remain independent islands, and editing either one - afterwards never touches the other. "Fresh" means fresh skills and SOUL, not unreachable. - """ + """Copy (not link) the active profile's model block into a fresh profile so it is usable; + profiles stay independent islands afterwards.""" config_path = profile_dir / "config.yaml" if config_path.exists(): return @@ -691,19 +555,16 @@ def _seed_model_config(profile_dir: Path) -> None: encoding="utf-8", ) except Exception: - # Creation must not fail over this; `hermes model` still sets it later. - pass + pass # creation must not fail over this; `hermes model` sets it later def _check_gateway_running(profile_dir: Path) -> bool: - """Check if a gateway is running for a given profile directory. + """Gateway liveness for a profile dir, never mutating HERMES_HOME. - Primary signal is ``gateway.pid`` verified against the runtime lock, which fails closed when - the lock isn't held by *this* reader (dashboard as a separate s6 service, launch-service - gateways with no live PID file). Then fall back to validating the PID in the profile's - ``gateway_state.json`` against the process table, matching ``/api/status``. Never mutates - ``HERMES_HOME``. - """ + Primary signal is ``gateway.pid`` verified against the runtime lock (fails closed when + the lock isn't held by *this* reader: dashboard as a separate s6 service, launch-service + gateways with no live PID file); fallback validates the PID in ``gateway_state.json`` + against the process table, matching ``/api/status``.""" try: from gateway.status import ( get_running_pid, @@ -722,12 +583,8 @@ def _check_gateway_running(profile_dir: Path) -> bool: def _served_by_running_multiplexer(profile_name: str) -> bool: - """True when the live default gateway multiplexes ``profile_name``. - - A served named profile has no gateway.pid of its own, so ``_check_gateway_running`` alone - reports it stopped while the default multiplexer is really its inbound process. Shared by the - named-profile start guard and cron liveness. - """ + """True when the live default gateway multiplexes ``profile_name`` (such a profile has no + gateway.pid of its own, so ``_check_gateway_running`` alone reports it stopped).""" try: from hermes_cli.gateway import named_profile_served_by_running_multiplexer @@ -736,25 +593,18 @@ def _served_by_running_multiplexer(profile_name: str) -> bool: return False -# In-process cache for skill counts. Walking ``skills_dir.rglob("SKILL.md")`` -# recurses the entire skill tree (each skill carries references/scripts/assets -# sub-trees); the default profile alone has ~270 skills, and ``list_profiles`` -# calls this for EVERY profile (16+), so an uncached scan costs ~6s — long -# enough that the desktop's per-request backend calls time out and the sidebar -# renders "全部智能体 0". We cache the count keyed by the skills dir, invalidated -# when the dir tree's signature (skills_dir + immediate category dirs mtimes) -# changes (catches skill add/remove) or after a short TTL (catches deep edits). +# In-process skill-count cache. ``rglob("SKILL.md")`` walks every skill's sub-trees; the +# default profile alone has ~270 skills and ``list_profiles`` counts EVERY profile (16+), so +# an uncached scan costs ~6s — enough for the desktop's per-request calls to time out and +# the sidebar to render "全部智能体 0". Keyed by skills dir, invalidated when the tree +# signature changes (skill add/remove) or after a short TTL (deep edits). _SKILL_COUNT_CACHE: dict[str, tuple[float, float, int]] = {} _SKILL_COUNT_TTL_SECONDS = 30.0 def _skills_dir_signature(skills_dir: Path) -> float: - """Cheap change-signature for a skills tree. - - Max mtime of ``skills_dir`` and its immediate children: adding/removing a category bumps the - root, adding/removing a skill bumps its category dir. One ``scandir`` keeps this - O(#categories), not O(#files). - """ + """Max mtime of ``skills_dir`` and its immediate children (adding/removing a category + bumps the root, a skill bumps its category). One scandir: O(#categories), not O(#files).""" try: sig = skills_dir.stat().st_mtime except OSError: @@ -791,11 +641,7 @@ def _count_skills(profile_dir: Path) -> int: ): return cached[2] - count = 0 - for md in skills_dir.rglob("SKILL.md"): - if is_excluded_skill_path(md): - continue - count += 1 + count = sum(1 for md in skills_dir.rglob("SKILL.md") if not is_excluded_skill_path(md)) _SKILL_COUNT_CACHE[key] = (signature, now, count) return count @@ -803,24 +649,15 @@ def _count_skills(profile_dir: Path) -> int: # --------------------------------------------------------------------------- # profile.yaml — per-profile metadata (description, role, etc.) # --------------------------------------------------------------------------- -# -# We keep this file deliberately tiny and separate from the profile's -# ``config.yaml``. ``config.yaml`` is the user-facing Hermes config -# (~5000 lines of defaults); ``profile.yaml`` is metadata ABOUT the -# profile itself (its role, who described it). Mixing them makes both -# harder to read. -# -# Missing file -> empty defaults; never an error. The kanban decomposer -# tolerates empty descriptions and just falls back to the profile name. +# Deliberately tiny and separate from ``config.yaml`` (user-facing Hermes config, ~5000 +# lines of defaults): this is metadata ABOUT the profile. Missing file -> empty defaults, +# never an error; the kanban decomposer falls back to the profile name. def read_profile_meta(profile_dir: Path) -> dict: - """Read ``/profile.yaml`` and return a dict. - - Returns ``{"description": "", "description_auto": False, "display_name": ""}`` when the file is - missing or unreadable. Never raises — a corrupt profile.yaml on an unrelated profile must not - break ``hermes profile list``. - """ + """Read ``profile.yaml`` -> ``{description, description_auto, display_name}`` (empty + defaults when missing/unreadable). Never raises — a corrupt file on one profile must not + break ``hermes profile list``.""" data = _load_yaml_dict(profile_dir / "profile.yaml") if data is None: return {"description": "", "description_auto": False, "display_name": ""} @@ -838,11 +675,8 @@ def write_profile_meta( description_auto: Optional[bool] = None, display_name: Optional[str] = None, ) -> None: - """Update ``/profile.yaml`` in place. - - Only the explicitly passed fields are overwritten; unspecified fields preserve existing values. - Creates the file if missing. Profile directory itself must exist. - """ + """Update ``profile.yaml`` in place: only passed fields are overwritten; the file is + created if missing. The profile directory itself must exist.""" if not profile_dir.is_dir(): raise FileNotFoundError(f"profile directory does not exist: {profile_dir}") path = profile_dir / "profile.yaml" @@ -857,33 +691,24 @@ def write_profile_meta( existing["display_name"] = display_name.strip() else: existing.pop("display_name", None) - # Atomic write: bare open("w") truncates before the dump, and the read - # path above swallows parse errors as {}, so a crashed write would - # silently drop unspecified fields on the next call (#51356, #16743). + # Atomic write: bare open("w") truncates before the dump, and the read path swallows + # parse errors as {}, so a crashed write would silently drop unspecified fields. from utils import atomic_yaml_write atomic_yaml_write(path, existing, sort_keys=False) def format_profile_label(name: str, display_name: Optional[str]) -> str: - """Render a profile for display: ``display_name (canonical_id)``. - - Falls back to the bare canonical id when no display name is set (or it equals the id) — byte- - for-byte the pre-feature rendering. Display names are presentation-only free text (Unicode - fine); they are never a directory name, wrapper filename, or argv token. - """ + """``display_name (canonical_id)``, or the bare id when no display name is set (or it + equals the id) — byte-for-byte the pre-feature rendering.""" dn = (display_name or "").strip() return f"{dn} ({name})" if dn and dn != name else name def set_profile_display_name(profile_name: str, display_name: str) -> str: - """Set (or clear, with ``""``) a profile's user-facing display name. - - Presentation-only: the canonical profile id is untouched. Returns the stored value. Raises - ``ValueError`` for names over 64 chars. - """ - canon = normalize_profile_name(profile_name) - validate_profile_name(canon) + """Set (or clear, with ``""``) a presentation-only display name. Returns the stored value; + raises ``ValueError`` over 64 chars.""" + canon = _canon_valid(profile_name) profile_dir = get_profile_dir(canon) if not profile_dir.is_dir(): raise FileNotFoundError(f"Profile '{canon}' does not exist.") @@ -938,9 +763,7 @@ def list_profiles() -> List[ProfileInfo]: named = _iter_named_profile_dirs() if named: - # Build the {profile -> alias} map ONCE instead of per profile - # (re-scanning the wrapper dir N times was the dominant cost here). - alias_map = build_alias_map() + alias_map = build_alias_map() # ONCE, not per profile (was the dominant cost) for entry in named: alias_name = alias_map.get(normalize_profile_name(entry.name)) profiles.append(_profile_info(entry.name, entry, is_default=False, alias_name=alias_name)) @@ -951,15 +774,13 @@ def profiles_to_serve( multiplex: bool, profile_allowlist: Optional[List[str]] = None, ) -> List[Tuple[str, Path]]: - """Return the ``(profile_name, hermes_home)`` pairs a gateway should serve. + """``(profile_name, hermes_home)`` pairs a gateway should serve — the single chokepoint + for "which profiles does the inbound gateway handle". - This is the single chokepoint for "which profiles does the inbound gateway handle" so later - multiplexing phases never re-derive the set. - - - ``multiplex=False`` (default): returns exactly one entry for the *active* profile — byte-for- - byte the single-profile behavior the gateway has always had. The name is ``"default"`` for the - default profile or the active named profile's id. - """ + ``multiplex=False``: exactly one entry for the *active* profile (byte-for-byte the + historical single-profile behavior; name is ``"default"`` or the named profile's id). + ``multiplex=True``: default plus every live named profile, optionally filtered by + *profile_allowlist* (invalid entries skipped, missing ones warned once).""" active = get_active_profile_name() or "default" if not multiplex: return [(active, get_profile_dir(active))] @@ -972,8 +793,7 @@ def profiles_to_serve( if not isinstance(entry, str): continue try: - name = normalize_profile_name(entry) - validate_profile_name(name) + name = _canon_valid(entry) except ValueError: continue if name != "default": @@ -1001,8 +821,7 @@ def _resolve_clone_source(clone_from: Optional[str]) -> Path: from hermes_constants import get_hermes_home source_dir = get_hermes_home() else: - clone_from = normalize_profile_name(clone_from) - validate_profile_name(clone_from) + clone_from = _canon_valid(clone_from) source_dir = get_profile_dir(clone_from) if not source_dir.is_dir(): raise FileNotFoundError( @@ -1024,11 +843,8 @@ def _seed_file_if_missing(path: Path, text: str, mode: Optional[int] = None) -> def _clone_file(source_dir: Path, profile_dir: Path, relpath: str) -> None: - """Copy one profile-relative file if it exists; ``.env`` is tightened to owner-only. - - ``shutil.copy2`` preserves source mode bits, so a loose source ``.env`` (host umask 0o022 - leaving 0o644) would otherwise hand the clone weak perms. - """ + """Copy one profile-relative file if it exists. ``.env`` is tightened to owner-only: + ``copy2`` preserves source mode bits, so a loose source (umask 0o644) would leak.""" src = source_dir / relpath if not src.exists(): return @@ -1042,6 +858,50 @@ def _clone_file(source_dir: Path, profile_dir: Path, relpath: str) -> None: pass +def _clone_all_into(source_dir: Path, profile_dir: Path, canon: str) -> None: + """--clone-all: full copytree minus infrastructure/history, then strip runtime files + and cloned single-use OAuth grants.""" + shutil.copytree( + source_dir, + profile_dir, + symlinks=True, + ignore=_clone_all_copytree_ignore(source_dir), + ) + for stale in _CLONE_ALL_STRIP: + (profile_dir / stale).unlink(missing_ok=True) + # auth.json / .anthropic_oauth.json copied verbatim fork single-use OAuth grants + # (Anthropic / Codex / xAI): one credential with two owners, and the first profile to + # refresh revokes the pair for every sibling. Drop the copies; the clone reads the root + # grant through the credential-pool fallback. + from hermes_cli.auth import strip_cloned_single_use_oauth_grants + stripped = strip_cloned_single_use_oauth_grants(profile_dir) + if any(stripped.values()): + logger.info( + "profile %s: dropped cloned single-use OAuth grants %s " + "(inherits the root grant instead)", canon, stripped, + ) + + +def _bootstrap_profile_dir(profile_dir: Path, source_dir: Optional[Path]) -> None: + """Fresh layout: bootstrap dirs, then either seed a model block (no source) or clone + config files, installed skills (the dashboard's "clone from default" must keep bundled + AND user-installed skills), and memory/identity files from *source_dir*.""" + profile_dir.mkdir(parents=True, exist_ok=True) + for subdir in _PROFILE_DIRS: + (profile_dir / subdir).mkdir(parents=True, exist_ok=True) + + if source_dir is None: + _seed_model_config(profile_dir) + return + for relpath in _CLONE_CONFIG_FILES: + _clone_file(source_dir, profile_dir, relpath) + source_skills = source_dir / "skills" + if source_skills.is_dir(): + shutil.copytree(source_skills, profile_dir / "skills", symlinks=True, dirs_exist_ok=True) + for relpath in _CLONE_SUBDIR_FILES: + _clone_file(source_dir, profile_dir, relpath) + + def create_profile( name: str, clone_from: Optional[str] = None, @@ -1056,15 +916,13 @@ def create_profile( ``clone_from`` defaults to the active profile when cloning. ``clone_all`` copies all state; ``clone_config`` copies config.yaml/.env/SOUL.md, installed skills, and identity files. ``no_skills`` creates an empty profile and writes a marker so ``hermes update`` skips - re-seeding its skills; it is mutually exclusive with the clone options, which copy skills. - """ + re-seeding its skills; it is mutually exclusive with the clone options, which copy skills.""" if no_skills and (clone_from is not None or clone_config or clone_all): raise ValueError( "--no-skills is mutually exclusive with --clone / --clone-from / --clone-all " "(cloning explicitly copies skills from the source profile)." ) - canon = normalize_profile_name(name) - validate_profile_name(canon) + canon = _canon_valid(name) if canon == "default": raise ValueError( @@ -1073,8 +931,8 @@ def create_profile( profile_dir = get_profile_dir(canon) if profile_dir.exists() and named_profile_is_deleted(profile_dir): - # Empty shells left by post-delete mkdir may be replaced. Identity - # files mean the leftover is not a shell — fail closed, no rmtree. + # Empty shells left by post-delete mkdir may be replaced. Identity files mean the + # leftover is not a shell — fail closed, no rmtree. if (profile_dir / "config.yaml").exists() or (profile_dir / ".env").exists(): raise FileExistsError(f"Profile '{canon}' already exists at {profile_dir}") shutil.rmtree(profile_dir) @@ -1087,68 +945,24 @@ def create_profile( source_dir = _resolve_clone_source(clone_from) if clone_all and source_dir: - # Full copy of source profile (exclude sibling ~/.hermes/profiles/) - shutil.copytree( - source_dir, - profile_dir, - symlinks=True, - ignore=_clone_all_copytree_ignore(source_dir), - ) - # Strip runtime files - for stale in _CLONE_ALL_STRIP: - (profile_dir / stale).unlink(missing_ok=True) - # A clone-all copies auth.json and .anthropic_oauth.json verbatim. - # Single-use OAuth grants (Anthropic / Codex / xAI) forked that way - # are one credential with two owners: the first profile to refresh - # revokes the pair for every sibling (#100339). Drop the copies; the - # clone reads the root grant through the credential-pool fallback. - from hermes_cli.auth import strip_cloned_single_use_oauth_grants - stripped = strip_cloned_single_use_oauth_grants(profile_dir) - if any(stripped.values()): - logger.info( - "profile %s: dropped cloned single-use OAuth grants %s " - "(inherits the root grant instead)", canon, stripped, - ) + _clone_all_into(source_dir, profile_dir, canon) else: - # Bootstrap directory structure - profile_dir.mkdir(parents=True, exist_ok=True) - for subdir in _PROFILE_DIRS: - (profile_dir / subdir).mkdir(parents=True, exist_ok=True) + _bootstrap_profile_dir(profile_dir, source_dir) - if source_dir is None: - _seed_model_config(profile_dir) - - # Clone config files, then installed skills (the dashboard's "clone from - # default" flow must preserve bundled AND user-installed skills), then - # memory/identity files from the source profile. - if source_dir is not None: - for relpath in _CLONE_CONFIG_FILES: - _clone_file(source_dir, profile_dir, relpath) - source_skills = source_dir / "skills" - if source_skills.is_dir(): - shutil.copytree(source_skills, profile_dir / "skills", symlinks=True, dirs_exist_ok=True) - for relpath in _CLONE_SUBDIR_FILES: - _clone_file(source_dir, profile_dir, relpath) - - # Seed an empty .env so the profile has its own credentials file from - # day one. Without it, profile-scoped env writes (dashboard Channels / - # Keys pages, `hermes -p auth add`) had no file until first - # write, and the profile silently inherited API keys from the shell - # environment — users reasonably read that as "the new profile reads - # the root .env". Skipped when --clone/--clone-all already copied one - # (save_env_value creates the file on demand if this fails). + # Seed an empty .env so the profile owns a credentials file from day one. Without it, + # profile-scoped env writes (dashboard Channels/Keys pages, `hermes -p auth add`) + # had no file until first write and the profile silently inherited shell API keys — + # read by users as "the new profile reads the root .env". Skipped when a clone copied one. _seed_file_if_missing(profile_dir / ".env", _PLACEHOLDER_ENV, 0o600) - # Seed a default SOUL.md so the user has a file to customize immediately. - # Skipped when the profile already has one (from --clone / --clone-all). + # Default SOUL.md to customize immediately (skipped when a clone already provided one). try: from hermes_cli.default_soul import DEFAULT_SOUL_MD _seed_file_if_missing(profile_dir / "SOUL.md", DEFAULT_SOUL_MD) except Exception: pass # best-effort — don't fail profile creation over this - # Write the opt-out marker so seed_profile_skills() and `hermes update`'s - # all-profile sync loop both skip this profile for bundled-skill seeding + # Opt-out marker read by seed_profile_skills() and `hermes update`'s all-profile sync # (the feature still works via the empty skills/ dir if this fails). if no_skills: _seed_file_if_missing( @@ -1158,17 +972,13 @@ def create_profile( "Delete this file to re-enable sync on the next `hermes update`.\n", ) - # Cloned configs can be older than the running Hermes (or predate schema - # tracking entirely). Migrate config-only clones immediately so - # desktop/status surfaces don't warn that a just-created profile is - # v0/outdated. Leave --clone-all snapshots byte-for-byte apart from the + # Migrate config-only clones now so desktop/status don't warn that a just-created + # profile is v0/outdated; --clone-all snapshots stay byte-for-byte apart from the # explicit runtime/history stripping above. if not clone_all: _migrate_profile_config_if_outdated(profile_dir) - # Persist description if the caller provided one. Done last so a - # partial-create failure doesn't strand a description file in an - # incomplete profile. + # Description last, so a partial-create failure doesn't strand a description file. if description and description.strip(): try: write_profile_meta( @@ -1179,27 +989,18 @@ def create_profile( except Exception: pass # non-fatal — user can describe later with `hermes profile describe` - # Phase 4: when running inside a container under s6, register the - # new profile's gateway as a runtime s6 service so - # `hermes -p gateway start` can supervise it via - # `s6-svc -u` instead of spawning a bare process. On host (systemd - # / launchd / windows) this is a no-op — the existing per-profile - # unit-generation paths handle gateway lifecycle. + # Inside a container under s6, register the gateway as a runtime s6 service so + # `hermes -p gateway start` supervises via `s6-svc -u` instead of a bare + # process. No-op on host (systemd/launchd/windows unit generation handles lifecycle). _maybe_register_gateway_service(canon) return profile_dir def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict]: - """Seed bundled skills into a profile via subprocess. - - Uses subprocess because sync_skills() caches HERMES_HOME at module level. Returns the sync - result dict, or None on failure. - - Profiles that opted out of bundled skills (via ``hermes profile create --no-skills`` — which - writes ``.no-bundled-skills`` to the profile root) still run the sync: ``sync_skills()`` detects - the marker itself and seeds only the essential skills (e.g. - """ + """Seed bundled skills into a profile via subprocess (sync_skills() caches HERMES_HOME at + module level). Returns the sync result dict, or None on failure. ``--no-skills`` profiles + still run the sync: ``sync_skills()`` detects the marker and seeds only essentials.""" project_root = Path(__file__).parent.parent.resolve() try: result = subprocess.run( @@ -1228,11 +1029,8 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict def backfill_profile_envs(quiet: bool = False) -> List[str]: - """Give every named profile that predates per-profile ``.env`` files one. - - Falls back to the placeholder header when the default install has no ``.env`` itself. Never - overwrites an existing profile ``.env``. - """ + """Give every named profile predating per-profile ``.env`` one (copy of the default's, or + the placeholder header). Never overwrites an existing profile ``.env``.""" backfilled: List[str] = [] default_env = _get_default_hermes_home() / ".env" @@ -1256,24 +1054,20 @@ def backfill_profile_envs(quiet: bool = False) -> List[str]: _BACKEND_TOKENS = frozenset({"serve", "dashboard", "gateway"}) _HERMES_ARGV_MARKERS = ("hermes_cli.main", "hermes-gateway", "tui_gateway") -# Matches python / python3 / python3.12 / pythonw(.exe) — the interpreter -# basenames a `#!/…/python3` console-script shim gets exec'd through when -# something (e.g. Electron's `findOnPath('hermes')` resolution) spawns the -# shim by handing the interpreter its path explicitly. In that shape the +# python / python3 / python3.12 / pythonw(.exe): the interpreter basenames a +# `#!/…/python3` console-script shim is exec'd through when something (e.g. Electron's +# `findOnPath('hermes')`) spawns the shim by handing the interpreter its path — then the # OS-reported argv[0] is the interpreter, not "hermes". _PYTHON_INTERPRETER_RE = re.compile(r"^python[\d.]*w?(\.exe)?$") -# Console-script entry points this project ships (pyproject.toml -# [project.scripts]). argv[1] is validated against these exact names rather -# than a loose ``startswith("hermes")``: when argv[0] is a bare interpreter, -# argv[1] can be ANY user script ("hermes-notes.py") and a prefix match would -# make it killable by profile delete. +# Console-script entry points this project ships (pyproject.toml [project.scripts]). +# argv[1] is matched against exact names, not ``startswith("hermes")``: with a bare +# interpreter argv[0], argv[1] can be ANY user script ("hermes-notes.py"). _HERMES_CONSOLE_SCRIPT_NAMES = frozenset({"hermes", "hermes-agent", "hermes-acp"}) def _is_hermes_argv(argv: list) -> bool: - """True when *argv* is a Hermes process: an entrypoint marker in argv, an executable named - ``hermes*``, or a python interpreter directly exec'ing a known ``hermes`` console-script shim. - """ + """True for a Hermes process: entrypoint marker in argv, executable named ``hermes*``, + or a python interpreter directly exec'ing a known ``hermes`` console-script shim.""" joined = " ".join(argv) exe_name = os.path.basename(argv[0]).lower() if any(marker in joined for marker in _HERMES_ARGV_MARKERS) or exe_name.startswith("hermes"): @@ -1294,14 +1088,10 @@ def _argv_profile_selectors(argv: list): def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]: - """PIDs of running Hermes *backends* bound to this profile. - - The ``gateway.pid`` file only tracks the messaging gateway. - - Best-effort and tightly scoped: current-user processes only, backend subcommands only (never an - interactive ``chat``/``tui``), and never this process or its ancestors. Returns an empty list if - ``psutil`` can't inspect anything. - """ + """PIDs of running Hermes *backends* bound to this profile (``gateway.pid`` only tracks + the messaging gateway). Tightly scoped: current-user processes, backend subcommands only + (never an interactive ``chat``/``tui``), never this process or its ancestors. Empty when + ``psutil`` can't inspect anything.""" try: import psutil # type: ignore except Exception: @@ -1312,8 +1102,8 @@ def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]: except OSError: resolved_dir = profile_dir - # Never terminate ourselves or a parent (e.g. `hermes -p profile - # delete` runs under the very profile it's deleting). + # Never terminate ourselves or a parent (`hermes -p profile delete` runs under + # the very profile it's deleting). skip: set[int] = {os.getpid()} try: parent = psutil.Process(os.getpid()).parent() @@ -1342,45 +1132,33 @@ def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]: argv = info.get("cmdline") or [] if not argv or not _is_hermes_argv(argv): continue - - # Restrict to backend subcommands so we never kill an interactive - # session the user is deliberately running. if not ({tok.lower() for tok in argv} & _BACKEND_TOKENS): continue - # Bound to THIS profile — by selector flag in argv... + # Bound to THIS profile by selector flag, or by HERMES_HOME pointing at its dir. bound = any( normalize_profile_name(sel) == canon for sel in _argv_profile_selectors(argv) ) - - # ...or by HERMES_HOME env pointing at this profile dir. if not bound: try: env_home = (proc.environ() or {}).get("HERMES_HOME", "") if env_home and Path(env_home).resolve() == resolved_dir: bound = True except Exception: - # environ() can raise AccessDenied even same-user on some - # platforms; fall back to the argv signal only. - pass + pass # environ() can raise AccessDenied even same-user; argv signal only if bound: pids.append(pid) - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - continue except Exception: - continue + continue # NoSuchProcess / AccessDenied / ZombieProcess and anything else return pids def _wait_then_force_kill(pids: List[int], start_times: dict, *, wait: float = 10.0) -> bool: - """After a graceful ``terminate_pid``, wait up to *wait* seconds (0.5s polls) for *pids* to - exit, then force-kill stragglers. Returns True when every pid exited gracefully. - - ``start_times`` pins each force kill to the same process incarnation (PID reuse guard); - force-kill errors are swallowed. - """ + """After a graceful ``terminate_pid``, wait up to *wait* seconds (0.5s polls) for *pids* + to exit, then force-kill stragglers. True when every pid exited gracefully. + ``start_times`` pins each force kill to the same process incarnation (PID reuse guard).""" from gateway.status import _pid_exists, get_process_start_time, terminate_pid for _ in range(int(wait / 0.5)): @@ -1401,12 +1179,9 @@ def _wait_then_force_kill(pids: List[int], start_times: dict, *, wait: float = 1 def _stop_profile_backends(canon: str, profile_dir: Path) -> None: - """Terminate any Desktop-spawned / stray backends bound to this profile. - - Complements ``_stop_gateway_process`` (which only knows ``gateway.pid``): without this, a live - ``serve``/``dashboard`` backend keeps creating files under the profile dir while ``rmtree`` - walks it, so the final ``rmdir`` fails with ``ENOTEMPTY`` and the delete doesn't converge. - """ + """Terminate Desktop-spawned / stray backends bound to this profile. Complements + ``_stop_gateway_process`` (which only knows ``gateway.pid``): a live ``serve``/``dashboard`` + keeps creating files while ``rmtree`` walks, so the final rmdir fails ENOTEMPTY.""" pids = _profile_bound_backend_pids(canon, profile_dir) if not pids: return @@ -1426,21 +1201,14 @@ def _stop_profile_backends(canon: str, profile_dir: Path) -> None: def _rmtree_make_writable(func, path, exc): - """onexc/onerror handler: add +w on PermissionError so rmtree can proceed. - - Handles two cases on NixOS (and other systems with read-only copies from immutable - stores): 1. The path itself isn't writable (e.g. a file with mode 0444) 2. The *parent* - directory isn't writable (e.g. mode 0555) - """ - # Normalise the two callback signatures: - # onexc(func, path, exc_instance) — 3.12+ - # onerror(func, path, exc_info_tuple) — 3.11 + """onexc/onerror handler: add +w on PermissionError so rmtree can proceed. Covers NixOS- + style read-only copies where the path itself (0444) or its parent (0555) isn't writable.""" + # onexc(func, path, exc_instance) on 3.12+; onerror(func, path, exc_info_tuple) on 3.11. if isinstance(exc, tuple): - exc = exc[1] # exc_info → actual exception object + exc = exc[1] if not isinstance(exc, PermissionError): raise - # Make the path and its parent writable (parent needed for unlink/rmdir). - for target in (path, os.path.dirname(path)): + for target in (path, os.path.dirname(path)): # parent needed for unlink/rmdir if target: try: os.chmod(target, os.stat(target).st_mode | stat.S_IWUSR) @@ -1450,21 +1218,16 @@ def _rmtree_make_writable(func, path, exc): def _rmtree_with_retry(profile_dir: Path, onexc_handler) -> None: - """``shutil.rmtree`` with a short retry loop for transient races. - - Even after stopping the gateway and profile backends, a just-terminated process can leave in- - flight writes (SQLite ``-wal``/``-shm`` checkpoints, sandbox temp files) that land after - ``rmtree`` has walked past a directory, surfacing as ``ENOTEMPTY`` (POSIX) or a transient - ``PermissionError`` (Windows file lock still releasing). - """ + """``shutil.rmtree`` with a short retry loop: a just-terminated process can leave in-flight + writes (SQLite -wal/-shm checkpoints, sandbox temp files) landing after rmtree walked + past a directory — ENOTEMPTY on POSIX, transient PermissionError on Windows.""" attempts = 3 last_exc: OSError | None = None for attempt in range(attempts): try: - # ``onexc`` was added in 3.12; fall back to ``onerror`` on 3.11. try: shutil.rmtree(profile_dir, onexc=onexc_handler) - except TypeError: + except TypeError: # ``onexc`` is 3.12+; 3.11 has ``onerror`` shutil.rmtree(profile_dir, onerror=onexc_handler) return except OSError as e: @@ -1503,12 +1266,9 @@ def _print_delete_summary(canon: str, profile_dir: Path, gw_running: bool, wrapp def delete_profile(name: str, yes: bool = False) -> Path: - """Delete a profile, its wrapper script, and its gateway service. - - Stops the gateway if running. Disables systemd/launchd service first to prevent auto-restart. - """ - canon = normalize_profile_name(name) - validate_profile_name(canon) + """Delete a profile, its wrapper script, and its gateway service (service disabled first + to prevent auto-restart, gateway stopped if running).""" + canon = _canon_valid(name) if canon == "default": raise ValueError( @@ -1525,7 +1285,6 @@ def delete_profile(name: str, yes: bool = False) -> Path: has_wrapper = wrapper_path.exists() _print_delete_summary(canon, profile_dir, gw_running, wrapper_path if has_wrapper else None) - # Confirmation if not yes: print() try: @@ -1537,35 +1296,24 @@ def delete_profile(name: str, yes: bool = False) -> Path: print("Cancelled.") return profile_dir - # 1. Disable service (prevents auto-restart) + # 1. Disable service (prevents auto-restart); drop the s6 slot on container (host no-op). _cleanup_gateway_service(canon, profile_dir) - # 1b. Phase 4: unregister the s6 service slot (container path). - # On host this is a no-op; on container it removes - # /run/service/gateway-/ so s6-supervise drops it. _maybe_unregister_gateway_service(canon) - # 2. Stop running gateway + # 2. Stop the gateway, then other backends bound to this profile (Desktop-spawned + # serve/dashboard the pid file never names): they hold the SQLite connection open and + # keep writing, which made rmtree fail ENOTEMPTY and resurrected the deleted tree. if gw_running: _stop_gateway_process(profile_dir) - - # 2b. Stop any other backends bound to this profile (Desktop-spawned - # serve/dashboard processes the gateway.pid file never names). They hold - # the profile's SQLite connection open and keep writing files, which makes - # the rmtree below fail with ENOTEMPTY and — before the ensure_hermes_home - # guard — resurrected the deleted tree. _stop_profile_backends(canon, profile_dir) - # Tombstone before rmtree so a stale serve/logging mkdir cannot relist - # this name as a live profile. + # Tombstone before rmtree so a stale serve/logging mkdir cannot relist this name live. mark_named_profile_deleted(profile_dir) - # 2c. Release this process's holographic memory-store connections into - # the profile. The Desktop's *main* serve process opens memory_store.db - # for every known profile and is deliberately not stopped above, so on - # Windows its open handles make the rmtree below fail with WinError 32 - # (#88347). When this delete runs inside serve (the DELETE - # /api/profiles/ route) the handles live in this process and are - # closed here; from the CLI this finds nothing and is a no-op. + # Release this process's holographic memory-store connections into the profile. The + # Desktop's main serve process opens memory_store.db for every profile and is + # deliberately not stopped above; on Windows its handles fail rmtree with WinError 32. + # Inside serve (DELETE /api/profiles/) the handles live here; from the CLI no-op. try: from plugins.memory.holographic.store import MemoryStore as _MemoryStore @@ -1599,11 +1347,8 @@ def delete_profile(name: str, yes: bool = False) -> Path: def _s6_runtime_manager(): - """Return the s6 service manager when running inside the container, else None. - - Silent on host: a failing/absent detector must never print a confusing s6 warning to users - who have never touched the container. - """ + """The s6 service manager inside the container, else None. Silent on host: a failing/ + absent detector must never print a confusing s6 warning to non-container users.""" try: from hermes_cli.service_manager import detect_service_manager, get_service_manager if detect_service_manager() != "s6": @@ -1615,33 +1360,21 @@ def _s6_runtime_manager(): def _maybe_register_gateway_service(profile_name: str) -> None: - """Register a profile's gateway with s6 inside the container. - - Best-effort: any error (no backend detected, s6 not yet ready, etc.) is logged and swallowed so - profile creation doesn't fail because the s6 supervision tree is in a weird state. The user can - re-register manually later via the gateway start command, which goes through the same dispatch - path. - """ + """Register a profile's gateway with s6 inside the container. Best-effort: profile + creation must not fail over a supervision-tree hiccup; `gateway start` re-registers.""" mgr = _s6_runtime_manager() if mgr is None: return try: mgr.register_profile_gateway(profile_name, start_now=False) except ValueError: - # Already registered (e.g. the container-boot reconciler ran - # first and brought up a stale slot). That's fine. - pass + pass # already registered (e.g. the container-boot reconciler brought up a stale slot) except Exception as exc: - # Don't fail profile create over a supervision-tree hiccup. print(f"⚠ Could not register s6 gateway service: {exc}") def _maybe_unregister_gateway_service(profile_name: str) -> None: - """Tear down a profile's s6 gateway service inside the container. - - No-op on host (same short-circuit as ``_maybe_register_gateway_service``); idempotent since - absent services are silently skipped. - """ + """Tear down a profile's s6 gateway service inside the container; host no-op, idempotent.""" mgr = _s6_runtime_manager() if mgr is None: return @@ -1655,8 +1388,7 @@ def _cleanup_gateway_service(name: str, profile_dir: Path) -> None: """Disable and remove systemd/launchd service for a profile.""" import platform as _platform - # Derive service name for this profile - # Temporarily set HERMES_HOME so _profile_suffix resolves correctly + # HERMES_HOME is set temporarily so _profile_suffix resolves the service name. old_home = os.environ.get("HERMES_HOME") try: os.environ["HERMES_HOME"] = str(profile_dir) @@ -1700,10 +1432,9 @@ def _stop_gateway_process(profile_dir: Path) -> None: raw = pid_file.read_text(encoding="utf-8").strip() data = json.loads(raw) if raw.startswith("{") else {"pid": int(raw)} pid = int(data["pid"]) - # Cross-profile kill refusal (#89315): the record's hermes_home stamp - # names the gateway's TRUE owner. A contaminated/poisoned gateway.pid - # inside this profile dir can point at another profile's live gateway - # — killing it starts the mutual SIGTERM restart loop from the issue. + # Cross-profile kill refusal: the record's hermes_home stamp names the gateway's TRUE + # owner. A poisoned gateway.pid in this dir can point at another profile's live + # gateway — killing it starts a mutual SIGTERM restart loop. from gateway.status import ( get_process_start_time, recorded_gateway_home_conflicts, @@ -1717,11 +1448,8 @@ def _stop_gateway_process(profile_dir: Path) -> None: "(stale/poisoned PID record, #89315)." ) return - # Route through terminate_pid so Windows uses the appropriate - # primitive (taskkill / TerminateProcess) — raw os.kill with - # _signal.SIGKILL raises AttributeError at import time on Windows, - # and raw os.kill with SIGTERM doesn't cascade to child processes - # the same way taskkill /T does. + # terminate_pid picks the Windows primitive (taskkill /T cascades to children; raw + # os.kill with SIGKILL fails at import on Windows). expected_start_time = data.get("start_time") if expected_start_time is None: expected_start_time = get_process_start_time(pid) @@ -1750,23 +1478,17 @@ def get_active_profile() -> str: def set_active_profile(name: str) -> None: - """Set the sticky active profile.""" - canon = normalize_profile_name(name) - validate_profile_name(canon) + """Set the sticky active profile (``default`` = remove the file).""" + canon = _canon_valid(name) if canon != "default" and not profile_exists(canon): - raise FileNotFoundError( - f"Profile '{canon}' does not exist. " - f"Create it with: hermes profile create {canon}" - ) + raise _missing_profile_error(canon) path = _get_active_profile_path() path.parent.mkdir(parents=True, exist_ok=True) if canon == "default": - # Remove the file to indicate default path.unlink(missing_ok=True) else: - # Atomic write - tmp = path.with_suffix(".tmp") + tmp = path.with_suffix(".tmp") # atomic write tmp.write_text(canon + "\n", encoding="utf-8") tmp.replace(path) @@ -1782,23 +1504,17 @@ def _retarget_active_profile(old: str, new: str, message: str) -> None: def get_active_profile_name() -> str: - """Infer the current profile name from HERMES_HOME. - - ``"default"`` when unset or ``~/.hermes``; the profile name when under - ``~/.hermes/profiles/``; ``"custom"`` for any other path. - """ + """Profile name inferred from HERMES_HOME: ``"default"`` when unset or ``~/.hermes``, the + name under ``~/.hermes/profiles/``, ``"custom"`` for any other path.""" from hermes_constants import get_hermes_home - hermes_home = get_hermes_home() - resolved = hermes_home.resolve() + resolved = get_hermes_home().resolve() - default_resolved = _get_default_hermes_home().resolve() - if resolved == default_resolved: + if resolved == _get_default_hermes_home().resolve(): return "default" profiles_root = _get_profiles_root().resolve() try: - rel = resolved.relative_to(profiles_root) - parts = rel.parts + parts = resolved.relative_to(profiles_root).parts if len(parts) == 1 and _PROFILE_ID_RE.match(parts[0]): return parts[0] except ValueError: @@ -1812,17 +1528,12 @@ def get_active_profile_name() -> str: # --------------------------------------------------------------------------- def _inside_git_checkout(path: Path) -> bool: - """Return True when *path* lies inside a Git checkout. - - Walks the path's OWN resolved ancestry for a ``.git`` marker (dir or worktree file), not - ``Path.cwd()``, so the check holds when HERMES_HOME sits in a checkout but the process runs - elsewhere (cron, service manager). Resolution failure reports True so callers fall through - to a provably safe candidate. - """ + """True when *path* lies inside a Git checkout. Walks the path's OWN resolved ancestry + (not cwd) so the check holds when HERMES_HOME sits in a checkout but the process runs + elsewhere (cron, service manager). Resolution failure reports True (fail closed).""" try: resolved = path.resolve() - except (OSError, RuntimeError): - # RuntimeError: symlink loops on Python <= 3.12; fail closed either way. + except (OSError, RuntimeError): # RuntimeError: symlink loops on Python <= 3.12 return True return any( (candidate / ".git").exists() for candidate in (resolved, *resolved.parents) @@ -1837,12 +1548,10 @@ def _profile_export_directory() -> Path: if not _inside_git_checkout(export_dir): return export_dir - # A custom deployment may point HERMES_HOME at its source checkout. Do - # not put the automatic archive under that tree; use a sibling store and - # fall back to the OS temp directory only for the unusual case where the - # user's home itself is a checkout (e.g. a dotfiles repo). - # Per-uid temp name: a fixed /tmp/hermes-profile-exports is a predictable - # shared path another local user could pre-create (or symlink) before us. + # A custom deployment may point HERMES_HOME at its source checkout: use a sibling store, + # falling back to the OS temp dir only when the user's home itself is a checkout (dotfiles + # repo). Per-uid temp name: a fixed /tmp/hermes-profile-exports is a predictable shared + # path another local user could pre-create (or symlink) first. uid_suffix = f"-{os.getuid()}" if hasattr(os, "getuid") else "" candidates = ( Path.home() / ".hermes-profile-exports", @@ -1851,9 +1560,8 @@ def _profile_export_directory() -> Path: for candidate in candidates: if not _inside_git_checkout(candidate): return candidate - # Fail closed: writing a secret-bearing archive into a source tree is the - # incident this helper exists to prevent (#92457). A warning on stderr - # would not stop a scripted export from recreating it. + # Fail closed: writing a secret-bearing archive into a source tree is the incident this + # helper prevents; a stderr warning would not stop a scripted export. raise ValueError( "No safe automatic export destination: every candidate directory is " "inside a Git checkout. Provide an explicit output path outside the " @@ -1862,18 +1570,13 @@ def _profile_export_directory() -> Path: def get_profile_export_path(name: str, *, timestamp: Optional[str] = None) -> Path: - """Return a managed destination for an export with no explicit output. - - Kept outside the cwd and every named profile: the CLI is often run from a source checkout, - where a ``.tar.gz`` default looked like a repo artifact and got committed by accident. - """ - canon = normalize_profile_name(name) - validate_profile_name(canon) + """Managed destination for an export with no explicit output — outside the cwd and every + profile, since a ``.tar.gz`` default in a source checkout got committed by accident.""" + canon = _canon_valid(name) export_dir = _profile_export_directory() export_dir.mkdir(parents=True, exist_ok=True) - # exist_ok=True would silently accept a directory (or symlink) another - # local user pre-created at a predictable path; refuse to write a - # secret-bearing archive anywhere we don't own. + # exist_ok=True silently accepts a directory (or symlink) another local user pre-created + # at a predictable path; refuse to write a secret-bearing archive anywhere we don't own. if export_dir.is_symlink(): raise ValueError( f"Export directory {export_dir} is a symlink; refusing to write " @@ -1888,16 +1591,11 @@ def get_profile_export_path(name: str, *, timestamp: Optional[str] = None) -> Pa stamp = timestamp or time.strftime("%Y%m%d-%H%M%S") return export_dir / f"{canon}-{stamp}.tar.gz" + def _default_export_ignore(root_dir: Path): - """Return an *ignore* callable for :func:`shutil.copytree`. - - * **Root-level allow-list** — only entries whose name appears in - ``_DEFAULT_EXPORT_INCLUDE_ROOT`` survive. Everything else (such as an unrelated ``x11-dev/`` - directory in a Docker deployment where HERMES_HOME equals the cwd) is excluded. - - Surviving text files are later force-redacted by :func:`_scrub_export_secrets` before the - archive is written. - """ + """copytree ignore for the default-profile export: root-level allow-list + (``_DEFAULT_EXPORT_INCLUDE_ROOT``) plus universal exclusions. Surviving text files are + then force-redacted by :func:`_scrub_export_secrets`.""" def _ignore(directory: str, contents: list) -> set: # Universal exclusions (any depth) plus npm lockfiles that can appear at root. @@ -1907,8 +1605,6 @@ def _default_export_ignore(root_dir: Path): or entry.endswith((".sock", ".tmp")) or entry in {"package.json", "package-lock.json"} } - # Root-level allow-list: drop everything that isn't a known - # Hermes profile artifact. if Path(directory) == root_dir: ignored.update( entry for entry in contents if entry not in _DEFAULT_EXPORT_INCLUDE_ROOT @@ -1921,24 +1617,18 @@ def _default_export_ignore(root_dir: Path): # Credential files dropped from named-profile exports. _EXPORT_CREDENTIAL_FILES = frozenset({"auth.json", ".env"}) -# Text / config suffixes walked during export secret scrubbing. Binary DBs, -# images, and other non-text artifacts are left alone (they may still leave -# via named-profile export — scrubbing those is a separate concern). +# Text/config suffixes secret-scrubbed on export; binary DBs, images etc. are left alone. _EXPORT_REDACT_SUFFIXES = frozenset({ ".md", ".txt", ".yaml", ".yml", ".json", ".jsonl", ".toml", ".ini", ".cfg", ".conf", ".py", ".sh", ".bash", ".zsh", ".js", ".ts", ".tsx", ".jsx", ".css", ".html", ".xml", ".csv", }) -# pathlib.Path(".cursorrules").suffix is "" — name-match these. -# ``*.env.example`` uses endswith (suffix would be ``.example``). -_EXPORT_REDACT_NAMES = frozenset({ - ".cursorrules", -}) +# ``Path(".cursorrules").suffix`` is "" — name-match; ``*.env.example`` uses endswith. +_EXPORT_REDACT_NAMES = frozenset({".cursorrules"}) def _should_redact_export_file(path: Path) -> bool: - """True when *path* is a text-ish file we should secret-scrub on export.""" name = path.name return ( name in _EXPORT_REDACT_NAMES @@ -1948,21 +1638,15 @@ def _should_redact_export_file(path: Path) -> bool: def _scrub_export_secrets(staged: Path) -> None: - """Force-redact secret-shaped strings in a staged export tree. - - Same ``agent.redact.redact_sensitive_text(..., force=True)`` pass used by ``hermes sessions - export --redact``. Runs on the *staged copy only* so the live profile is never rewritten. - - Symlinks to text files are materialized into regular files when their content changes, so - redaction never follows a link back into the source profile (``copytree(..., symlinks=True)``). - """ + """Force-redact secret-shaped strings in a staged export tree (same pass as ``hermes + sessions export --redact``). Runs on the staged copy only; symlinks to text files are + materialized when content changes so redaction never follows a link back into the source.""" from agent.redact import redact_sensitive_text for path in staged.rglob("*"): try: is_link = path.is_symlink() - # Skip broken links, symlinked directories, and non-files. - if not path.is_file(): + if not path.is_file(): # broken links, symlinked dirs, non-files continue except OSError: continue @@ -1985,33 +1669,21 @@ def _scrub_export_secrets(staged: Path) -> None: def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, str]] = None) -> Path: - """Export a profile to a tar.gz archive. - - Credential files (``auth.json``, ``.env``) are excluded, and secret-shaped strings in staged - text files are force-redacted before the archive is written. Returns the output file path. - """ + """Export a profile to a tar.gz archive; credential files are excluded and staged text is + force-redacted first. Returns the output file path.""" import tempfile - canon = normalize_profile_name(name) - validate_profile_name(canon) + canon = _canon_valid(name) profile_dir = get_profile_dir(canon) if not profile_dir.is_dir(): raise FileNotFoundError(f"Profile '{canon}' does not exist.") - output = Path(output_path) # Archive base name without extension (.tar.gz appended by the writer). - base = str(output).removesuffix(".tar.gz").removesuffix(".tgz") + base = str(Path(output_path)).removesuffix(".tar.gz").removesuffix(".tgz") - def _stage_extras(staged: Path) -> None: - for rel, content in (extra_files or {}).items(): - parts = normalize_archive_parts(rel) - target = staged.joinpath(*parts) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content, encoding="utf-8") - - # The default profile IS ~/.hermes itself (dir name ".hermes", not "default"), - # so both paths stage a filtered copy under a temp dir named after the canonical - # id: the root allow-list for default, credential exclusion for named profiles. + # The default profile IS ~/.hermes (dir name ".hermes"), so both paths stage a filtered + # copy under a temp dir named after the canonical id: root allow-list for default, + # credential exclusion for named profiles. def _ignore_credentials(directory: str, contents: list) -> set: return _EXPORT_CREDENTIAL_FILES & set(contents) @@ -2019,7 +1691,10 @@ def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, with tempfile.TemporaryDirectory() as tmpdir: staged = Path(tmpdir) / canon shutil.copytree(profile_dir, staged, symlinks=True, ignore=ignore) - _stage_extras(staged) + for rel, content in (extra_files or {}).items(): + target = staged.joinpath(*normalize_archive_parts(rel)) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") _scrub_export_secrets(staged) return Path(make_targz(base, tmpdir, canon)) @@ -2045,11 +1720,9 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path: "Profile archive must contain exactly one top-level directory." ) - # Archives exported from the default profile have "default/" as top-level - # dir. Importing as "default" would target ~/.hermes itself — disallow - # that and guide the user toward a named profile. - canon = normalize_profile_name(inferred_name) - validate_profile_name(canon) + # Default-profile archives have "default/" at top level; importing as "default" would + # target ~/.hermes itself. + canon = _canon_valid(inferred_name) if canon == "default": raise ValueError( "Cannot import as 'default' — that is the built-in root profile (~/.hermes). " @@ -2060,8 +1733,7 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path: if profile_dir.exists(): raise FileExistsError(f"Profile '{canon}' already exists at {profile_dir}") - profiles_root = _get_profiles_root() - profiles_root.mkdir(parents=True, exist_ok=True) + _get_profiles_root().mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="hermes_profile_import_") as tmpdir: staging_root = Path(tmpdir) @@ -2142,22 +1814,17 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> block = hosts[source_host] if isinstance(block, dict) and "aiPeer" not in block: - # source_host is either ``hermes_`` or legacy ``hermes.``. - block["aiPeer"] = old_name + block["aiPeer"] = old_name # source_host is ``hermes_`` or legacy ``hermes.`` hosts[new_host] = hosts.pop(source_host) if _atomic_write_json(path, raw): print(f"✓ Honcho host updated: {source_host} → {new_host}") def rename_profile(old_name: str, new_name: str) -> Path: - """Rename a profile: directory, wrapper script, service, active_profile. - - The default profile's home IS the installation root, so "renaming" it sets a presentation-only - ``display_name`` in profile.yaml instead — the canonical id stays ``default`` and every - resolution path is untouched. - """ - old_canon = normalize_profile_name(old_name) - validate_profile_name(old_canon) + """Rename a profile: directory, wrapper script, service, active_profile. The default + profile's home IS the installation root, so "renaming" it sets a presentation-only + ``display_name`` instead — the canonical id stays ``default``.""" + old_canon = _canon_valid(old_name) if old_canon == "default": if not (new_name or "").strip(): @@ -2166,8 +1833,7 @@ def rename_profile(old_name: str, new_name: str) -> Path: print(f"✓ Display name set: {cleaned} (canonical id remains 'default')") return _get_default_hermes_home() - new_canon = normalize_profile_name(new_name) - validate_profile_name(new_canon) + new_canon = _canon_valid(new_name) if new_canon == "default": raise ValueError("Cannot rename to 'default' — it is reserved.") @@ -2212,18 +1878,14 @@ def rename_profile(old_name: str, new_name: str) -> Path: # --------------------------------------------------------------------------- def resolve_profile_env(profile_name: str) -> str: - """Resolve a profile name to a HERMES_HOME path string. - - Called early in the CLI entry point, before any hermes modules are imported, to set the - HERMES_HOME environment variable. - """ - canon = normalize_profile_name(profile_name) - validate_profile_name(canon) + """Resolve a profile name to a HERMES_HOME path string. Called early in the CLI entry + point, before hermes modules are imported, to set HERMES_HOME.""" + canon = _canon_valid(profile_name) env_home = os.environ.get("HERMES_HOME", "").strip() if env_home: env_path = Path(env_home) - # A profile-shaped env value means the root is the grandparent - # (mirrors get_default_hermes_root()). + # A profile-shaped env value means the root is the grandparent (mirrors + # get_default_hermes_root()). root = env_path.parent.parent if env_path.parent.name == "profiles" else env_path else: root = _get_default_hermes_home() @@ -2232,9 +1894,6 @@ def resolve_profile_env(profile_name: str) -> str: profile_dir = root / "profiles" / canon if not profile_dir.is_dir() or named_profile_is_deleted(profile_dir): - raise FileNotFoundError( - f"Profile '{canon}' does not exist. " - f"Create it with: hermes profile create {canon}" - ) + raise _missing_profile_error(canon) return str(profile_dir)