"""``hermes profile`` command — one handler per action, dispatched by table. Moved out of ``hermes_cli/main.py:cmd_profile`` (was a 600-line if/elif chain). ``hermes_cli.main`` re-exports ``cmd_profile`` so existing callers and monkeypatches on ``hermes_cli.main.cmd_profile`` keep working. """ from __future__ import annotations from pathlib import Path import os import sys def _render_distribution_plan(plan) -> None: """Print a human-readable summary of a pending distribution install.""" from hermes_cli.profile_distribution import MANIFEST_FILENAME mf = plan.manifest print(f"\nDistribution: {mf.name} v{mf.version}") if mf.description: print(f" {mf.description}") if mf.author: print(f" Author: {mf.author}") if mf.hermes_requires: print(f" Requires: Hermes {mf.hermes_requires}") print(f" Source: {plan.provenance}") print(f" Target: {plan.target_dir}") if plan.existing: # Distinguish "updating an existing distribution" (well-understood # semantics — dist-owned overwritten, config preserved, user data # untouched) from "overwriting a hand-built plain profile" (same # mechanics but the user didn't sign up for this when they created # the profile manually). existing_is_distribution = (plan.target_dir / MANIFEST_FILENAME).is_file() if existing_is_distribution: print(" (profile exists — will overwrite distribution-owned files only)") else: print( " ⚠ Profile exists but is NOT a distribution. Installing here will\n" " overwrite its SOUL.md, skills/, cron/, and mcp.json.\n" " Your memories, sessions, auth.json, and .env will be preserved,\n" " but any hand-edits to distribution-owned files will be lost." ) if mf.env_requires: print("\n Env vars:") for er in mf.env_requires: tag = "required" if er.required else "optional" # Check both the current shell environment and the target profile's # .env file so we don't nag about keys the user already has set up. already = os.environ.get(er.name) is not None if not already and plan.target_dir.is_dir(): env_path = plan.target_dir / ".env" if env_path.is_file(): try: # .env is written as UTF-8 everywhere in the codebase, # but a Notepad-edited file can carry a BOM — read as # utf-8-sig so the first key isn't hidden behind # U+FEFF (#62617). for raw in env_path.read_text(encoding="utf-8-sig").splitlines(): line = raw.strip() if not line or line.startswith("#"): continue key = line.split("=", 1)[0].strip() if key == er.name: already = True break except (OSError, UnicodeDecodeError): # UnicodeDecodeError is a ValueError, not an OSError, so # the old guard let a mis-encoded .env abort the whole # install preview. Skip the pre-check instead. pass status = "✓ set" if already else ("needs setting" if er.required else "—") line = f" • {er.name} ({tag}, {status})" if er.description: line += f" — {er.description}" print(line) if plan.has_cron: print( "\n ⚠ This distribution ships cron jobs. They will NOT run " "automatically — review and enable manually." ) def _profile_status(args): """Bare ``hermes profile`` — show current profile status.""" from hermes_constants import display_hermes_home from hermes_cli.profiles import ( get_active_profile_name, list_profiles, ) from hermes_cli.profiles import format_profile_label profile_name = get_active_profile_name() dhh = display_hermes_home() profiles = list_profiles() current = next( ( p for p in profiles if p.name == profile_name or (profile_name == "default" and p.is_default) ), None, ) label = format_profile_label( profile_name, current.display_name if current else "" ) print(f"\nActive profile: {label}") print(f"Path: {dhh}") if current is not None: p = current if p.model: print( f"Model: {p.model}" + (f" ({p.provider})" if p.provider else "") ) print( f"Gateway: {'running' if p.gateway_running else 'stopped'}" ) print(f"Skills: {p.skill_count} installed") if p.alias_path: alias_display = p.alias_name or p.name print(f"Alias: {alias_display} → hermes -p {p.name}") print() return def _profile_list(args): from hermes_cli.profiles import ( get_active_profile_name, list_profiles, ) from hermes_cli.profiles import format_profile_label profiles = list_profiles() active = get_active_profile_name() if not profiles: print("No profiles found.") return # Header print( f"\n {'Profile':<16} {'Model':<28} {'Gateway':<12} " f"{'Alias':<12} {'Distribution'}" ) print( f" {'─' * 15} {'─' * 27} {'─' * 11} " f"{'─' * 11} {'─' * 20}" ) for p in profiles: marker = ( " ◆" if (p.name == active or (active == "default" and p.is_default)) else " " ) name = format_profile_label(p.name, p.display_name) model = (p.model or "—")[:26] gw = "running" if p.gateway_running else "stopped" alias = (p.alias_name or p.name) if p.alias_path else "—" if p.is_default: alias = "—" if p.distribution_name: dist = f"{p.distribution_name}@{p.distribution_version or '?'}" dist = dist[:30] else: dist = "—" print(f"{marker}{name:<15} {model:<28} {gw:<12} {alias:<12} {dist}") print() def _profile_use(args): from hermes_cli.profiles import set_active_profile name = args.profile_name try: set_active_profile(name) if name == "default": print("Switched to: default (~/.hermes)") else: print(f"Switched to: {name}") except (ValueError, FileNotFoundError) as e: print(f"Error: {e}") sys.exit(1) def _profile_create(args): from hermes_cli.profiles import ( _get_wrapper_dir, _is_wrapper_dir_in_path, check_alias_collision, create_profile, create_wrapper_script, get_active_profile_name, seed_profile_skills, ) name = args.profile_name clone = getattr(args, "clone", False) clone_all = getattr(args, "clone_all", False) no_alias = getattr(args, "no_alias", False) no_skills = getattr(args, "no_skills", False) try: clone_from = getattr(args, "clone_from", None) clone_config = clone or clone_from is not None profile_dir = create_profile( name=name, clone_from=clone_from, clone_all=clone_all, clone_config=clone_config, no_alias=no_alias, no_skills=no_skills, description=getattr(args, "description", None), ) print(f"\nProfile '{name}' created at {profile_dir}") if clone_config or clone_all: source_label = ( getattr(args, "clone_from", None) or get_active_profile_name() ) if clone_all: print( f"Full copy from {source_label} " "(excluding session history, backups, and snapshots)." ) else: print( f"Cloned config, .env, SOUL.md, and skills from {source_label}." ) # Auto-clone Honcho config for the new profile (only with clone operations) if clone_config or clone_all: try: from plugins.memory.honcho.cli import clone_honcho_for_profile if clone_honcho_for_profile(name): print(f"Honcho config cloned (peer: {name})") except Exception: pass # Honcho plugin not installed or not configured # Seed bundled skills for fresh profiles only. Clone operations # already copied the source profile's skills, including any # user-installed or intentionally removed skills. if not (clone_config or clone_all): result = seed_profile_skills(profile_dir) if result and result.get("skipped_opt_out"): print( "No bundled skills seeded (--no-skills). " "Delete .no-bundled-skills in the profile to opt back in." ) elif result: copied = len(result.get("copied", [])) print(f"{copied} bundled skills synced.") else: print( "⚠ Skills could not be seeded. Run `{} update` to retry.".format( name ) ) # Create wrapper alias if not no_alias: collision = check_alias_collision(name) if collision: print(f"\n⚠ Cannot create alias '{name}' — {collision}") print( f" Choose a custom alias: hermes profile alias {name} --name " ) print(f" Or access via flag: hermes -p {name} chat") else: wrapper_path = create_wrapper_script(name) if wrapper_path: print(f"Wrapper created: {wrapper_path}") if not _is_wrapper_dir_in_path(): print(f"\n⚠ {_get_wrapper_dir()} is not in your PATH.") print( " Add to your shell config (~/.bashrc or ~/.zshrc):" ) print(' export PATH="$HOME/.local/bin:$PATH"') # Profile dir for display try: profile_dir_display = "~/" + profile_dir.relative_to(Path.home()).as_posix() except ValueError: profile_dir_display = str(profile_dir) # Next steps print("\nNext steps:") print(f" {name} setup Configure API keys and model") print(f" {name} chat Start chatting") print(f" {name} gateway start Start the messaging gateway") if clone or clone_all: print(f"\n Edit {profile_dir_display}/.env for different API keys") print(f" Edit {profile_dir_display}/SOUL.md for different personality") else: print( f"\n ⚠ This profile has no API keys yet. Run '{name} setup' first," ) print(" or it will inherit keys from your shell environment.") print(f" Edit {profile_dir_display}/SOUL.md to customize personality") print() except (ValueError, FileExistsError, FileNotFoundError) as e: print(f"Error: {e}") sys.exit(1) def _profile_delete(args): from hermes_cli.profiles import delete_profile name = args.profile_name yes = getattr(args, "yes", False) try: delete_profile(name, yes=yes) except (ValueError, FileNotFoundError) as e: print(f"Error: {e}") sys.exit(1) def _profile_describe(args): from hermes_cli import profiles as _profiles_mod all_flag = bool(getattr(args, "all_missing", False)) auto_flag = bool(getattr(args, "auto", False)) overwrite_flag = bool(getattr(args, "overwrite", False)) text_value = getattr(args, "text", None) name = getattr(args, "profile_name", None) if all_flag and not auto_flag: print("profile describe: --all requires --auto", file=sys.stderr) sys.exit(2) if all_flag and (text_value or name): print( "profile describe: --all is mutually exclusive with a profile name / --text", file=sys.stderr, ) sys.exit(2) if not all_flag and not name: print("profile describe: profile name is required (or --all --auto)", file=sys.stderr) sys.exit(2) if text_value and auto_flag: print( "profile describe: --text is mutually exclusive with --auto", file=sys.stderr, ) sys.exit(2) # Show current description if no operation requested. if name and not text_value and not auto_flag: try: if _profiles_mod.normalize_profile_name(name) == "default": from hermes_constants import get_hermes_home as _hh profile_dir = Path(_hh()) else: profile_dir = _profiles_mod.get_profile_dir(name) except Exception as exc: print(f"Error: {exc}", file=sys.stderr) sys.exit(1) if not profile_dir.is_dir(): print(f"Error: profile '{name}' not found", file=sys.stderr) sys.exit(1) meta = _profiles_mod.read_profile_meta(profile_dir) desc = meta.get("description") or "" if not desc: print(f"(no description set for '{name}')") else: tag = "[auto] " if meta.get("description_auto") else "" print(f"{tag}{desc}") sys.exit(0) # --text path: just write the user-authored description. if text_value: try: if _profiles_mod.normalize_profile_name(name) == "default": from hermes_constants import get_hermes_home as _hh profile_dir = Path(_hh()) else: profile_dir = _profiles_mod.get_profile_dir(name) _profiles_mod.write_profile_meta( profile_dir, description=text_value, description_auto=False, ) print(f"Description updated for '{name}'.") except Exception as exc: print(f"Error: {exc}", file=sys.stderr) sys.exit(1) sys.exit(0) # --auto path: invoke the LLM describer. from hermes_cli import profile_describer as _pd if all_flag: targets = _pd.list_describable_profiles(missing_only=True) if not targets: print("All profiles already have descriptions.") sys.exit(0) else: targets = [name] ok_count = 0 fail_count = 0 for tgt in targets: outcome = _pd.describe_profile(tgt, overwrite=overwrite_flag) if outcome.ok: ok_count += 1 print(f"Described '{outcome.profile_name}': {outcome.description}") else: fail_count += 1 print( f"profile describe {outcome.profile_name}: {outcome.reason}", file=sys.stderr, ) if not all_flag: sys.exit(0 if ok_count == 1 else 1) sys.exit(0 if ok_count > 0 else 1) def _profile_show(args): name = args.profile_name from hermes_cli.profiles import ( get_profile_dir, profile_exists, _read_config_model, _check_gateway_running, _served_by_running_multiplexer, _count_skills, _read_distribution_meta, _get_wrapper_dir, find_alias_for_profile, format_profile_label, read_profile_meta, ) if not profile_exists(name): print(f"Error: Profile '{name}' does not exist.") sys.exit(1) profile_dir = get_profile_dir(name) model, provider = _read_config_model(profile_dir) gw = _check_gateway_running(profile_dir) or _served_by_running_multiplexer(name) skills = _count_skills(profile_dir) dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir) alias_name = find_alias_for_profile(name) display = read_profile_meta(profile_dir).get("display_name", "") print(f"\nProfile: {format_profile_label(name, display)}") print(f"Path: {profile_dir}") if model: print(f"Model: {model}" + (f" ({provider})" if provider else "")) print(f"Gateway: {'running' if gw else 'stopped'}") print(f"Skills: {skills}") print( f".env: {'exists' if (profile_dir / '.env').exists() else 'not configured'}" ) print( f"SOUL.md: {'exists' if (profile_dir / 'SOUL.md').exists() else 'not configured'}" ) if dist_name: print(f"Distribution: {dist_name}@{dist_version or '?'}") if dist_source: print(f"Installed from: {dist_source}") print(f" (run `hermes profile info {name}` for full manifest)") if alias_name: is_windows = sys.platform == "win32" wrapper = _get_wrapper_dir() / (f"{alias_name}.bat" if is_windows else alias_name) print(f"Alias: {alias_name} → hermes -p {name} ({wrapper})") print() def _profile_alias(args): from hermes_cli.profiles import ( _get_wrapper_dir, _is_wrapper_dir_in_path, check_alias_collision, create_wrapper_script, remove_wrapper_script, ) name = args.profile_name remove = getattr(args, "remove", False) custom_name = getattr(args, "alias_name", None) from hermes_cli.profiles import profile_exists, validate_alias_name if not profile_exists(name): print(f"Error: Profile '{name}' does not exist.") sys.exit(1) alias_name = custom_name or name try: validate_alias_name(alias_name) except ValueError as exc: print(f"Error: {exc}") sys.exit(1) if remove: if remove_wrapper_script(alias_name): print(f"✓ Removed alias '{alias_name}'") else: print(f"No alias '{alias_name}' found to remove.") else: collision = check_alias_collision(alias_name) if collision: print(f"Error: {collision}") sys.exit(1) wrapper_path = create_wrapper_script( alias_name, target=name if custom_name else None ) if wrapper_path: print(f"✓ Alias created: {wrapper_path}") if not _is_wrapper_dir_in_path(): print(f"⚠ {_get_wrapper_dir()} is not in your PATH.") def _profile_rename(args): from hermes_cli.profiles import normalize_profile_name, rename_profile try: new_dir = rename_profile(args.old_name, args.new_name) if normalize_profile_name(args.old_name) != "default": print(f"\nProfile renamed: {args.old_name} → {args.new_name}") print(f"Path: {new_dir}\n") except (ValueError, FileExistsError, FileNotFoundError) as e: print(f"Error: {e}") sys.exit(1) def _profile_export(args): from hermes_cli.profiles import export_profile, get_profile_export_path name = args.profile_name try: output = args.output or str(get_profile_export_path(name)) result_path = export_profile(name, output) print(f"✓ Exported '{name}' to {result_path}") except (ValueError, FileNotFoundError, OSError) as e: print(f"Error: {e}") sys.exit(1) def _profile_import(args): from hermes_cli.profiles import ( check_alias_collision, create_wrapper_script, ) from hermes_cli.profiles import import_profile try: profile_dir = import_profile( args.archive, name=getattr(args, "import_name", None) ) name = profile_dir.name print(f"✓ Imported profile '{name}' at {profile_dir}") # Offer to create alias collision = check_alias_collision(name) if not collision: wrapper_path = create_wrapper_script(name) if wrapper_path: print(f" Wrapper created: {wrapper_path}") print() except (ValueError, FileExistsError, FileNotFoundError) as e: print(f"Error: {e}") sys.exit(1) def _profile_install(args): import tempfile from hermes_cli.profile_distribution import ( plan_install, install_distribution, DistributionError, ) try: # Preview: stage the distribution into a scratch dir, show the # manifest, then do the real install. The double-stage avoids # any side-effects if the user declines. with tempfile.TemporaryDirectory(prefix="hermes_dist_preview_") as tmp: plan = plan_install( args.source, Path(tmp), override_name=getattr(args, "install_name", None), ) _render_distribution_plan(plan) if not getattr(args, "yes", False): try: answer = input("\nProceed with install? [y/N] ").strip().lower() except (EOFError, KeyboardInterrupt): answer = "" if answer not in {"y", "yes"}: print("Install cancelled.") return plan = install_distribution( args.source, name=getattr(args, "install_name", None), force=getattr(args, "force", False), create_alias=getattr(args, "alias", False), ) print(f"\n✓ Installed '{plan.manifest.name}' v{plan.manifest.version}") print(f" Profile path: {plan.target_dir}") if plan.manifest.env_requires: print( f" Next: copy .env.EXAMPLE to .env and fill in required keys:\n" f" {plan.target_dir}/.env.EXAMPLE" ) if plan.has_cron: print( " Cron jobs were included but are NOT scheduled automatically.\n" f" Review them with: hermes -p {plan.manifest.name} cron list" ) print(f"\n Use with: hermes -p {plan.manifest.name} chat") except (DistributionError, ValueError) as e: print(f"Error: {e}") sys.exit(1) def _profile_update(args): from hermes_cli.profile_distribution import ( update_distribution, read_manifest, DistributionError, ) from hermes_cli.profiles import get_profile_dir, normalize_profile_name name = args.profile_name try: canon = normalize_profile_name(name) current = read_manifest(get_profile_dir(canon)) if current is None: print( f"Error: Profile '{canon}' is not a distribution (no distribution.yaml). " "Only profiles installed via `hermes profile install` can be updated." ) sys.exit(1) force_config = getattr(args, "force_config", False) if not getattr(args, "yes", False): print(f"\nUpdate '{canon}' from: {current.source or '(no source)'}") print(f" Currently at version {current.version}") if force_config: print(" --force-config set: config.yaml WILL be overwritten.") else: print(" config.yaml will be preserved (pass --force-config to overwrite).") print(" User data (memories, sessions, auth, .env) will NOT be touched.") try: answer = input("\nProceed? [y/N] ").strip().lower() except (EOFError, KeyboardInterrupt): answer = "" if answer not in {"y", "yes"}: print("Update cancelled.") return plan = update_distribution(canon, force_config=force_config) print(f"\n✓ Updated '{plan.manifest.name}' → v{plan.manifest.version}") if plan.has_cron: print( " Cron files were refreshed. Review with: " f"hermes -p {plan.manifest.name} cron list" ) except (DistributionError, ValueError) as e: print(f"Error: {e}") sys.exit(1) def _profile_info(args): from hermes_cli.profile_distribution import describe_distribution, DistributionError try: data = describe_distribution(args.profile_name) except (DistributionError, ValueError) as e: print(f"Error: {e}") sys.exit(1) if not data: print( f"Profile '{args.profile_name}' is not a distribution " "(no distribution.yaml)." ) return print(f"\nDistribution: {data.get('name')}") print(f"Version: {data.get('version', '?')}") if data.get("description"): print(f"Description: {data['description']}") if data.get("author"): print(f"Author: {data['author']}") if data.get("license"): print(f"License: {data['license']}") if data.get("hermes_requires"): print(f"Requires: Hermes {data['hermes_requires']}") if data.get("source"): print(f"Source: {data['source']}") if data.get("installed_at"): print(f"Installed: {data['installed_at']}") env_reqs = data.get("env_requires") or [] if env_reqs: print("\nEnvironment variables:") for er in env_reqs: tag = "required" if er.get("required", True) else "optional" line = f" {er['name']} ({tag})" if er.get("description"): line += f" — {er['description']}" print(line) if er.get("default") is not None: print(f" default: {er['default']}") print() # Order mirrors the original if/elif chain; None = bare ``hermes profile``. PROFILE_ACTIONS = { None: _profile_status, 'list': _profile_list, 'use': _profile_use, 'create': _profile_create, 'delete': _profile_delete, 'describe': _profile_describe, 'show': _profile_show, 'alias': _profile_alias, 'rename': _profile_rename, 'export': _profile_export, 'import': _profile_import, 'install': _profile_install, 'update': _profile_update, 'info': _profile_info, } def cmd_profile(args): """Profile management — create, delete, list, switch, alias.""" handler = PROFILE_ACTIONS.get(getattr(args, "profile_action", None)) if handler is not None: return handler(args)