""" Hermes Web UI -- Profile state management. Wraps hermes_cli.profiles to provide profile switching for the web UI. The web UI maintains a process-level "active profile" that determines which HERMES_HOME directory is used for config, skills, memory, cron, and API keys. Profile switches update os.environ['HERMES_HOME'] and monkey-patch module-level cached paths in hermes-agent modules (skills_tool, cron/jobs) that snapshot HERMES_HOME at import time. """ import json import os import re import shutil import threading from pathlib import Path # ── Constants (match hermes_cli.profiles upstream) ───────────────────────── _PROFILE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$') _PROFILE_DIRS = [ 'memories', 'sessions', 'skills', 'skins', 'logs', 'plans', 'workspace', 'cron', ] _CLONE_CONFIG_FILES = ['config.yaml', '.env', 'SOUL.md'] # ── Module state ──────────────────────────────────────────────────────────── _active_profile = 'default' _profile_lock = threading.Lock() def _resolve_base_hermes_home() -> Path: """Return the BASE ~/.hermes directory — the root that contains profiles/. This is intentionally distinct from HERMES_HOME, which tracks the *active profile's* home and changes on every profile switch. The base dir must always point to the top-level .hermes regardless of which profile is active. Resolution order: 1. HERMES_BASE_HOME env var (set explicitly, highest priority) 2. HERMES_HOME env var — but only if it does NOT look like a profile subdir (i.e. its parent is not named 'profiles'). This handles test isolation where HERMES_HOME is set to an isolated test state dir. 3. ~/.hermes (always-correct default) The bug this prevents: if HERMES_HOME has already been mutated to /home/user/.hermes/profiles/webui (by init_profile_state at startup), reading it here would make _DEFAULT_HERMES_HOME point to that subdir, causing switch_profile('webui') to look for /home/user/.hermes/profiles/webui/profiles/webui — which doesn't exist. """ # Explicit override for tests or unusual setups base_override = os.getenv('HERMES_BASE_HOME', '').strip() if base_override: return Path(base_override).expanduser() hermes_home = os.getenv('HERMES_HOME', '').strip() if hermes_home: p = Path(hermes_home).expanduser() # If HERMES_HOME points to a profiles/ subdir, walk up two levels to the base if p.parent.name == 'profiles': return p.parent.parent # Otherwise trust it (e.g. test isolation sets HERMES_HOME to TEST_STATE_DIR) return p return Path.home() / '.hermes' _DEFAULT_HERMES_HOME = _resolve_base_hermes_home() def _read_active_profile_file() -> str: """Read the sticky active profile from ~/.hermes/active_profile.""" ap_file = _DEFAULT_HERMES_HOME / 'active_profile' if ap_file.exists(): try: name = ap_file.read_text().strip() if name: return name except Exception: pass return 'default' # ── Public API ────────────────────────────────────────────────────────────── def get_active_profile_name() -> str: """Return the currently active profile name.""" return _active_profile def get_active_hermes_home() -> Path: """Return the HERMES_HOME path for the currently active profile.""" if _active_profile == 'default': return _DEFAULT_HERMES_HOME profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / _active_profile if profile_dir.is_dir(): return profile_dir return _DEFAULT_HERMES_HOME def _set_hermes_home(home: Path): """Set HERMES_HOME env var and monkey-patch cached module-level paths.""" os.environ['HERMES_HOME'] = str(home) # Patch skills_tool module-level cache (snapshots HERMES_HOME at import) try: import tools.skills_tool as _sk _sk.HERMES_HOME = home _sk.SKILLS_DIR = home / 'skills' except (ImportError, AttributeError): pass # Patch cron/jobs module-level cache try: import cron.jobs as _cj _cj.HERMES_DIR = home _cj.CRON_DIR = home / 'cron' _cj.JOBS_FILE = _cj.CRON_DIR / 'jobs.json' _cj.OUTPUT_DIR = _cj.CRON_DIR / 'output' except (ImportError, AttributeError): pass def _reload_dotenv(home: Path): """Load .env from the profile dir into os.environ (additive).""" env_path = home / '.env' if not env_path.exists(): return try: for line in env_path.read_text().splitlines(): line = line.strip() if line and not line.startswith('#') and '=' in line: k, v = line.split('=', 1) k = k.strip() v = v.strip().strip('"').strip("'") if k and v: os.environ[k] = v except Exception: pass def init_profile_state() -> None: """Initialize profile state at server startup. Reads ~/.hermes/active_profile, sets HERMES_HOME env var, patches module-level cached paths. Called once from config.py after imports. """ global _active_profile _active_profile = _read_active_profile_file() home = get_active_hermes_home() _set_hermes_home(home) _reload_dotenv(home) def switch_profile(name: str) -> dict: """Switch the active profile. Validates the profile exists, updates process state, patches module caches, reloads .env, and reloads config.yaml. Returns: {'profiles': [...], 'active': name} Raises ValueError if profile doesn't exist or agent is busy. """ global _active_profile # Import here to avoid circular import at module load from api.config import STREAMS, STREAMS_LOCK, reload_config # Block if agent is running with STREAMS_LOCK: if len(STREAMS) > 0: raise RuntimeError( 'Cannot switch profiles while an agent is running. ' 'Cancel or wait for it to finish.' ) # Resolve profile directory if name == 'default': home = _DEFAULT_HERMES_HOME else: home = _DEFAULT_HERMES_HOME / 'profiles' / name if not home.is_dir(): raise ValueError(f"Profile '{name}' does not exist.") with _profile_lock: _active_profile = name _set_hermes_home(home) _reload_dotenv(home) # Write sticky default for CLI consistency try: ap_file = _DEFAULT_HERMES_HOME / 'active_profile' ap_file.write_text(name if name != 'default' else '') except Exception: pass # Reload config.yaml from the new profile reload_config() # Return profile-specific defaults so frontend can apply them from api.workspace import get_last_workspace from api.config import get_config cfg = get_config() model_cfg = cfg.get('model', {}) default_model = None if isinstance(model_cfg, str): default_model = model_cfg elif isinstance(model_cfg, dict): default_model = model_cfg.get('default') return { 'profiles': list_profiles_api(), 'active': name, 'default_model': default_model, 'default_workspace': get_last_workspace(), } def list_profiles_api() -> list: """List all profiles with metadata, serialized for JSON response.""" try: from hermes_cli.profiles import list_profiles infos = list_profiles() except ImportError: # hermes_cli not available -- return just the default return [_default_profile_dict()] active = _active_profile result = [] for p in infos: result.append({ 'name': p.name, 'path': str(p.path), 'is_default': p.is_default, 'is_active': p.name == active, 'gateway_running': p.gateway_running, 'model': p.model, 'provider': p.provider, 'has_env': p.has_env, 'skill_count': p.skill_count, }) return result def _default_profile_dict() -> dict: """Fallback profile dict when hermes_cli is not importable.""" return { 'name': 'default', 'path': str(_DEFAULT_HERMES_HOME), 'is_default': True, 'is_active': True, 'gateway_running': False, 'model': None, 'provider': None, 'has_env': (_DEFAULT_HERMES_HOME / '.env').exists(), 'skill_count': 0, } def _validate_profile_name(name: str): """Validate profile name format (matches hermes_cli.profiles upstream).""" if name == 'default': raise ValueError("Cannot create a profile named 'default' -- it is the built-in profile.") # Use fullmatch (not match) so a trailing newline can't sneak past the $ anchor if not _PROFILE_ID_RE.fullmatch(name): raise ValueError( f"Invalid profile name {name!r}. " "Must match [a-z0-9][a-z0-9_-]{0,63}" ) def _create_profile_fallback(name: str, clone_from: str = None, clone_config: bool = False) -> Path: """Create a profile directory without hermes_cli (Docker/standalone fallback).""" profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name if profile_dir.exists(): raise FileExistsError(f"Profile '{name}' already exists.") # Bootstrap directory structure (exist_ok=False so a concurrent create raises) profile_dir.mkdir(parents=True, exist_ok=False) for subdir in _PROFILE_DIRS: (profile_dir / subdir).mkdir(parents=True, exist_ok=True) # Clone config files from source profile if requested if clone_config and clone_from: if clone_from == 'default': source_dir = _DEFAULT_HERMES_HOME else: source_dir = _DEFAULT_HERMES_HOME / 'profiles' / clone_from if source_dir.is_dir(): for filename in _CLONE_CONFIG_FILES: src = source_dir / filename if src.exists(): shutil.copy2(src, profile_dir / filename) return profile_dir def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key: str = None) -> None: """Write custom endpoint fields into config.yaml for a profile.""" if not base_url and not api_key: return config_path = profile_dir / 'config.yaml' try: import yaml as _yaml except ImportError: return cfg = {} if config_path.exists(): try: loaded = _yaml.safe_load(config_path.read_text()) if isinstance(loaded, dict): cfg = loaded except Exception: pass model_section = cfg.get('model', {}) if not isinstance(model_section, dict): model_section = {} if base_url: model_section['base_url'] = base_url if api_key: model_section['api_key'] = api_key cfg['model'] = model_section config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True)) def create_profile_api(name: str, clone_from: str = None, clone_config: bool = False, base_url: str = None, api_key: str = None) -> dict: """Create a new profile. Returns the new profile info dict.""" _validate_profile_name(name) # Defense-in-depth: validate clone_from here too, even though routes.py # also validates it. Any caller that bypasses the HTTP layer gets protection. if clone_from is not None and clone_from != 'default': _validate_profile_name(clone_from) try: from hermes_cli.profiles import create_profile create_profile( name, clone_from=clone_from, clone_config=clone_config, clone_all=False, no_alias=True, ) except ImportError: _create_profile_fallback(name, clone_from, clone_config) # Resolve the profile directory from the profile list when possible. # hermes_cli and the webui runtime do not always agree on the exact root, # so we prefer the path returned by list_profiles_api() and fall back to the # standard profile location only if the profile cannot be found there yet. profile_path = _DEFAULT_HERMES_HOME / 'profiles' / name for p in list_profiles_api(): if p['name'] == name: try: profile_path = Path(p.get('path') or profile_path) except Exception: pass break profile_path.mkdir(parents=True, exist_ok=True) _write_endpoint_to_config(profile_path, base_url=base_url, api_key=api_key) # Find and return the newly created profile info. # When hermes_cli is not importable, list_profiles_api() also falls back # to the stub default-only list and won't find the new profile by name. # In that case, return a complete profile dict directly. for p in list_profiles_api(): if p['name'] == name: return p return { 'name': name, 'path': str(profile_path), 'is_default': False, 'is_active': _active_profile == name, 'gateway_running': False, 'model': None, 'provider': None, 'has_env': (profile_path / '.env').exists(), 'skill_count': 0, } def delete_profile_api(name: str) -> dict: """Delete a profile. Switches to default first if it's the active one.""" if name == 'default': raise ValueError("Cannot delete the default profile.") # If deleting the active profile, switch to default first if _active_profile == name: try: switch_profile('default') except RuntimeError: raise RuntimeError( f"Cannot delete active profile '{name}' while an agent is running. " "Cancel or wait for it to finish." ) try: from hermes_cli.profiles import delete_profile delete_profile(name, yes=True) except ImportError: # Manual fallback: just remove the directory import shutil profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name if profile_dir.is_dir(): shutil.rmtree(str(profile_dir)) else: raise ValueError(f"Profile '{name}' does not exist.") return {'ok': True, 'name': name}