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

@@ -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':