feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)

Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.

Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.

Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Esquenazi
2026-04-03 10:50:21 -07:00
parent af73a5d8fd
commit d2b27f6f1e
12 changed files with 688 additions and 34 deletions

View File

@@ -134,17 +134,44 @@ if _AGENT_DIR is not None:
else:
_HERMES_FOUND = False
# ── Config file (optional YAML) ──────────────────────────────────────────────
CONFIG_PATH = Path(os.getenv(
'HERMES_CONFIG_PATH',
str(HOME / '.hermes' / 'config.yaml')
)).expanduser()
# ── Config file (reloadable -- supports profile switching) ──────────────────
_cfg_cache = {}
_cfg_lock = threading.Lock()
try:
import yaml as _yaml
cfg = _yaml.safe_load(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
except Exception:
cfg = {}
def _get_config_path() -> Path:
"""Return config.yaml path for the active profile."""
env_override = os.getenv('HERMES_CONFIG_PATH')
if env_override:
return Path(env_override).expanduser()
try:
from api.profiles import get_active_hermes_home
return get_active_hermes_home() / 'config.yaml'
except ImportError:
return HOME / '.hermes' / 'config.yaml'
def get_config() -> dict:
"""Return the cached config dict, loading from disk if needed."""
if not _cfg_cache:
reload_config()
return _cfg_cache
def reload_config():
"""Reload config.yaml from the active profile's directory."""
with _cfg_lock:
_cfg_cache.clear()
config_path = _get_config_path()
try:
import yaml as _yaml
if config_path.exists():
loaded = _yaml.safe_load(config_path.read_text())
if isinstance(loaded, dict):
_cfg_cache.update(loaded)
except Exception:
pass
# Initial load
reload_config()
cfg = _cfg_cache # alias for backward compat with existing references
# ── Default workspace discovery ───────────────────────────────────────────────
def _discover_default_workspace() -> Path:
@@ -183,7 +210,7 @@ def print_startup_config():
f' state dir : {STATE_DIR}',
f' workspace : {DEFAULT_WORKSPACE}',
f' host:port : {HOST}:{PORT}',
f' config file : {CONFIG_PATH} {"(found)" if CONFIG_PATH.exists() else "(not found, using defaults)"}',
f' config file : {_get_config_path()} {"(found)" if _get_config_path().exists() else "(not found, using defaults)"}',
'',
]
print('\n'.join(lines), flush=True)
@@ -234,11 +261,12 @@ MIME_MAP = {
}
# ── Toolsets (from config.yaml or hardcoded default) ─────────────────────────
CLI_TOOLSETS = cfg.get('platform_toolsets', {}).get('cli', [
_DEFAULT_TOOLSETS = [
'browser', 'clarify', 'code_execution', 'cronjob', 'delegation', 'file',
'image_gen', 'memory', 'session_search', 'skills', 'terminal', 'todo',
'web', 'webhook',
])
]
CLI_TOOLSETS = get_config().get('platform_toolsets', {}).get('cli', _DEFAULT_TOOLSETS)
# ── Model / provider discovery ───────────────────────────────────────────────
@@ -396,7 +424,11 @@ def get_available_models() -> dict:
# 3. Try to read auth store for active provider (if hermes is installed)
if not active_provider:
auth_store_path = HOME / '.hermes' / 'auth.json'
try:
from api.profiles import get_active_hermes_home as _gah
auth_store_path = _gah() / 'auth.json'
except ImportError:
auth_store_path = HOME / '.hermes' / 'auth.json'
if auth_store_path.exists():
try:
import json as _j
@@ -406,7 +438,11 @@ def get_available_models() -> dict:
pass
# 4. Check for API keys that imply available providers
hermes_env_path = HOME / '.hermes' / '.env'
try:
from api.profiles import get_active_hermes_home as _gah2
hermes_env_path = _gah2() / '.env'
except ImportError:
hermes_env_path = HOME / '.hermes' / '.env'
env_keys = {}
if hermes_env_path.exists():
try:
@@ -655,3 +691,11 @@ if SETTINGS_FILE.exists():
# ── SESSIONS in-memory cache (LRU OrderedDict) ───────────────────────────────
SESSIONS: collections.OrderedDict = collections.OrderedDict()
# ── Profile state initialisation ────────────────────────────────────────────
# Must run after all imports are resolved to correctly patch module-level caches
try:
from api.profiles import init_profile_state
init_profile_state()
except ImportError:
pass # hermes_cli not available -- default profile only

View File

@@ -34,8 +34,8 @@ def _write_session_index():
class Session:
def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, project_id=None, **kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived); self.project_id = project_id or None
def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, project_id=None, profile=None, **kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived); self.project_id = project_id or None; self.profile = profile
@property
def path(self): return SESSION_DIR / f'{self.session_id}.json'
def save(self): self.updated_at = time.time(); self.path.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=2), encoding='utf-8'); _write_session_index()
@@ -44,7 +44,7 @@ class Session:
p = SESSION_DIR / f'{sid}.json'
if not p.exists(): return None
return cls(**json.loads(p.read_text(encoding='utf-8')))
def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id}
def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id, 'profile': self.profile}
def get_session(sid):
with LOCK:
@@ -63,7 +63,12 @@ def get_session(sid):
def new_session(workspace=None, model=None):
# Use _cfg.DEFAULT_MODEL (not the import-time snapshot) so save_settings() changes take effect
s = Session(workspace=workspace or get_last_workspace(), model=model or _cfg.DEFAULT_MODEL)
try:
from api.profiles import get_active_profile_name
_profile = get_active_profile_name()
except ImportError:
_profile = None
s = Session(workspace=workspace or get_last_workspace(), model=model or _cfg.DEFAULT_MODEL, profile=_profile)
with LOCK:
SESSIONS[s.session_id] = s
SESSIONS.move_to_end(s.session_id)

246
api/profiles.py Normal file
View File

@@ -0,0 +1,246 @@
"""
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 threading
from pathlib import Path
# ── Module state ────────────────────────────────────────────────────────────
_active_profile = 'default'
_profile_lock = threading.Lock()
_DEFAULT_HERMES_HOME = Path.home() / '.hermes'
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():
"""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 {'profiles': list_profiles_api(), 'active': name}
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 create_profile_api(name: str, clone_from: str = None,
clone_config: bool = False) -> dict:
"""Create a new profile. Returns the new profile info dict."""
try:
from hermes_cli.profiles import create_profile, validate_profile_name
except ImportError:
raise RuntimeError('Profile management requires hermes-agent to be installed.')
validate_profile_name(name)
create_profile(
name,
clone_from=clone_from,
clone_config=clone_config,
clone_all=False,
no_alias=True,
)
# Find and return the newly created profile info
for p in list_profiles_api():
if p['name'] == name:
return p
return {'name': name, 'path': str(_DEFAULT_HERMES_HOME / 'profiles' / name)}
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}

View File

@@ -234,6 +234,15 @@ def handle_get(handler, parsed):
if parsed.path == '/api/memory':
return _handle_memory_read(handler)
# ── Profile API (GET) ──
if parsed.path == '/api/profiles':
from api.profiles import list_profiles_api, get_active_profile_name
return j(handler, {'profiles': list_profiles_api(), 'active': get_active_profile_name()})
if parsed.path == '/api/profile/active':
from api.profiles import get_active_profile_name, get_active_hermes_home
return j(handler, {'name': get_active_profile_name(), 'path': str(get_active_hermes_home())})
return False # 404
@@ -372,6 +381,43 @@ def handle_post(handler, parsed):
if parsed.path == '/api/memory/write':
return _handle_memory_write(handler, body)
# ── Profile API (POST) ──
if parsed.path == '/api/profile/switch':
name = body.get('name', '').strip()
if not name: return bad(handler, 'name is required')
try:
from api.profiles import switch_profile
result = switch_profile(name)
return j(handler, result)
except (ValueError, FileNotFoundError) as e:
return bad(handler, str(e), 404)
except RuntimeError as e:
return bad(handler, str(e), 409)
if parsed.path == '/api/profile/create':
name = body.get('name', '').strip()
if not name: return bad(handler, 'name is required')
try:
from api.profiles import create_profile_api
result = create_profile_api(
name,
clone_from=body.get('clone_from'),
clone_config=bool(body.get('clone_config', False)),
)
return j(handler, {'ok': True, 'profile': result})
except (ValueError, FileExistsError, RuntimeError) as e:
return bad(handler, str(e))
if parsed.path == '/api/profile/delete':
name = body.get('name', '').strip()
if not name: return bad(handler, 'name is required')
try:
from api.profiles import delete_profile_api
result = delete_profile_api(name)
return j(handler, result)
except (ValueError, FileNotFoundError) as e:
return bad(handler, str(e))
# ── Settings (POST) ──
if parsed.path == '/api/settings':
saved = save_settings(body)
@@ -731,7 +777,11 @@ def _handle_cron_recent(handler, parsed):
def _handle_memory_read(handler):
mem_dir = Path.home() / '.hermes' / 'memories'
try:
from api.profiles import get_active_hermes_home
mem_dir = get_active_hermes_home() / 'memories'
except ImportError:
mem_dir = Path.home() / '.hermes' / 'memories'
mem_file = mem_dir / 'MEMORY.md'
user_file = mem_dir / 'USER.md'
memory = mem_file.read_text(encoding='utf-8', errors='replace') if mem_file.exists() else ''
@@ -1078,7 +1128,11 @@ def _handle_skill_delete(handler, body):
def _handle_memory_write(handler, body):
try: require(body, 'section', 'content')
except ValueError as e: return bad(handler, str(e))
mem_dir = Path.home() / '.hermes' / 'memories'
try:
from api.profiles import get_active_hermes_home
mem_dir = get_active_hermes_home() / 'memories'
except ImportError:
mem_dir = Path.home() / '.hermes' / 'memories'
mem_dir.mkdir(parents=True, exist_ok=True)
section = body['section']
if section == 'memory':

View File

@@ -64,19 +64,30 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
put('cancel', {'message': 'Cancelled before start'})
return
# Resolve profile home for this agent run (snapshot at start)
try:
from api.profiles import get_active_hermes_home
_profile_home = str(get_active_hermes_home())
except ImportError:
_profile_home = os.environ.get('HERMES_HOME', '')
_set_thread_env(
TERMINAL_CWD=str(s.workspace),
HERMES_EXEC_ASK='1',
HERMES_SESSION_KEY=session_id,
HERMES_HOME=_profile_home,
)
# Still set process-level env as fallback for tools that bypass thread-local
with _agent_lock:
old_cwd = os.environ.get('TERMINAL_CWD')
old_exec_ask = os.environ.get('HERMES_EXEC_ASK')
old_session_key = os.environ.get('HERMES_SESSION_KEY')
old_hermes_home = os.environ.get('HERMES_HOME')
os.environ['TERMINAL_CWD'] = str(s.workspace)
os.environ['HERMES_EXEC_ASK'] = '1'
os.environ['HERMES_SESSION_KEY'] = session_id
if _profile_home:
os.environ['HERMES_HOME'] = _profile_home
try:
def on_token(text):
@@ -187,6 +198,8 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask
if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None)
else: os.environ['HERMES_SESSION_KEY'] = old_session_key
if old_hermes_home is None: os.environ.pop('HERMES_HOME', None)
else: os.environ['HERMES_HOME'] = old_hermes_home
except Exception as e:
print('[webui] stream error:\n' + traceback.format_exc(), flush=True)