Files
hermes-agent/tests/hermes_cli/test_profiles.py
Teknium f6db1b27ba feat: add profiles — run multiple isolated Hermes instances (#3681)
Each profile is a fully independent HERMES_HOME with its own config,
API keys, memory, sessions, skills, gateway, cron, and state.db.

Core module: hermes_cli/profiles.py (~900 lines)
  - Profile CRUD: create, delete, list, show, rename
  - Three clone levels: blank, --clone (config), --clone-all (everything)
  - Export/import: tar.gz archive for backup and migration
  - Wrapper alias scripts (~/.local/bin/<name>)
  - Collision detection for alias names
  - Sticky default via ~/.hermes/active_profile
  - Skill seeding via subprocess (handles module-level caching)
  - Auto-stop gateway on delete with disable-before-stop for services
  - Tab completion generation for bash and zsh

CLI integration (hermes_cli/main.py):
  - _apply_profile_override(): pre-import -p/--profile flag + sticky default
  - Full 'hermes profile' subcommand: list, use, create, delete, show,
    alias, rename, export, import
  - 'hermes completion bash/zsh' command
  - Multi-profile skill sync in hermes update

Display (cli.py, banner.py, gateway/run.py):
  - CLI prompt: 'coder ❯' when using a non-default profile
  - Banner shows profile name
  - Gateway startup log includes profile name

Gateway safety:
  - Token locks: Discord, Slack, WhatsApp, Signal (extends Telegram pattern)
  - Port conflict detection: API server, webhook adapter

Diagnostics (hermes_cli/doctor.py):
  - Profile health section: lists profiles, checks config, .env, aliases
  - Orphan alias detection: warns when wrapper points to deleted profile

Tests (tests/hermes_cli/test_profiles.py):
  - 71 automated tests covering: validation, CRUD, clone levels, rename,
    export/import, active profile, isolation, alias collision, completion
  - Full suite: 6760 passed, 0 new failures

Documentation:
  - website/docs/user-guide/profiles.md: full user guide (12 sections)
  - website/docs/reference/profile-commands.md: command reference (12 commands)
  - website/docs/reference/faq.md: 6 profile FAQ entries
  - website/sidebars.ts: navigation updated
2026-03-29 10:41:20 -07:00

623 lines
23 KiB
Python

"""Comprehensive tests for hermes_cli.profiles module.
Tests cover: validation, directory resolution, CRUD operations, active profile
management, export/import, renaming, alias collision checks, profile isolation,
and shell completion generation.
"""
import json
import os
import tarfile
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from hermes_cli.profiles import (
validate_profile_name,
get_profile_dir,
create_profile,
delete_profile,
list_profiles,
set_active_profile,
get_active_profile,
get_active_profile_name,
resolve_profile_env,
check_alias_collision,
rename_profile,
export_profile,
import_profile,
generate_bash_completion,
generate_zsh_completion,
_get_profiles_root,
_get_default_hermes_home,
)
# ---------------------------------------------------------------------------
# Shared fixture: redirect Path.home() and HERMES_HOME for profile tests
# ---------------------------------------------------------------------------
@pytest.fixture()
def profile_env(tmp_path, monkeypatch):
"""Set up an isolated environment for profile tests.
* Path.home() -> tmp_path (so _get_profiles_root() = tmp_path/.hermes/profiles)
* HERMES_HOME -> tmp_path/.hermes (so get_hermes_home() agrees)
* Creates the bare-minimum ~/.hermes directory.
"""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
default_home = tmp_path / ".hermes"
default_home.mkdir(exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(default_home))
return tmp_path
# ===================================================================
# TestValidateProfileName
# ===================================================================
class TestValidateProfileName:
"""Tests for validate_profile_name()."""
@pytest.mark.parametrize("name", ["coder", "work-bot", "a1", "my_agent"])
def test_valid_names_accepted(self, name):
# Should not raise
validate_profile_name(name)
@pytest.mark.parametrize("name", ["UPPER", "has space", ".hidden", "-leading"])
def test_invalid_names_rejected(self, name):
with pytest.raises(ValueError):
validate_profile_name(name)
def test_too_long_rejected(self):
long_name = "a" * 65
with pytest.raises(ValueError):
validate_profile_name(long_name)
def test_max_length_accepted(self):
# 64 chars total: 1 leading + 63 remaining = 64, within [0,63] range
name = "a" * 64
validate_profile_name(name)
def test_default_accepted(self):
# 'default' is a special-case pass-through
validate_profile_name("default")
def test_empty_string_rejected(self):
with pytest.raises(ValueError):
validate_profile_name("")
# ===================================================================
# TestGetProfileDir
# ===================================================================
class TestGetProfileDir:
"""Tests for get_profile_dir()."""
def test_default_returns_hermes_home(self, profile_env):
tmp_path = profile_env
result = get_profile_dir("default")
assert result == tmp_path / ".hermes"
def test_named_profile_returns_profiles_subdir(self, profile_env):
tmp_path = profile_env
result = get_profile_dir("coder")
assert result == tmp_path / ".hermes" / "profiles" / "coder"
# ===================================================================
# TestCreateProfile
# ===================================================================
class TestCreateProfile:
"""Tests for create_profile()."""
def test_creates_directory_with_subdirs(self, profile_env):
profile_dir = create_profile("coder", no_alias=True)
assert profile_dir.is_dir()
for subdir in ["memories", "sessions", "skills", "skins", "logs",
"plans", "workspace", "cron"]:
assert (profile_dir / subdir).is_dir(), f"Missing subdir: {subdir}"
def test_duplicate_raises_file_exists(self, profile_env):
create_profile("coder", no_alias=True)
with pytest.raises(FileExistsError):
create_profile("coder", no_alias=True)
def test_default_raises_value_error(self, profile_env):
with pytest.raises(ValueError, match="default"):
create_profile("default", no_alias=True)
def test_invalid_name_raises_value_error(self, profile_env):
with pytest.raises(ValueError):
create_profile("INVALID!", no_alias=True)
def test_clone_config_copies_files(self, profile_env):
tmp_path = profile_env
default_home = tmp_path / ".hermes"
# Create source config files in default profile
(default_home / "config.yaml").write_text("model: test")
(default_home / ".env").write_text("KEY=val")
(default_home / "SOUL.md").write_text("Be helpful.")
profile_dir = create_profile("coder", clone_config=True, no_alias=True)
assert (profile_dir / "config.yaml").read_text() == "model: test"
assert (profile_dir / ".env").read_text() == "KEY=val"
assert (profile_dir / "SOUL.md").read_text() == "Be helpful."
def test_clone_all_copies_entire_tree(self, profile_env):
tmp_path = profile_env
default_home = tmp_path / ".hermes"
# Populate default with some content
(default_home / "memories").mkdir(exist_ok=True)
(default_home / "memories" / "note.md").write_text("remember this")
(default_home / "config.yaml").write_text("model: gpt-4")
# Runtime files that should be stripped
(default_home / "gateway.pid").write_text("12345")
(default_home / "gateway_state.json").write_text("{}")
(default_home / "processes.json").write_text("[]")
profile_dir = create_profile("coder", clone_all=True, no_alias=True)
# Content should be copied
assert (profile_dir / "memories" / "note.md").read_text() == "remember this"
assert (profile_dir / "config.yaml").read_text() == "model: gpt-4"
# Runtime files should be stripped
assert not (profile_dir / "gateway.pid").exists()
assert not (profile_dir / "gateway_state.json").exists()
assert not (profile_dir / "processes.json").exists()
def test_clone_config_missing_files_skipped(self, profile_env):
"""Clone config gracefully skips files that don't exist in source."""
profile_dir = create_profile("coder", clone_config=True, no_alias=True)
# No error; optional files just not copied
assert not (profile_dir / "config.yaml").exists()
assert not (profile_dir / ".env").exists()
assert not (profile_dir / "SOUL.md").exists()
# ===================================================================
# TestDeleteProfile
# ===================================================================
class TestDeleteProfile:
"""Tests for delete_profile()."""
def test_removes_directory(self, profile_env):
profile_dir = create_profile("coder", no_alias=True)
assert profile_dir.is_dir()
# Mock gateway import to avoid real systemd/launchd interaction
with patch("hermes_cli.profiles._cleanup_gateway_service"):
delete_profile("coder", yes=True)
assert not profile_dir.is_dir()
def test_default_raises_value_error(self, profile_env):
with pytest.raises(ValueError, match="default"):
delete_profile("default", yes=True)
def test_nonexistent_raises_file_not_found(self, profile_env):
with pytest.raises(FileNotFoundError):
delete_profile("nonexistent", yes=True)
# ===================================================================
# TestListProfiles
# ===================================================================
class TestListProfiles:
"""Tests for list_profiles()."""
def test_returns_default_when_no_named_profiles(self, profile_env):
profiles = list_profiles()
names = [p.name for p in profiles]
assert "default" in names
def test_includes_named_profiles(self, profile_env):
create_profile("alpha", no_alias=True)
create_profile("beta", no_alias=True)
profiles = list_profiles()
names = [p.name for p in profiles]
assert "alpha" in names
assert "beta" in names
def test_sorted_alphabetically(self, profile_env):
create_profile("zebra", no_alias=True)
create_profile("alpha", no_alias=True)
create_profile("middle", no_alias=True)
profiles = list_profiles()
named = [p.name for p in profiles if not p.is_default]
assert named == sorted(named)
def test_default_is_first(self, profile_env):
create_profile("alpha", no_alias=True)
profiles = list_profiles()
assert profiles[0].name == "default"
assert profiles[0].is_default is True
# ===================================================================
# TestActiveProfile
# ===================================================================
class TestActiveProfile:
"""Tests for set_active_profile() / get_active_profile()."""
def test_set_and_get_roundtrip(self, profile_env):
create_profile("coder", no_alias=True)
set_active_profile("coder")
assert get_active_profile() == "coder"
def test_no_file_returns_default(self, profile_env):
assert get_active_profile() == "default"
def test_empty_file_returns_default(self, profile_env):
tmp_path = profile_env
active_path = tmp_path / ".hermes" / "active_profile"
active_path.write_text("")
assert get_active_profile() == "default"
def test_set_to_default_removes_file(self, profile_env):
tmp_path = profile_env
create_profile("coder", no_alias=True)
set_active_profile("coder")
active_path = tmp_path / ".hermes" / "active_profile"
assert active_path.exists()
set_active_profile("default")
assert not active_path.exists()
def test_set_nonexistent_raises(self, profile_env):
with pytest.raises(FileNotFoundError):
set_active_profile("nonexistent")
# ===================================================================
# TestGetActiveProfileName
# ===================================================================
class TestGetActiveProfileName:
"""Tests for get_active_profile_name()."""
def test_default_hermes_home_returns_default(self, profile_env):
# HERMES_HOME points to tmp_path/.hermes which is the default
assert get_active_profile_name() == "default"
def test_profile_path_returns_profile_name(self, profile_env, monkeypatch):
tmp_path = profile_env
create_profile("coder", no_alias=True)
profile_dir = tmp_path / ".hermes" / "profiles" / "coder"
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
assert get_active_profile_name() == "coder"
def test_custom_path_returns_custom(self, profile_env, monkeypatch):
tmp_path = profile_env
custom = tmp_path / "some" / "other" / "path"
custom.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(custom))
assert get_active_profile_name() == "custom"
# ===================================================================
# TestResolveProfileEnv
# ===================================================================
class TestResolveProfileEnv:
"""Tests for resolve_profile_env()."""
def test_existing_profile_returns_path(self, profile_env):
tmp_path = profile_env
create_profile("coder", no_alias=True)
result = resolve_profile_env("coder")
assert result == str(tmp_path / ".hermes" / "profiles" / "coder")
def test_default_returns_default_home(self, profile_env):
tmp_path = profile_env
result = resolve_profile_env("default")
assert result == str(tmp_path / ".hermes")
def test_nonexistent_raises_file_not_found(self, profile_env):
with pytest.raises(FileNotFoundError):
resolve_profile_env("nonexistent")
def test_invalid_name_raises_value_error(self, profile_env):
with pytest.raises(ValueError):
resolve_profile_env("INVALID!")
# ===================================================================
# TestAliasCollision
# ===================================================================
class TestAliasCollision:
"""Tests for check_alias_collision()."""
def test_normal_name_returns_none(self, profile_env):
# Mock 'which' to return not-found
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="")
result = check_alias_collision("mybot")
assert result is None
def test_reserved_name_returns_message(self, profile_env):
result = check_alias_collision("hermes")
assert result is not None
assert "reserved" in result.lower()
def test_subcommand_returns_message(self, profile_env):
result = check_alias_collision("chat")
assert result is not None
assert "subcommand" in result.lower()
def test_default_is_reserved(self, profile_env):
result = check_alias_collision("default")
assert result is not None
assert "reserved" in result.lower()
# ===================================================================
# TestRenameProfile
# ===================================================================
class TestRenameProfile:
"""Tests for rename_profile()."""
def test_renames_directory(self, profile_env):
tmp_path = profile_env
create_profile("oldname", no_alias=True)
old_dir = tmp_path / ".hermes" / "profiles" / "oldname"
assert old_dir.is_dir()
# Mock alias collision to avoid subprocess calls
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"):
new_dir = rename_profile("oldname", "newname")
assert not old_dir.is_dir()
assert new_dir.is_dir()
assert new_dir == tmp_path / ".hermes" / "profiles" / "newname"
def test_default_raises_value_error(self, profile_env):
with pytest.raises(ValueError, match="default"):
rename_profile("default", "newname")
def test_rename_to_default_raises_value_error(self, profile_env):
create_profile("coder", no_alias=True)
with pytest.raises(ValueError, match="default"):
rename_profile("coder", "default")
def test_nonexistent_raises_file_not_found(self, profile_env):
with pytest.raises(FileNotFoundError):
rename_profile("nonexistent", "newname")
def test_target_exists_raises_file_exists(self, profile_env):
create_profile("alpha", no_alias=True)
create_profile("beta", no_alias=True)
with pytest.raises(FileExistsError):
rename_profile("alpha", "beta")
# ===================================================================
# TestExportImport
# ===================================================================
class TestExportImport:
"""Tests for export_profile() / import_profile()."""
def test_export_creates_tar_gz(self, profile_env, tmp_path):
create_profile("coder", no_alias=True)
# Put a marker file so we can verify content
profile_dir = get_profile_dir("coder")
(profile_dir / "marker.txt").write_text("hello")
output = tmp_path / "export" / "coder.tar.gz"
output.parent.mkdir(parents=True, exist_ok=True)
result = export_profile("coder", str(output))
assert Path(result).exists()
assert tarfile.is_tarfile(str(result))
def test_import_restores_from_archive(self, profile_env, tmp_path):
# Create and export a profile
create_profile("coder", no_alias=True)
profile_dir = get_profile_dir("coder")
(profile_dir / "marker.txt").write_text("hello")
archive_path = tmp_path / "export" / "coder.tar.gz"
archive_path.parent.mkdir(parents=True, exist_ok=True)
export_profile("coder", str(archive_path))
# Delete the profile, then import it back under a new name
import shutil
shutil.rmtree(profile_dir)
assert not profile_dir.is_dir()
imported = import_profile(str(archive_path), name="coder")
assert imported.is_dir()
assert (imported / "marker.txt").read_text() == "hello"
def test_import_to_existing_name_raises(self, profile_env, tmp_path):
create_profile("coder", no_alias=True)
profile_dir = get_profile_dir("coder")
archive_path = tmp_path / "export" / "coder.tar.gz"
archive_path.parent.mkdir(parents=True, exist_ok=True)
export_profile("coder", str(archive_path))
# Importing to same existing name should fail
with pytest.raises(FileExistsError):
import_profile(str(archive_path), name="coder")
def test_export_nonexistent_raises(self, profile_env, tmp_path):
with pytest.raises(FileNotFoundError):
export_profile("nonexistent", str(tmp_path / "out.tar.gz"))
# ===================================================================
# TestProfileIsolation
# ===================================================================
class TestProfileIsolation:
"""Verify that two profiles have completely separate paths."""
def test_separate_config_paths(self, profile_env):
create_profile("alpha", no_alias=True)
create_profile("beta", no_alias=True)
alpha_dir = get_profile_dir("alpha")
beta_dir = get_profile_dir("beta")
assert alpha_dir / "config.yaml" != beta_dir / "config.yaml"
assert str(alpha_dir) not in str(beta_dir)
def test_separate_state_db_paths(self, profile_env):
alpha_dir = get_profile_dir("alpha")
beta_dir = get_profile_dir("beta")
assert alpha_dir / "state.db" != beta_dir / "state.db"
def test_separate_skills_paths(self, profile_env):
create_profile("alpha", no_alias=True)
create_profile("beta", no_alias=True)
alpha_dir = get_profile_dir("alpha")
beta_dir = get_profile_dir("beta")
assert alpha_dir / "skills" != beta_dir / "skills"
# Verify both exist and are independent dirs
assert (alpha_dir / "skills").is_dir()
assert (beta_dir / "skills").is_dir()
# ===================================================================
# TestCompletion
# ===================================================================
class TestCompletion:
"""Tests for bash/zsh completion generators."""
def test_bash_completion_contains_complete(self):
script = generate_bash_completion()
assert len(script) > 0
assert "complete" in script
def test_zsh_completion_contains_compdef(self):
script = generate_zsh_completion()
assert len(script) > 0
assert "compdef" in script
def test_bash_completion_has_hermes_profiles_function(self):
script = generate_bash_completion()
assert "_hermes_profiles" in script
def test_zsh_completion_has_hermes_function(self):
script = generate_zsh_completion()
assert "_hermes" in script
# ===================================================================
# TestGetProfilesRoot / TestGetDefaultHermesHome (internal helpers)
# ===================================================================
class TestInternalHelpers:
"""Tests for _get_profiles_root() and _get_default_hermes_home()."""
def test_profiles_root_under_home(self, profile_env):
tmp_path = profile_env
root = _get_profiles_root()
assert root == tmp_path / ".hermes" / "profiles"
def test_default_hermes_home(self, profile_env):
tmp_path = profile_env
home = _get_default_hermes_home()
assert home == tmp_path / ".hermes"
# ===================================================================
# Edge cases and additional coverage
# ===================================================================
class TestEdgeCases:
"""Additional edge-case tests."""
def test_create_profile_returns_correct_path(self, profile_env):
tmp_path = profile_env
result = create_profile("mybot", no_alias=True)
expected = tmp_path / ".hermes" / "profiles" / "mybot"
assert result == expected
def test_list_profiles_default_info_fields(self, profile_env):
profiles = list_profiles()
default = [p for p in profiles if p.name == "default"][0]
assert default.is_default is True
assert default.gateway_running is False
assert default.skill_count == 0
def test_gateway_running_check_with_pid_file(self, profile_env):
"""Verify _check_gateway_running reads pid file and probes os.kill."""
from hermes_cli.profiles import _check_gateway_running
tmp_path = profile_env
default_home = tmp_path / ".hermes"
# No pid file -> not running
assert _check_gateway_running(default_home) is False
# Write a PID file with a JSON payload
pid_file = default_home / "gateway.pid"
pid_file.write_text(json.dumps({"pid": 99999}))
# os.kill(99999, 0) should raise ProcessLookupError -> not running
assert _check_gateway_running(default_home) is False
# Mock os.kill to simulate a running process
with patch("os.kill", return_value=None):
assert _check_gateway_running(default_home) is True
def test_gateway_running_check_plain_pid(self, profile_env):
"""Pid file containing just a number (legacy format)."""
from hermes_cli.profiles import _check_gateway_running
tmp_path = profile_env
default_home = tmp_path / ".hermes"
pid_file = default_home / "gateway.pid"
pid_file.write_text("99999")
with patch("os.kill", return_value=None):
assert _check_gateway_running(default_home) is True
def test_profile_name_boundary_single_char(self):
"""Single alphanumeric character is valid."""
validate_profile_name("a")
validate_profile_name("1")
def test_profile_name_boundary_all_hyphens(self):
"""Name starting with hyphen is invalid."""
with pytest.raises(ValueError):
validate_profile_name("-abc")
def test_profile_name_underscore_start(self):
"""Name starting with underscore is invalid (must start with [a-z0-9])."""
with pytest.raises(ValueError):
validate_profile_name("_abc")
def test_clone_from_named_profile(self, profile_env):
"""Clone config from a named (non-default) profile."""
tmp_path = profile_env
# Create source profile with config
source_dir = create_profile("source", no_alias=True)
(source_dir / "config.yaml").write_text("model: cloned")
(source_dir / ".env").write_text("SECRET=yes")
target_dir = create_profile(
"target", clone_from="source", clone_config=True, no_alias=True,
)
assert (target_dir / "config.yaml").read_text() == "model: cloned"
assert (target_dir / ".env").read_text() == "SECRET=yes"
def test_delete_clears_active_profile(self, profile_env):
"""Deleting the active profile resets active to default."""
tmp_path = profile_env
create_profile("coder", no_alias=True)
set_active_profile("coder")
assert get_active_profile() == "coder"
with patch("hermes_cli.profiles._cleanup_gateway_service"):
delete_profile("coder", yes=True)
assert get_active_profile() == "default"