diff --git a/AGENTS.md b/AGENTS.md index 19c6f2797..8045c3d21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,6 +210,10 @@ registry.register( The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string. +**Path references in tool schemas**: If the schema description mentions file paths (e.g. default output directories), use `display_hermes_home()` to make them profile-aware. The schema is generated at import time, which is after `_apply_profile_override()` sets `HERMES_HOME`. + +**State files**: If a tool stores persistent state (caches, logs, checkpoints), use `get_hermes_home()` for the base directory — never `Path.home() / ".hermes"`. This ensures each profile gets its own state. + **Agent-level tools** (todo, memory): intercepted by `run_agent.py` before `handle_function_call()`. See `todo_tool.py` for the pattern. --- @@ -358,8 +362,69 @@ in config.yaml (or `HERMES_BACKGROUND_NOTIFICATIONS` env var): --- +## Profiles: Multi-Instance Support + +Hermes supports **profiles** — multiple fully isolated instances, each with its own +`HERMES_HOME` directory (config, API keys, memory, sessions, skills, gateway, etc.). + +The core mechanism: `_apply_profile_override()` in `hermes_cli/main.py` sets +`HERMES_HOME` before any module imports. All 119+ references to `get_hermes_home()` +automatically scope to the active profile. + +### Rules for profile-safe code + +1. **Use `get_hermes_home()` for all HERMES_HOME paths.** Import from `hermes_constants`. + NEVER hardcode `~/.hermes` or `Path.home() / ".hermes"` in code that reads/writes state. + ```python + # GOOD + from hermes_constants import get_hermes_home + config_path = get_hermes_home() / "config.yaml" + + # BAD — breaks profiles + config_path = Path.home() / ".hermes" / "config.yaml" + ``` + +2. **Use `display_hermes_home()` for user-facing messages.** Import from `hermes_constants`. + This returns `~/.hermes` for default or `~/.hermes/profiles/` for profiles. + ```python + # GOOD + from hermes_constants import display_hermes_home + print(f"Config saved to {display_hermes_home()}/config.yaml") + + # BAD — shows wrong path for profiles + print("Config saved to ~/.hermes/config.yaml") + ``` + +3. **Module-level constants are fine** — they cache `get_hermes_home()` at import time, + which is AFTER `_apply_profile_override()` sets the env var. Just use `get_hermes_home()`, + not `Path.home() / ".hermes"`. + +4. **Tests that mock `Path.home()` must also set `HERMES_HOME`** — since code now uses + `get_hermes_home()` (reads env var), not `Path.home() / ".hermes"`: + ```python + with patch.object(Path, "home", return_value=tmp_path), \ + patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}): + ... + ``` + +5. **Gateway platform adapters should use token locks** — if the adapter connects with + a unique credential (bot token, API key), call `acquire_scoped_lock()` from + `gateway.status` in the `connect()`/`start()` method and `release_scoped_lock()` in + `disconnect()`/`stop()`. This prevents two profiles from using the same credential. + See `gateway/platforms/telegram.py` for the canonical pattern. + +6. **Profile operations are HOME-anchored, not HERMES_HOME-anchored** — `_get_profiles_root()` + returns `Path.home() / ".hermes" / "profiles"`, NOT `get_hermes_home() / "profiles"`. + This is intentional — it lets `hermes -p coder profile list` see all profiles regardless + of which one is active. + ## Known Pitfalls +### DO NOT hardcode `~/.hermes` paths +Use `get_hermes_home()` from `hermes_constants` for code paths. Use `display_hermes_home()` +for user-facing print/log messages. Hardcoding `~/.hermes` breaks profiles — each profile +has its own `HERMES_HOME` directory. This was the source of 5 bugs fixed in PR #3575. + ### DO NOT use `simple_term_menu` for interactive menus Rendering bugs in tmux/iTerm2 — ghosting on scroll. Use `curses` (stdlib) instead. See `hermes_cli/tools_config.py` for the pattern. @@ -375,6 +440,19 @@ Tool schema descriptions must not mention tools from other toolsets by name (e.g ### Tests must not write to `~/.hermes/` The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Never hardcode `~/.hermes/` paths in tests. +**Profile tests**: When testing profile features, also mock `Path.home()` so that +`_get_profiles_root()` and `_get_default_hermes_home()` resolve within the temp dir. +Use the pattern from `tests/hermes_cli/test_profiles.py`: +```python +@pytest.fixture +def profile_env(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + return home +``` + --- ## Testing diff --git a/cli.py b/cli.py index bb678e8fd..f642894ae 100644 --- a/cli.py +++ b/cli.py @@ -5944,6 +5944,9 @@ class HermesCLI: ``normal_prompt`` is the full ``branding.prompt_symbol``. ``state_suffix`` is what special states (sudo/secret/approval/agent) should render after their leading icon. + + When a profile is active (not "default"), the profile name is + prepended to the prompt symbol: ``coder ❯`` instead of ``❯``. """ try: from hermes_cli.skin_engine import get_active_prompt_symbol @@ -5952,6 +5955,15 @@ class HermesCLI: symbol = "❯ " symbol = (symbol or "❯ ").rstrip() + " " + + # Prepend profile name when not default + try: + from hermes_cli.profiles import get_active_profile_name + profile = get_active_profile_name() + if profile not in ("default", "custom"): + symbol = f"{profile} {symbol}" + except Exception: + pass stripped = symbol.rstrip() if not stripped: return "❯ ", "❯ " diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 7d8d81171..19fa5f60d 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1261,6 +1261,17 @@ class APIServerAdapter(BasePlatformAdapter): self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job) self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job) + # Port conflict detection — fail fast if port is already in use + import socket as _socket + try: + with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s: + _s.settimeout(1) + _s.connect(('127.0.0.1', self._port)) + logger.error('[%s] Port %d already in use. Set a different port in config.yaml: platforms.api_server.port', self.name, self._port) + return False + except (ConnectionRefusedError, OSError): + pass # port is free + self._runner = web.AppRunner(self._app) await self._runner.setup() self._site = web.TCPSite(self._runner, self._host, self._port) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 2060f344f..18e93ce69 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -486,6 +486,16 @@ class DiscordAdapter(BasePlatformAdapter): return False try: + # Acquire scoped lock to prevent duplicate bot token usage + from gateway.status import acquire_scoped_lock + acquired, existing = acquire_scoped_lock('discord-bot-token', self.config.token, metadata={'platform': 'discord'}) + if not acquired: + owner_pid = existing.get('pid') if isinstance(existing, dict) else None + message = f'Discord bot token already in use' + (f' (PID {owner_pid})' if owner_pid else '') + '. Stop the other gateway first.' + logger.error('[%s] %s', self.name, message) + self._set_fatal_error('discord_token_lock', message, retryable=False) + return False + # Set up intents -- members intent needed for username-to-ID resolution intents = Intents.default() intents.message_content = True @@ -638,6 +648,14 @@ class DiscordAdapter(BasePlatformAdapter): self._running = False self._client = None self._ready_event.clear() + + # Release the token lock + try: + from gateway.status import release_scoped_lock + release_scoped_lock('discord-bot-token', self.config.token) + except Exception: + pass + logger.info("[%s] Disconnected", self.name) async def send( diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 95f605c0b..1629e0863 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -184,6 +184,8 @@ class SignalAdapter(BasePlatformAdapter): self._recent_sent_timestamps: set = set() self._max_recent_timestamps = 50 + self._phone_lock_identity: Optional[str] = None + logger.info("Signal adapter initialized: url=%s account=%s groups=%s", self.http_url, _redact_phone(self.account), "enabled" if self.group_allow_from else "disabled") @@ -198,6 +200,29 @@ class SignalAdapter(BasePlatformAdapter): logger.error("Signal: SIGNAL_HTTP_URL and SIGNAL_ACCOUNT are required") return False + # Acquire scoped lock to prevent duplicate Signal listeners for the same phone + try: + from gateway.status import acquire_scoped_lock + + self._phone_lock_identity = self.account + acquired, existing = acquire_scoped_lock( + "signal-phone", + self._phone_lock_identity, + metadata={"platform": self.platform.value}, + ) + if not acquired: + owner_pid = existing.get("pid") if isinstance(existing, dict) else None + message = ( + "Another local Hermes gateway is already using this Signal account" + + (f" (PID {owner_pid})." if owner_pid else ".") + + " Stop the other gateway before starting a second Signal listener." + ) + logger.error("Signal: %s", message) + self._set_fatal_error("signal_phone_lock", message, retryable=False) + return False + except Exception as e: + logger.warning("Signal: Could not acquire phone lock (non-fatal): %s", e) + self.client = httpx.AsyncClient(timeout=30.0) # Health check — verify signal-cli daemon is reachable @@ -245,6 +270,14 @@ class SignalAdapter(BasePlatformAdapter): await self.client.aclose() self.client = None + if self._phone_lock_identity: + try: + from gateway.status import release_scoped_lock + release_scoped_lock("signal-phone", self._phone_lock_identity) + except Exception as e: + logger.warning("Signal: Error releasing phone lock: %s", e, exc_info=True) + self._phone_lock_identity = None + logger.info("Signal: disconnected") # ------------------------------------------------------------------ diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 3fae98ae6..35a6145d9 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -93,6 +93,16 @@ class SlackAdapter(BasePlatformAdapter): return False try: + # Acquire scoped lock to prevent duplicate app token usage + from gateway.status import acquire_scoped_lock + acquired, existing = acquire_scoped_lock('slack-app-token', app_token, metadata={'platform': 'slack'}) + if not acquired: + owner_pid = existing.get('pid') if isinstance(existing, dict) else None + message = f'Slack app token already in use' + (f' (PID {owner_pid})' if owner_pid else '') + '. Stop the other gateway first.' + logger.error('[%s] %s', self.name, message) + self._set_fatal_error('slack_token_lock', message, retryable=False) + return False + self._app = AsyncApp(token=bot_token) # Get our own bot user ID for mention detection @@ -138,6 +148,16 @@ class SlackAdapter(BasePlatformAdapter): except Exception as e: # pragma: no cover - defensive logging logger.warning("[Slack] Error while closing Socket Mode handler: %s", e, exc_info=True) self._running = False + + # Release the token lock + try: + from gateway.status import release_scoped_lock + app_token = os.getenv("SLACK_APP_TOKEN") + if app_token: + release_scoped_lock('slack-app-token', app_token) + except Exception: + pass + logger.info("[Slack] Disconnected") async def send( diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 841d61607..5f7c78cfa 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -118,6 +118,17 @@ class WebhookAdapter(BasePlatformAdapter): app.router.add_get("/health", self._handle_health) app.router.add_post("/webhooks/{route_name}", self._handle_webhook) + # Port conflict detection — fail fast if port is already in use + import socket as _socket + try: + with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s: + _s.settimeout(1) + _s.connect(('127.0.0.1', self._port)) + logger.error('[webhook] Port %d already in use. Set a different port in config.yaml: platforms.webhook.port', self._port) + return False + except (ConnectionRefusedError, OSError): + pass # port is free + self._runner = web.AppRunner(app) await self._runner.setup() site = web.TCPSite(self._runner, self._host, self._port) diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 9337f9587..d9ac0077c 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -142,6 +142,7 @@ class WhatsAppAdapter(BasePlatformAdapter): self._bridge_log_fh = None self._bridge_log: Optional[Path] = None self._poll_task: Optional[asyncio.Task] = None + self._session_lock_identity: Optional[str] = None async def connect(self) -> bool: """ @@ -160,6 +161,29 @@ class WhatsAppAdapter(BasePlatformAdapter): logger.info("[%s] Bridge found at %s", self.name, bridge_path) + # Acquire scoped lock to prevent duplicate sessions + try: + from gateway.status import acquire_scoped_lock + + self._session_lock_identity = str(self._session_path) + acquired, existing = acquire_scoped_lock( + "whatsapp-session", + self._session_lock_identity, + metadata={"platform": self.platform.value}, + ) + if not acquired: + owner_pid = existing.get("pid") if isinstance(existing, dict) else None + message = ( + "Another local Hermes gateway is already using this WhatsApp session" + + (f" (PID {owner_pid})." if owner_pid else ".") + + " Stop the other gateway before starting a second WhatsApp bridge." + ) + logger.error("[%s] %s", self.name, message) + self._set_fatal_error("whatsapp_session_lock", message, retryable=False) + return False + except Exception as e: + logger.warning("[%s] Could not acquire session lock (non-fatal): %s", self.name, e) + # Auto-install npm dependencies if node_modules doesn't exist bridge_dir = bridge_path.parent if not (bridge_dir / "node_modules").exists(): @@ -313,6 +337,12 @@ class WhatsAppAdapter(BasePlatformAdapter): return True except Exception as e: + if self._session_lock_identity: + try: + from gateway.status import release_scoped_lock + release_scoped_lock("whatsapp-session", self._session_lock_identity) + except Exception: + pass logger.error("[%s] Failed to start bridge: %s", self.name, e, exc_info=True) self._close_bridge_log() return False @@ -371,9 +401,17 @@ class WhatsAppAdapter(BasePlatformAdapter): # Bridge was not started by us, don't kill it print(f"[{self.name}] Disconnecting (external bridge left running)") + if self._session_lock_identity: + try: + from gateway.status import release_scoped_lock + release_scoped_lock("whatsapp-session", self._session_lock_identity) + except Exception as e: + logger.warning("[%s] Error releasing WhatsApp session lock: %s", self.name, e, exc_info=True) + self._mark_disconnected() self._bridge_process = None self._close_bridge_log() + self._session_lock_identity = None print(f"[{self.name}] Disconnected") async def send( diff --git a/gateway/run.py b/gateway/run.py index 1eda6e3c7..e18102a29 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -959,6 +959,13 @@ class GatewayRunner: """ logger.info("Starting Hermes Gateway...") logger.info("Session storage: %s", self.config.sessions_dir) + try: + from hermes_cli.profiles import get_active_profile_name + _profile = get_active_profile_name() + if _profile and _profile != "default": + logger.info("Active profile: %s", _profile) + except Exception: + pass try: from gateway.status import write_runtime_status write_runtime_status(gateway_state="starting", exit_reason=None) diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index c4eb827e2..fafce906e 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -403,6 +403,15 @@ def build_welcome_banner(console: Console, model: str, cwd: str, if mcp_connected: summary_parts.append(f"{mcp_connected} MCP servers") summary_parts.append("/help for commands") + # Show active profile name when not 'default' + try: + from hermes_cli.profiles import get_active_profile_name + _profile_name = get_active_profile_name() + if _profile_name and _profile_name != "default": + right_lines.append(f"[bold {accent}]Profile:[/] [{text}]{_profile_name}[/]") + except Exception: + pass # Never break the banner over a profiles.py bug + right_lines.append(f"[dim {dim}]{' · '.join(summary_parts)}[/]") # Update check — use prefetched result if available diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index c5bb179a5..a0a841905 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -730,6 +730,53 @@ def run_doctor(args): except Exception as _e: check_warn("Honcho check failed", str(_e)) + # ========================================================================= + # Profiles + # ========================================================================= + try: + from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists + import re as _re + + named_profiles = [p for p in list_profiles() if not p.is_default] + if named_profiles: + print() + print(color("◆ Profiles", Colors.CYAN, Colors.BOLD)) + check_ok(f"{len(named_profiles)} profile(s) found") + wrapper_dir = _get_wrapper_dir() + for p in named_profiles: + parts = [] + if p.gateway_running: + parts.append("gateway running") + if p.model: + parts.append(p.model[:30]) + if not (p.path / "config.yaml").exists(): + parts.append("⚠ missing config") + if not (p.path / ".env").exists(): + parts.append("no .env") + wrapper = wrapper_dir / p.name + if not wrapper.exists(): + parts.append("no alias") + status = ", ".join(parts) if parts else "configured" + check_ok(f" {p.name}: {status}") + + # Check for orphan wrappers + if wrapper_dir.is_dir(): + for wrapper in wrapper_dir.iterdir(): + if not wrapper.is_file(): + continue + try: + content = wrapper.read_text() + if "hermes -p" in content: + _m = _re.search(r"hermes -p (\S+)", content) + if _m and not profile_exists(_m.group(1)): + check_warn(f"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists") + except Exception: + pass + except ImportError: + pass + except Exception as _e: + logger.debug("Profile health check failed: %s", _e) + # ========================================================================= # Summary # ========================================================================= diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e27344bb3..93dceeec8 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -54,6 +54,71 @@ from typing import Optional PROJECT_ROOT = Path(__file__).parent.parent.resolve() sys.path.insert(0, str(PROJECT_ROOT)) +# --------------------------------------------------------------------------- +# Profile override — MUST happen before any hermes module import. +# +# Many modules cache HERMES_HOME at import time (module-level constants). +# We intercept --profile/-p from sys.argv here and set the env var so that +# every subsequent ``os.getenv("HERMES_HOME", ...)`` resolves correctly. +# The flag is stripped from sys.argv so argparse never sees it. +# Falls back to ~/.hermes/active_profile for sticky default. +# --------------------------------------------------------------------------- +def _apply_profile_override() -> None: + """Pre-parse --profile/-p and set HERMES_HOME before module imports.""" + argv = sys.argv[1:] + profile_name = None + consume = 0 + + # 1. Check for explicit -p / --profile flag + for i, arg in enumerate(argv): + if arg in ("--profile", "-p") and i + 1 < len(argv): + profile_name = argv[i + 1] + consume = 2 + break + elif arg.startswith("--profile="): + profile_name = arg.split("=", 1)[1] + consume = 1 + break + + # 2. If no flag, check ~/.hermes/active_profile + if profile_name is None: + try: + active_path = Path.home() / ".hermes" / "active_profile" + if active_path.exists(): + name = active_path.read_text().strip() + if name and name != "default": + profile_name = name + consume = 0 # don't strip anything from argv + except (UnicodeDecodeError, OSError): + pass # corrupted file, skip + + # 3. If we found a profile, resolve and set HERMES_HOME + if profile_name is not None: + try: + from hermes_cli.profiles import resolve_profile_env + hermes_home = resolve_profile_env(profile_name) + except (ValueError, FileNotFoundError) as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + except Exception as exc: + # A bug in profiles.py must NEVER prevent hermes from starting + print(f"Warning: profile override failed ({exc}), using default", file=sys.stderr) + return + os.environ["HERMES_HOME"] = hermes_home + # Strip the flag from argv so argparse doesn't choke + if consume > 0: + for i, arg in enumerate(argv): + if arg in ("--profile", "-p"): + start = i + 1 # +1 because argv is sys.argv[1:] + sys.argv = sys.argv[:start] + sys.argv[start + consume:] + break + elif arg.startswith("--profile="): + start = i + 1 + sys.argv = sys.argv[:start] + sys.argv[start + 1:] + break + +_apply_profile_override() + # Load .env from ~/.hermes/.env first, then project root as dev fallback. # User-managed env files should override stale shell exports on restart. from hermes_cli.config import get_hermes_home @@ -2924,7 +2989,35 @@ def cmd_update(args): print(" ✓ Skills are up to date") except Exception as e: logger.debug("Skills sync during update failed: %s", e) - + + # Sync bundled skills to all other profiles + try: + from hermes_cli.profiles import list_profiles, get_active_profile_name, seed_profile_skills + active = get_active_profile_name() + other_profiles = [p for p in list_profiles() if not p.is_default and p.name != active] + if other_profiles: + print() + print("→ Syncing bundled skills to other profiles...") + for p in other_profiles: + try: + r = seed_profile_skills(p.path, quiet=True) + if r: + copied = len(r.get("copied", [])) + updated = len(r.get("updated", [])) + modified = len(r.get("user_modified", [])) + parts = [] + if copied: parts.append(f"+{copied} new") + if updated: parts.append(f"↑{updated} updated") + if modified: parts.append(f"~{modified} user-modified") + status = ", ".join(parts) if parts else "up to date" + else: + status = "sync failed" + print(f" {p.name}: {status}") + except Exception as pe: + print(f" {p.name}: error ({pe})") + except Exception: + pass # profiles module not available or no profiles + # Check for config migrations print() print("→ Checking configuration for new options...") @@ -3122,6 +3215,7 @@ def _coalesce_session_name_args(argv: list) -> list: "chat", "model", "gateway", "setup", "whatsapp", "login", "logout", "status", "cron", "doctor", "config", "pairing", "skills", "tools", "mcp", "sessions", "insights", "version", "update", "uninstall", + "profile", } _SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"} @@ -3145,6 +3239,253 @@ def _coalesce_session_name_args(argv: list) -> list: return result +def cmd_profile(args): + """Profile management — create, delete, list, switch, alias.""" + from hermes_cli.profiles import ( + list_profiles, create_profile, delete_profile, seed_profile_skills, + get_active_profile, set_active_profile, get_active_profile_name, + check_alias_collision, create_wrapper_script, remove_wrapper_script, + _is_wrapper_dir_in_path, _get_wrapper_dir, + ) + from hermes_constants import display_hermes_home + + action = getattr(args, "profile_action", None) + + if action is None: + # Bare `hermes profile` — show current profile status + profile_name = get_active_profile_name() + dhh = display_hermes_home() + print(f"\nActive profile: {profile_name}") + print(f"Path: {dhh}") + + profiles = list_profiles() + for p in profiles: + if p.name == profile_name or (profile_name == "default" and p.is_default): + 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: + print(f"Alias: {p.name} → hermes -p {p.name}") + break + print() + return + + if action == "list": + 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} {'Alias'}") + print(f" {'─' * 15} {'─' * 27} {'─' * 11} {'─' * 12}") + + for p in profiles: + marker = " ◆" if (p.name == active or (active == "default" and p.is_default)) else " " + name = p.name + model = (p.model or "—")[:26] + gw = "running" if p.gateway_running else "stopped" + alias = p.name if p.alias_path else "—" + if p.is_default: + alias = "—" + print(f"{marker}{name:<15} {model:<28} {gw:<12} {alias}") + print() + + elif action == "use": + name = args.profile_name + try: + set_active_profile(name) + if name == "default": + print(f"Switched to: default (~/.hermes)") + else: + print(f"Switched to: {name}") + except (ValueError, FileNotFoundError) as e: + print(f"Error: {e}") + sys.exit(1) + + elif action == "create": + name = args.profile_name + clone = getattr(args, "clone", False) + clone_all = getattr(args, "clone_all", False) + no_alias = getattr(args, "no_alias", False) + + try: + clone_from = getattr(args, "clone_from", None) + + profile_dir = create_profile( + name=name, + clone_from=clone_from, + clone_all=clone_all, + clone_config=clone, + no_alias=no_alias, + ) + print(f"\nProfile '{name}' created at {profile_dir}") + + if clone 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}.") + else: + print(f"Cloned config, .env, SOUL.md from {source_label}.") + + # Seed bundled skills (skip if --clone-all already copied them) + if not clone_all: + result = seed_profile_skills(profile_dir) + if 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(f' Add to your shell config (~/.bashrc or ~/.zshrc):') + print(f' export PATH="$HOME/.local/bin:$PATH"') + + # Next steps + print(f"\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: + from hermes_constants import get_hermes_home + profile_dir_display = f"~/.hermes/profiles/{name}" + print(f"\n Edit {profile_dir_display}/.env for different API keys") + print(f" Edit {profile_dir_display}/SOUL.md for different personality") + print() + + except (ValueError, FileExistsError, FileNotFoundError) as e: + print(f"Error: {e}") + sys.exit(1) + + elif action == "delete": + 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) + + elif action == "show": + name = args.profile_name + from hermes_cli.profiles import get_profile_dir, profile_exists, _read_config_model, _check_gateway_running, _count_skills + 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) + skills = _count_skills(profile_dir) + wrapper = _get_wrapper_dir() / name + + print(f"\nProfile: {name}") + 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 wrapper.exists(): + print(f"Alias: {wrapper}") + print() + + elif action == "alias": + name = args.profile_name + remove = getattr(args, "remove", False) + custom_name = getattr(args, "alias_name", None) + + from hermes_cli.profiles import profile_exists + if not profile_exists(name): + print(f"Error: Profile '{name}' does not exist.") + sys.exit(1) + + alias_name = custom_name or name + + 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) + if wrapper_path: + # If custom name, write the profile name into the wrapper + if custom_name: + wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {name} "$@"\n') + print(f"✓ Alias created: {wrapper_path}") + if not _is_wrapper_dir_in_path(): + print(f"⚠ {_get_wrapper_dir()} is not in your PATH.") + + elif action == "rename": + from hermes_cli.profiles import rename_profile + try: + new_dir = rename_profile(args.old_name, args.new_name) + 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) + + elif action == "export": + from hermes_cli.profiles import export_profile + name = args.profile_name + output = args.output or f"{name}.tar.gz" + try: + result_path = export_profile(name, output) + print(f"✓ Exported '{name}' to {result_path}") + except (ValueError, FileNotFoundError) as e: + print(f"Error: {e}") + sys.exit(1) + + elif action == "import": + 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 cmd_completion(args): + """Print shell completion script.""" + from hermes_cli.profiles import generate_bash_completion, generate_zsh_completion + shell = getattr(args, "shell", "bash") + if shell == "zsh": + print(generate_zsh_completion()) + else: + print(generate_bash_completion()) + + def main(): """Main entry point for hermes CLI.""" parser = argparse.ArgumentParser( @@ -4342,7 +4683,75 @@ For more help on a command: sys.exit(1) acp_parser.set_defaults(func=cmd_acp) - + + # ========================================================================= + # profile command + # ========================================================================= + profile_parser = subparsers.add_parser( + "profile", + help="Manage profiles — multiple isolated Hermes instances", + ) + profile_subparsers = profile_parser.add_subparsers(dest="profile_action") + + profile_list = profile_subparsers.add_parser("list", help="List all profiles") + profile_use = profile_subparsers.add_parser("use", help="Set sticky default profile") + profile_use.add_argument("profile_name", help="Profile name (or 'default')") + + profile_create = profile_subparsers.add_parser("create", help="Create a new profile") + profile_create.add_argument("profile_name", help="Profile name (lowercase, alphanumeric)") + profile_create.add_argument("--clone", action="store_true", + help="Copy config.yaml, .env, SOUL.md from active profile") + profile_create.add_argument("--clone-all", action="store_true", + help="Full copy of active profile (all state)") + profile_create.add_argument("--clone-from", metavar="SOURCE", + help="Source profile to clone from (default: active)") + profile_create.add_argument("--no-alias", action="store_true", + help="Skip wrapper script creation") + + profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile") + profile_delete.add_argument("profile_name", help="Profile to delete") + profile_delete.add_argument("-y", "--yes", action="store_true", + help="Skip confirmation prompt") + + profile_show = profile_subparsers.add_parser("show", help="Show profile details") + profile_show.add_argument("profile_name", help="Profile to show") + + profile_alias = profile_subparsers.add_parser("alias", help="Manage wrapper scripts") + profile_alias.add_argument("profile_name", help="Profile name") + profile_alias.add_argument("--remove", action="store_true", + help="Remove the wrapper script") + profile_alias.add_argument("--name", dest="alias_name", metavar="NAME", + help="Custom alias name (default: profile name)") + + profile_rename = profile_subparsers.add_parser("rename", help="Rename a profile") + profile_rename.add_argument("old_name", help="Current profile name") + profile_rename.add_argument("new_name", help="New profile name") + + profile_export = profile_subparsers.add_parser("export", help="Export a profile to archive") + profile_export.add_argument("profile_name", help="Profile to export") + profile_export.add_argument("-o", "--output", default=None, + help="Output file (default: .tar.gz)") + + profile_import = profile_subparsers.add_parser("import", help="Import a profile from archive") + profile_import.add_argument("archive", help="Path to .tar.gz archive") + profile_import.add_argument("--name", dest="import_name", metavar="NAME", + help="Profile name (default: inferred from archive)") + + profile_parser.set_defaults(func=cmd_profile) + + # ========================================================================= + # completion command + # ========================================================================= + completion_parser = subparsers.add_parser( + "completion", + help="Print shell completion script (bash or zsh)", + ) + completion_parser.add_argument( + "shell", nargs="?", default="bash", choices=["bash", "zsh"], + help="Shell type (default: bash)", + ) + completion_parser.set_defaults(func=cmd_completion) + # ========================================================================= # Parse and execute # ========================================================================= diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py new file mode 100644 index 000000000..7ef39d105 --- /dev/null +++ b/hermes_cli/profiles.py @@ -0,0 +1,906 @@ +""" +Profile management for multiple isolated Hermes instances. + +Each profile is a fully independent HERMES_HOME directory with its own +config.yaml, .env, memory, sessions, skills, gateway, cron, and logs. +Profiles live under ``~/.hermes/profiles//`` by default. + +The "default" profile is ``~/.hermes`` itself — backward compatible, +zero migration needed. + +Usage:: + + hermes profile create coder # fresh profile + bundled skills + hermes profile create coder --clone # also copy config, .env, SOUL.md + hermes profile create coder --clone-all # full copy of source profile + coder chat # use via wrapper alias + hermes -p coder chat # or via flag + hermes profile use coder # set as sticky default + hermes profile delete coder # remove profile + alias + service +""" + +import json +import os +import re +import shutil +import stat +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +_PROFILE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") + +# Directories bootstrapped inside every new profile +_PROFILE_DIRS = [ + "memories", + "sessions", + "skills", + "skins", + "logs", + "plans", + "workspace", + "cron", +] + +# Files copied during --clone (if they exist in the source) +_CLONE_CONFIG_FILES = [ + "config.yaml", + ".env", + "SOUL.md", +] + +# Runtime files stripped after --clone-all (shouldn't carry over) +_CLONE_ALL_STRIP = [ + "gateway.pid", + "gateway_state.json", + "processes.json", +] + +# Names that cannot be used as profile aliases +_RESERVED_NAMES = frozenset({ + "hermes", "default", "test", "tmp", "root", "sudo", +}) + +# Hermes subcommands that cannot be used as profile names/aliases +_HERMES_SUBCOMMANDS = frozenset({ + "chat", "model", "gateway", "setup", "whatsapp", "login", "logout", + "status", "cron", "doctor", "config", "pairing", "skills", "tools", + "mcp", "sessions", "insights", "version", "update", "uninstall", + "profile", "plugins", "honcho", "acp", +}) + + +# --------------------------------------------------------------------------- +# Path helpers +# --------------------------------------------------------------------------- + +def _get_profiles_root() -> Path: + """Return the directory where named profiles are stored. + + Always ``~/.hermes/profiles/`` — anchored to the user's home, + NOT to the current HERMES_HOME (which may itself be a profile). + This ensures ``coder profile list`` can see all profiles. + """ + return Path.home() / ".hermes" / "profiles" + + +def _get_default_hermes_home() -> Path: + """Return the default (pre-profile) HERMES_HOME path.""" + return Path.home() / ".hermes" + + +def _get_active_profile_path() -> Path: + """Return the path to the sticky active_profile file.""" + return _get_default_hermes_home() / "active_profile" + + +def _get_wrapper_dir() -> Path: + """Return the directory for wrapper scripts.""" + return Path.home() / ".local" / "bin" + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +def validate_profile_name(name: str) -> None: + """Raise ``ValueError`` if *name* is not a valid profile identifier.""" + if name == "default": + return # special alias for ~/.hermes + if not _PROFILE_ID_RE.match(name): + raise ValueError( + f"Invalid profile name {name!r}. Must match " + f"[a-z0-9][a-z0-9_-]{{0,63}}" + ) + + +def get_profile_dir(name: str) -> Path: + """Resolve a profile name to its HERMES_HOME directory.""" + if name == "default": + return _get_default_hermes_home() + return _get_profiles_root() / name + + +def profile_exists(name: str) -> bool: + """Check whether a profile directory exists.""" + if name == "default": + return True + return get_profile_dir(name).is_dir() + + +# --------------------------------------------------------------------------- +# Alias / wrapper script management +# --------------------------------------------------------------------------- + +def check_alias_collision(name: str) -> Optional[str]: + """Return a human-readable collision message, or None if the name is safe. + + Checks: reserved names, hermes subcommands, existing binaries in PATH. + """ + if name in _RESERVED_NAMES: + return f"'{name}' is a reserved name" + if name in _HERMES_SUBCOMMANDS: + return f"'{name}' conflicts with a hermes subcommand" + + # Check existing commands in PATH + wrapper_dir = _get_wrapper_dir() + try: + result = subprocess.run( + ["which", name], capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + existing_path = result.stdout.strip() + # Allow overwriting our own wrappers + if existing_path == str(wrapper_dir / name): + try: + content = (wrapper_dir / name).read_text() + if "hermes -p" in content: + return None # it's our wrapper, safe to overwrite + except Exception: + pass + return f"'{name}' conflicts with an existing command ({existing_path})" + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + return None # safe + + +def _is_wrapper_dir_in_path() -> bool: + """Check if ~/.local/bin is in PATH.""" + wrapper_dir = str(_get_wrapper_dir()) + return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep) + + +def create_wrapper_script(name: str) -> Optional[Path]: + """Create a shell wrapper script at ~/.local/bin/. + + Returns the path to the created wrapper, or None if creation failed. + """ + wrapper_dir = _get_wrapper_dir() + try: + wrapper_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + print(f"⚠ Could not create {wrapper_dir}: {e}") + return None + + wrapper_path = wrapper_dir / name + try: + wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {name} "$@"\n') + wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return wrapper_path + except OSError as e: + print(f"⚠ Could not create wrapper at {wrapper_path}: {e}") + return None + + +def remove_wrapper_script(name: str) -> bool: + """Remove the wrapper script for a profile. Returns True if removed.""" + wrapper_path = _get_wrapper_dir() / name + if wrapper_path.exists(): + try: + # Verify it's our wrapper before removing + content = wrapper_path.read_text() + if "hermes -p" in content: + wrapper_path.unlink() + return True + except Exception: + pass + return False + + +# --------------------------------------------------------------------------- +# ProfileInfo +# --------------------------------------------------------------------------- + +@dataclass +class ProfileInfo: + """Summary information about a profile.""" + name: str + path: Path + is_default: bool + gateway_running: bool + model: Optional[str] = None + provider: Optional[str] = None + has_env: bool = False + skill_count: int = 0 + alias_path: Optional[Path] = None + + +def _read_config_model(profile_dir: Path) -> tuple: + """Read model/provider from a profile's config.yaml. Returns (model, provider).""" + config_path = profile_dir / "config.yaml" + if not config_path.exists(): + return None, None + try: + import yaml + with open(config_path, "r") as f: + cfg = yaml.safe_load(f) or {} + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, str): + return model_cfg, None + if isinstance(model_cfg, dict): + return model_cfg.get("model"), model_cfg.get("provider") + return None, None + except Exception: + return None, None + + +def _check_gateway_running(profile_dir: Path) -> bool: + """Check if a gateway is running for a given profile directory.""" + pid_file = profile_dir / "gateway.pid" + if not pid_file.exists(): + return False + try: + raw = pid_file.read_text().strip() + if not raw: + return False + data = json.loads(raw) if raw.startswith("{") else {"pid": int(raw)} + pid = int(data["pid"]) + os.kill(pid, 0) # existence check + return True + except (json.JSONDecodeError, KeyError, ValueError, TypeError, + ProcessLookupError, PermissionError, OSError): + return False + + +def _count_skills(profile_dir: Path) -> int: + """Count installed skills in a profile.""" + skills_dir = profile_dir / "skills" + if not skills_dir.is_dir(): + return 0 + count = 0 + for md in skills_dir.rglob("SKILL.md"): + if "/.hub/" not in str(md) and "/.git/" not in str(md): + count += 1 + return count + + +# --------------------------------------------------------------------------- +# CRUD operations +# --------------------------------------------------------------------------- + +def list_profiles() -> List[ProfileInfo]: + """Return info for all profiles, including the default.""" + profiles = [] + wrapper_dir = _get_wrapper_dir() + + # Default profile + default_home = _get_default_hermes_home() + if default_home.is_dir(): + model, provider = _read_config_model(default_home) + profiles.append(ProfileInfo( + name="default", + path=default_home, + is_default=True, + gateway_running=_check_gateway_running(default_home), + model=model, + provider=provider, + has_env=(default_home / ".env").exists(), + skill_count=_count_skills(default_home), + )) + + # Named profiles + profiles_root = _get_profiles_root() + if profiles_root.is_dir(): + for entry in sorted(profiles_root.iterdir()): + if not entry.is_dir(): + continue + name = entry.name + if not _PROFILE_ID_RE.match(name): + continue + model, provider = _read_config_model(entry) + alias_path = wrapper_dir / name + profiles.append(ProfileInfo( + name=name, + path=entry, + is_default=False, + gateway_running=_check_gateway_running(entry), + model=model, + provider=provider, + has_env=(entry / ".env").exists(), + skill_count=_count_skills(entry), + alias_path=alias_path if alias_path.exists() else None, + )) + + return profiles + + +def create_profile( + name: str, + clone_from: Optional[str] = None, + clone_all: bool = False, + clone_config: bool = False, + no_alias: bool = False, +) -> Path: + """Create a new profile directory. + + Parameters + ---------- + name: + Profile identifier (lowercase, alphanumeric, hyphens, underscores). + clone_from: + Source profile to clone from. If ``None`` and clone_config/clone_all + is True, defaults to the currently active profile. + clone_all: + If True, do a full copytree of the source (all state). + clone_config: + If True, copy only config files (config.yaml, .env, SOUL.md). + no_alias: + If True, skip wrapper script creation. + + Returns + ------- + Path + The newly created profile directory. + """ + validate_profile_name(name) + + if name == "default": + raise ValueError( + "Cannot create a profile named 'default' — it is the built-in profile (~/.hermes)." + ) + + profile_dir = get_profile_dir(name) + if profile_dir.exists(): + raise FileExistsError(f"Profile '{name}' already exists at {profile_dir}") + + # Resolve clone source + source_dir = None + if clone_from is not None or clone_all or clone_config: + if clone_from is None: + # Default: clone from active profile + from hermes_constants import get_hermes_home + source_dir = get_hermes_home() + else: + validate_profile_name(clone_from) + source_dir = get_profile_dir(clone_from) + if not source_dir.is_dir(): + raise FileNotFoundError( + f"Source profile '{clone_from or 'active'}' does not exist at {source_dir}" + ) + + if clone_all and source_dir: + # Full copy of source profile + shutil.copytree(source_dir, profile_dir) + # Strip runtime files + for stale in _CLONE_ALL_STRIP: + (profile_dir / stale).unlink(missing_ok=True) + else: + # Bootstrap directory structure + profile_dir.mkdir(parents=True, exist_ok=True) + for subdir in _PROFILE_DIRS: + (profile_dir / subdir).mkdir(parents=True, exist_ok=True) + + # Clone config files from source + if source_dir is not None: + for filename in _CLONE_CONFIG_FILES: + src = source_dir / filename + if src.exists(): + shutil.copy2(src, profile_dir / filename) + + return profile_dir + + +def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict]: + """Seed bundled skills into a profile via subprocess. + + Uses subprocess because sync_skills() caches HERMES_HOME at module level. + Returns the sync result dict, or None on failure. + """ + project_root = Path(__file__).parent.parent.resolve() + try: + result = subprocess.run( + [sys.executable, "-c", + "import json; from tools.skills_sync import sync_skills; " + "r = sync_skills(quiet=True); print(json.dumps(r))"], + env={**os.environ, "HERMES_HOME": str(profile_dir)}, + cwd=str(project_root), + capture_output=True, text=True, timeout=60, + ) + if result.returncode == 0 and result.stdout.strip(): + return json.loads(result.stdout.strip()) + if not quiet: + print(f"⚠ Skill seeding returned exit code {result.returncode}") + if result.stderr.strip(): + print(f" {result.stderr.strip()[:200]}") + return None + except subprocess.TimeoutExpired: + if not quiet: + print("⚠ Skill seeding timed out (60s)") + return None + except Exception as e: + if not quiet: + print(f"⚠ Skill seeding failed: {e}") + return None + + +def delete_profile(name: str, yes: bool = False) -> Path: + """Delete a profile, its wrapper script, and its gateway service. + + Stops the gateway if running. Disables systemd/launchd service first + to prevent auto-restart. + + Returns the path that was removed. + """ + validate_profile_name(name) + + if name == "default": + raise ValueError( + "Cannot delete the default profile (~/.hermes).\n" + "To remove everything, use: hermes uninstall" + ) + + profile_dir = get_profile_dir(name) + if not profile_dir.is_dir(): + raise FileNotFoundError(f"Profile '{name}' does not exist.") + + # Show what will be deleted + model, provider = _read_config_model(profile_dir) + gw_running = _check_gateway_running(profile_dir) + skill_count = _count_skills(profile_dir) + + print(f"\nProfile: {name}") + print(f"Path: {profile_dir}") + if model: + print(f"Model: {model}" + (f" ({provider})" if provider else "")) + if skill_count: + print(f"Skills: {skill_count}") + + items = [ + "All config, API keys, memories, sessions, skills, cron jobs", + ] + + # Check for service + from hermes_cli.gateway import _profile_suffix, get_service_name + wrapper_path = _get_wrapper_dir() / name + has_wrapper = wrapper_path.exists() + if has_wrapper: + items.append(f"Command alias ({wrapper_path})") + + print(f"\nThis will permanently delete:") + for item in items: + print(f" • {item}") + if gw_running: + print(f" ⚠ Gateway is running — it will be stopped.") + + # Confirmation + if not yes: + print() + try: + confirm = input(f"Type '{name}' to confirm: ").strip() + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + return profile_dir + if confirm != name: + print("Cancelled.") + return profile_dir + + # 1. Disable service (prevents auto-restart) + _cleanup_gateway_service(name, profile_dir) + + # 2. Stop running gateway + if gw_running: + _stop_gateway_process(profile_dir) + + # 3. Remove wrapper script + if has_wrapper: + if remove_wrapper_script(name): + print(f"✓ Removed {wrapper_path}") + + # 4. Remove profile directory + try: + shutil.rmtree(profile_dir) + print(f"✓ Removed {profile_dir}") + except Exception as e: + print(f"⚠ Could not remove {profile_dir}: {e}") + + # 5. Clear active_profile if it pointed to this profile + try: + active = get_active_profile() + if active == name: + set_active_profile("default") + print("✓ Active profile reset to default") + except Exception: + pass + + print(f"\nProfile '{name}' deleted.") + return profile_dir + + +def _cleanup_gateway_service(name: str, profile_dir: Path) -> None: + """Disable and remove systemd/launchd service for a profile.""" + import platform as _platform + + # Derive service name for this profile + # Temporarily set HERMES_HOME so _profile_suffix resolves correctly + old_home = os.environ.get("HERMES_HOME") + try: + os.environ["HERMES_HOME"] = str(profile_dir) + from hermes_cli.gateway import get_service_name, get_launchd_plist_path + + if _platform.system() == "Linux": + svc_name = get_service_name() + svc_file = Path.home() / ".config" / "systemd" / "user" / f"{svc_name}.service" + if svc_file.exists(): + subprocess.run( + ["systemctl", "--user", "disable", svc_name], + capture_output=True, check=False, timeout=10, + ) + subprocess.run( + ["systemctl", "--user", "stop", svc_name], + capture_output=True, check=False, timeout=10, + ) + svc_file.unlink(missing_ok=True) + subprocess.run( + ["systemctl", "--user", "daemon-reload"], + capture_output=True, check=False, timeout=10, + ) + print(f"✓ Service {svc_name} removed") + + elif _platform.system() == "Darwin": + plist_path = get_launchd_plist_path() + if plist_path.exists(): + subprocess.run( + ["launchctl", "unload", str(plist_path)], + capture_output=True, check=False, timeout=10, + ) + plist_path.unlink(missing_ok=True) + print(f"✓ Launchd service removed") + except Exception as e: + print(f"⚠ Service cleanup: {e}") + finally: + if old_home is not None: + os.environ["HERMES_HOME"] = old_home + elif "HERMES_HOME" in os.environ: + del os.environ["HERMES_HOME"] + + +def _stop_gateway_process(profile_dir: Path) -> None: + """Stop a running gateway process via its PID file.""" + import signal as _signal + import time as _time + + pid_file = profile_dir / "gateway.pid" + if not pid_file.exists(): + return + + try: + raw = pid_file.read_text().strip() + data = json.loads(raw) if raw.startswith("{") else {"pid": int(raw)} + pid = int(data["pid"]) + os.kill(pid, _signal.SIGTERM) + # Wait up to 10s for graceful shutdown + for _ in range(20): + _time.sleep(0.5) + try: + os.kill(pid, 0) + except ProcessLookupError: + print(f"✓ Gateway stopped (PID {pid})") + return + # Force kill + try: + os.kill(pid, _signal.SIGKILL) + except ProcessLookupError: + pass + print(f"✓ Gateway force-stopped (PID {pid})") + except (ProcessLookupError, PermissionError): + print("✓ Gateway already stopped") + except Exception as e: + print(f"⚠ Could not stop gateway: {e}") + + +# --------------------------------------------------------------------------- +# Active profile (sticky default) +# --------------------------------------------------------------------------- + +def get_active_profile() -> str: + """Read the sticky active profile name. + + Returns ``"default"`` if no active_profile file exists or it's empty. + """ + path = _get_active_profile_path() + try: + name = path.read_text().strip() + if not name: + return "default" + return name + except (FileNotFoundError, UnicodeDecodeError, OSError): + return "default" + + +def set_active_profile(name: str) -> None: + """Set the sticky active profile. + + Writes to ``~/.hermes/active_profile``. Use ``"default"`` to clear. + """ + validate_profile_name(name) + if name != "default" and not profile_exists(name): + raise FileNotFoundError( + f"Profile '{name}' does not exist. " + f"Create it with: hermes profile create {name}" + ) + + path = _get_active_profile_path() + path.parent.mkdir(parents=True, exist_ok=True) + if name == "default": + # Remove the file to indicate default + path.unlink(missing_ok=True) + else: + # Atomic write + tmp = path.with_suffix(".tmp") + tmp.write_text(name + "\n") + tmp.replace(path) + + +def get_active_profile_name() -> str: + """Infer the current profile name from HERMES_HOME. + + Returns ``"default"`` if HERMES_HOME is not set or points to ``~/.hermes``. + Returns the profile name if HERMES_HOME points into ``~/.hermes/profiles/``. + Returns ``"custom"`` if HERMES_HOME is set to an unrecognized path. + """ + from hermes_constants import get_hermes_home + hermes_home = get_hermes_home() + resolved = hermes_home.resolve() + + default_resolved = _get_default_hermes_home().resolve() + if resolved == default_resolved: + return "default" + + profiles_root = _get_profiles_root().resolve() + try: + rel = resolved.relative_to(profiles_root) + parts = rel.parts + if len(parts) == 1 and _PROFILE_ID_RE.match(parts[0]): + return parts[0] + except ValueError: + pass + + return "custom" + + +# --------------------------------------------------------------------------- +# Export / Import +# --------------------------------------------------------------------------- + +def export_profile(name: str, output_path: str) -> Path: + """Export a profile to a tar.gz archive. + + Returns the output file path. + """ + validate_profile_name(name) + profile_dir = get_profile_dir(name) + if not profile_dir.is_dir(): + raise FileNotFoundError(f"Profile '{name}' does not exist.") + + output = Path(output_path) + # shutil.make_archive wants the base name without extension + base = str(output).removesuffix(".tar.gz").removesuffix(".tgz") + result = shutil.make_archive(base, "gztar", str(profile_dir.parent), name) + return Path(result) + + +def import_profile(archive_path: str, name: Optional[str] = None) -> Path: + """Import a profile from a tar.gz archive. + + If *name* is not given, infers it from the archive's top-level directory. + Returns the imported profile directory. + """ + import tarfile + + archive = Path(archive_path) + if not archive.exists(): + raise FileNotFoundError(f"Archive not found: {archive}") + + # Peek at the archive to find the top-level directory name + with tarfile.open(archive, "r:gz") as tf: + top_dirs = {m.name.split("/")[0] for m in tf.getmembers() if "/" in m.name} + if not top_dirs: + top_dirs = {m.name for m in tf.getmembers() if m.isdir()} + + inferred_name = name or (top_dirs.pop() if len(top_dirs) == 1 else None) + if not inferred_name: + raise ValueError( + "Cannot determine profile name from archive. " + "Specify it explicitly: hermes profile import --name " + ) + + validate_profile_name(inferred_name) + profile_dir = get_profile_dir(inferred_name) + if profile_dir.exists(): + raise FileExistsError(f"Profile '{inferred_name}' already exists at {profile_dir}") + + profiles_root = _get_profiles_root() + profiles_root.mkdir(parents=True, exist_ok=True) + + shutil.unpack_archive(str(archive), str(profiles_root)) + + # If the archive extracted under a different name, rename + extracted = profiles_root / (top_dirs.pop() if top_dirs else inferred_name) + if extracted != profile_dir and extracted.exists(): + extracted.rename(profile_dir) + + return profile_dir + + +# --------------------------------------------------------------------------- +# Rename +# --------------------------------------------------------------------------- + +def rename_profile(old_name: str, new_name: str) -> Path: + """Rename a profile: directory, wrapper script, service, active_profile. + + Returns the new profile directory. + """ + validate_profile_name(old_name) + validate_profile_name(new_name) + + if old_name == "default": + raise ValueError("Cannot rename the default profile.") + if new_name == "default": + raise ValueError("Cannot rename to 'default' — it is reserved.") + + old_dir = get_profile_dir(old_name) + new_dir = get_profile_dir(new_name) + + if not old_dir.is_dir(): + raise FileNotFoundError(f"Profile '{old_name}' does not exist.") + if new_dir.exists(): + raise FileExistsError(f"Profile '{new_name}' already exists.") + + # 1. Stop gateway if running + if _check_gateway_running(old_dir): + _cleanup_gateway_service(old_name, old_dir) + _stop_gateway_process(old_dir) + + # 2. Rename directory + old_dir.rename(new_dir) + print(f"✓ Renamed {old_dir.name} → {new_dir.name}") + + # 3. Update wrapper script + remove_wrapper_script(old_name) + collision = check_alias_collision(new_name) + if not collision: + create_wrapper_script(new_name) + print(f"✓ Alias updated: {new_name}") + else: + print(f"⚠ Cannot create alias '{new_name}' — {collision}") + + # 4. Update active_profile if it pointed to old name + try: + if get_active_profile() == old_name: + set_active_profile(new_name) + print(f"✓ Active profile updated: {new_name}") + except Exception: + pass + + return new_dir + + +# --------------------------------------------------------------------------- +# Tab completion +# --------------------------------------------------------------------------- + +def generate_bash_completion() -> str: + """Generate a bash completion script for hermes profile names.""" + return '''# Hermes Agent profile completion +# Add to ~/.bashrc: eval "$(hermes completion bash)" + +_hermes_profiles() { + local profiles_dir="$HOME/.hermes/profiles" + local profiles="default" + if [ -d "$profiles_dir" ]; then + profiles="$profiles $(ls "$profiles_dir" 2>/dev/null)" + fi + echo "$profiles" +} + +_hermes_completion() { + local cur prev + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + # Complete profile names after -p / --profile + if [[ "$prev" == "-p" || "$prev" == "--profile" ]]; then + COMPREPLY=($(compgen -W "$(_hermes_profiles)" -- "$cur")) + return + fi + + # Complete profile subcommands + if [[ "${COMP_WORDS[1]}" == "profile" ]]; then + case "$prev" in + profile) + COMPREPLY=($(compgen -W "list use create delete show alias rename export import" -- "$cur")) + return + ;; + use|delete|show|alias|rename|export) + COMPREPLY=($(compgen -W "$(_hermes_profiles)" -- "$cur")) + return + ;; + esac + fi + + # Top-level subcommands + if [[ "$COMP_CWORD" == 1 ]]; then + local commands="chat model gateway setup status cron doctor config skills tools mcp sessions profile update version" + COMPREPLY=($(compgen -W "$commands" -- "$cur")) + fi +} + +complete -F _hermes_completion hermes +''' + + +def generate_zsh_completion() -> str: + """Generate a zsh completion script for hermes profile names.""" + return '''#compdef hermes +# Hermes Agent profile completion +# Add to ~/.zshrc: eval "$(hermes completion zsh)" + +_hermes() { + local -a profiles + profiles=(default) + if [[ -d "$HOME/.hermes/profiles" ]]; then + profiles+=("${(@f)$(ls $HOME/.hermes/profiles 2>/dev/null)}") + fi + + _arguments \\ + '-p[Profile name]:profile:($profiles)' \\ + '--profile[Profile name]:profile:($profiles)' \\ + '1:command:(chat model gateway setup status cron doctor config skills tools mcp sessions profile update version)' \\ + '*::arg:->args' + + case $words[1] in + profile) + _arguments '1:action:(list use create delete show alias rename export import)' \\ + '2:profile:($profiles)' + ;; + esac +} + +_hermes "$@" +''' + + +# --------------------------------------------------------------------------- +# Profile env resolution (called from _apply_profile_override) +# --------------------------------------------------------------------------- + +def resolve_profile_env(profile_name: str) -> str: + """Resolve a profile name to a HERMES_HOME path string. + + Called early in the CLI entry point, before any hermes modules + are imported, to set the HERMES_HOME environment variable. + """ + validate_profile_name(profile_name) + profile_dir = get_profile_dir(profile_name) + + if profile_name != "default" and not profile_dir.is_dir(): + raise FileNotFoundError( + f"Profile '{profile_name}' does not exist. " + f"Create it with: hermes profile create {profile_name}" + ) + + return str(profile_dir) diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py new file mode 100644 index 000000000..80152a4a0 --- /dev/null +++ b/tests/hermes_cli/test_profiles.py @@ -0,0 +1,622 @@ +"""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" diff --git a/website/docs/developer-guide/contributing.md b/website/docs/developer-guide/contributing.md index 1d1e24c62..603b416ac 100644 --- a/website/docs/developer-guide/contributing.md +++ b/website/docs/developer-guide/contributing.md @@ -90,6 +90,7 @@ pytest tests/ -v - **Comments**: Only when explaining non-obvious intent, trade-offs, or API quirks - **Error handling**: Catch specific exceptions. Use `logger.warning()`/`logger.error()` with `exc_info=True` for unexpected errors - **Cross-platform**: Never assume Unix (see below) +- **Profile-safe paths**: Never hardcode `~/.hermes` — use `get_hermes_home()` from `hermes_constants` for code paths and `display_hermes_home()` for user-facing messages. See [AGENTS.md](https://github.com/NousResearch/hermes-agent/blob/main/AGENTS.md#profiles-multi-instance-support) for full rules. ## Cross-Platform Compatibility diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index 8f0971f3b..e207420f8 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -489,6 +489,44 @@ If an MCP server crashes mid-request, Hermes will report a timeout. Check the se --- +## Profiles + +### How do profiles differ from just setting HERMES_HOME? + +Profiles are a managed layer on top of `HERMES_HOME`. You *could* manually set `HERMES_HOME=/some/path` before every command, but profiles handle all the plumbing for you: creating the directory structure, generating shell aliases (`hermes-work`), tracking the active profile in `~/.hermes/active_profile`, and syncing skill updates across all profiles automatically. They also integrate with tab completion so you don't have to remember paths. + +### Can two profiles share the same bot token? + +No. Each messaging platform (Telegram, Discord, etc.) requires exclusive access to a bot token. If two profiles try to use the same token simultaneously, the second gateway will fail to connect. Create a separate bot per profile — for Telegram, talk to [@BotFather](https://t.me/BotFather) to make additional bots. + +### Do profiles share memory or sessions? + +No. Each profile has its own memory store, session database, and skills directory. They are completely isolated. If you want to start a new profile with existing memories and sessions, use `hermes profile create newname --clone-all` to copy everything from the current profile. + +### What happens when I run `hermes update`? + +`hermes update` pulls the latest code and reinstalls dependencies **once** (not per-profile). It then syncs updated skills to all profiles automatically. You only need to run `hermes update` once — it covers every profile on the machine. + +### Can I move a profile to a different machine? + +Yes. Export the profile to a portable archive and import it on the other machine: + +```bash +# On the source machine +hermes profile export work ./work-backup.tar.gz + +# Copy the file to the target machine, then: +hermes profile import ./work-backup.tar.gz work +``` + +The imported profile will have all config, memories, sessions, and skills from the export. You may need to update paths or re-authenticate with providers if the new machine has a different setup. + +### How many profiles can I run? + +There is no hard limit. Each profile is just a directory under `~/.hermes/profiles/`. The practical limit depends on your disk space and how many concurrent gateways your system can handle (each gateway is a lightweight Python process). Running dozens of profiles is fine; each idle profile uses no resources. + +--- + ## Still Stuck? If your issue isn't covered here: diff --git a/website/docs/reference/profile-commands.md b/website/docs/reference/profile-commands.md new file mode 100644 index 000000000..935597084 --- /dev/null +++ b/website/docs/reference/profile-commands.md @@ -0,0 +1,280 @@ +--- +sidebar_position: 7 +--- + +# Profile Commands Reference + +This page covers all commands related to [Hermes profiles](../user-guide/profiles.md). For general CLI commands, see [CLI Commands Reference](./cli-commands.md). + +## `hermes profile` + +```bash +hermes profile +``` + +Top-level command for managing profiles. Running `hermes profile` without a subcommand shows help. + +| Subcommand | Description | +|------------|-------------| +| `list` | List all profiles. | +| `use` | Set the active (default) profile. | +| `create` | Create a new profile. | +| `delete` | Delete a profile. | +| `show` | Show details about a profile. | +| `alias` | Regenerate the shell alias for a profile. | +| `rename` | Rename a profile. | +| `export` | Export a profile to a tar.gz archive. | +| `import` | Import a profile from a tar.gz archive. | + +## `hermes profile list` + +```bash +hermes profile list +``` + +Lists all profiles. The currently active profile is marked with `*`. + +**Example:** + +```bash +$ hermes profile list + default +* work + dev + personal +``` + +No options. + +## `hermes profile use` + +```bash +hermes profile use +``` + +Sets `` as the active profile. All subsequent `hermes` commands (without `-p`) will use this profile. + +| Argument | Description | +|----------|-------------| +| `` | Profile name to activate. Use `default` to return to the base profile. | + +**Example:** + +```bash +hermes profile use work +hermes profile use default +``` + +## `hermes profile create` + +```bash +hermes profile create [options] +``` + +Creates a new profile. + +| Argument / Option | Description | +|-------------------|-------------| +| `` | Name for the new profile. Must be a valid directory name (alphanumeric, hyphens, underscores). | +| `--clone` | Copy `config.yaml`, `.env`, and `SOUL.md` from the current profile. | +| `--clone-all` | Copy everything (config, memories, skills, sessions, state) from the current profile. | +| `--from ` | Clone from a specific profile instead of the current one. Used with `--clone` or `--clone-all`. | + +**Examples:** + +```bash +# Blank profile — needs full setup +hermes profile create mybot + +# Clone config only from current profile +hermes profile create work --clone + +# Clone everything from current profile +hermes profile create backup --clone-all + +# Clone config from a specific profile +hermes profile create work2 --clone --from work +``` + +## `hermes profile delete` + +```bash +hermes profile delete [options] +``` + +Deletes a profile and removes its shell alias. + +| Argument / Option | Description | +|-------------------|-------------| +| `` | Profile to delete. | +| `--yes`, `-y` | Skip confirmation prompt. | + +**Example:** + +```bash +hermes profile delete mybot +hermes profile delete mybot --yes +``` + +:::warning +This permanently deletes the profile's entire directory including all config, memories, sessions, and skills. Cannot delete the currently active profile. +::: + +## `hermes profile show` + +```bash +hermes profile show [name] +``` + +Displays details about a profile including its home directory, configured model, active platforms, and disk usage. + +| Argument | Description | +|----------|-------------| +| `[name]` | Profile to inspect. Defaults to the current active profile if omitted. | + +**Example:** + +```bash +$ hermes profile show work +Profile: work +Home: ~/.hermes/profiles/work +Model: anthropic/claude-sonnet-4 +Platforms: telegram, discord +Skills: 12 installed +Disk: 48 MB +``` + +## `hermes profile alias` + +```bash +hermes profile alias +``` + +Regenerates the shell alias script at `~/.local/bin/hermes-`. Useful if the alias was accidentally deleted or if you need to update it after moving your Hermes installation. + +| Argument | Description | +|----------|-------------| +| `` | Profile to create/update the alias for. | + +**Example:** + +```bash +hermes profile alias work +# Creates/updates ~/.local/bin/hermes-work +``` + +## `hermes profile rename` + +```bash +hermes profile rename +``` + +Renames a profile. Updates the directory and shell alias. + +| Argument | Description | +|----------|-------------| +| `` | Current profile name. | +| `` | New profile name. | + +**Example:** + +```bash +hermes profile rename mybot assistant +# ~/.hermes/profiles/mybot → ~/.hermes/profiles/assistant +# ~/.local/bin/hermes-mybot → ~/.local/bin/hermes-assistant +``` + +## `hermes profile export` + +```bash +hermes profile export +``` + +Exports a profile as a compressed tar.gz archive. + +| Argument | Description | +|----------|-------------| +| `` | Profile to export. | +| `` | Path for the output archive (e.g., `./work-backup.tar.gz`). | + +**Example:** + +```bash +hermes profile export work ./work-2026-03-29.tar.gz +``` + +## `hermes profile import` + +```bash +hermes profile import [name] +``` + +Imports a profile from a tar.gz archive. + +| Argument | Description | +|----------|-------------| +| `` | Path to the tar.gz archive to import. | +| `[name]` | Name for the imported profile. Defaults to the original profile name from the archive. | + +**Example:** + +```bash +hermes profile import ./work-2026-03-29.tar.gz work-restored +``` + +## `hermes -p` / `hermes --profile` + +```bash +hermes -p [options] +hermes --profile [options] +``` + +Global flag to run any Hermes command under a specific profile without changing the sticky default. This overrides the active profile for the duration of the command. + +| Option | Description | +|--------|-------------| +| `-p `, `--profile ` | Profile to use for this command. | + +**Examples:** + +```bash +hermes -p work chat -q "Check the server status" +hermes --profile dev gateway start +hermes -p personal skills list +hermes -p work config edit +``` + +## `hermes completion` + +```bash +hermes completion +``` + +Generates shell completion scripts. Includes completions for profile names and profile subcommands. + +| Argument | Description | +|----------|-------------| +| `` | Shell to generate completions for: `bash`, `zsh`, or `fish`. | + +**Examples:** + +```bash +# Install completions +hermes completion bash >> ~/.bashrc +hermes completion zsh >> ~/.zshrc +hermes completion fish > ~/.config/fish/completions/hermes.fish + +# Reload shell +source ~/.bashrc +``` + +After installation, tab completion works for: +- `hermes profile ` — subcommands (list, use, create, etc.) +- `hermes profile use ` — profile names +- `hermes -p ` — profile names + +## See also + +- [Profiles User Guide](../user-guide/profiles.md) +- [CLI Commands Reference](./cli-commands.md) +- [FAQ — Profiles section](./faq.md#profiles) diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md new file mode 100644 index 000000000..bf848b723 --- /dev/null +++ b/website/docs/user-guide/profiles.md @@ -0,0 +1,244 @@ +--- +sidebar_position: 2 +--- + +# Profiles: Running Multiple Agents + +Run multiple independent Hermes agents on the same machine — each with its own config, memory, sessions, and gateway. + +## What are profiles? + +A profile is a fully isolated Hermes environment. Each profile gets its own `HERMES_HOME` directory containing its own `config.yaml`, `.env`, `SOUL.md`, memories, sessions, skills, and state database. Profiles let you run separate agents for different purposes — a personal assistant, a work bot, a dev agent — without any cross-contamination. + +Each profile also gets a shell alias (e.g., `hermes-work`) so you can launch it directly without flags. + +## Quick start + +```bash +# Create a profile called "work" +hermes profile create work + +# Switch to it as the default +hermes profile use work + +# Launch — now everything uses the "work" environment +hermes +``` + +That's it. From now on, `hermes` uses the "work" profile until you switch back. + +## Creating a profile + +### Blank profile + +```bash +hermes profile create mybot +``` + +Creates a fresh, empty profile. You'll need to run `hermes setup` (or `hermes-mybot setup`) to configure it from scratch — provider, model, gateway tokens, etc. + +### Clone config only (`--clone`) + +```bash +hermes profile create work --clone +``` + +Copies your current profile's `config.yaml`, `.env`, and `SOUL.md` into the new profile. This gives you the same provider/model setup without copying memories, sessions, or skills. Useful when you want a second agent with the same API keys but different personality or gateway tokens. + +### Clone everything (`--clone-all`) + +```bash +hermes profile create backup --clone-all +``` + +Copies **everything** — config, memories, sessions, skills, state database, the lot. This is a full snapshot of your current profile. Useful for creating a backup or forking an agent that already has learned context. + +## Using profiles + +### Shell aliases + +Every profile gets an alias installed to `~/.local/bin/`: + +```bash +hermes-work # Runs hermes with the "work" profile +hermes-mybot # Runs hermes with the "mybot" profile +hermes-backup # Runs hermes with the "backup" profile +``` + +These aliases work with all subcommands: + +```bash +hermes-work chat -q "Check my calendar" +hermes-work gateway start +hermes-work skills list +``` + +### Sticky default (`hermes profile use`) + +```bash +hermes profile use work +``` + +Sets "work" as the active profile. Now plain `hermes` uses the work profile — no alias or flag needed. The active profile is stored in `~/.hermes/active_profile`. + +Switch back to the default profile: + +```bash +hermes profile use default +``` + +### One-off with `-p` flag + +```bash +hermes -p work chat -q "Summarize my inbox" +hermes -p mybot gateway status +``` + +The `-p` / `--profile` flag overrides the sticky default for a single command without changing it. + +## Running gateways + +Each profile runs its own independent gateway. This means you can have multiple bots online simultaneously — for example, a personal Telegram bot and a team Discord bot: + +```bash +hermes-personal gateway start # Starts personal bot's gateway +hermes-work gateway start # Starts work bot's gateway +``` + +Each gateway uses the tokens and platform config from its own profile's `config.yaml` and `.env`. There are no port or token conflicts because each profile is fully isolated. + +:::warning +Each bot token (Telegram, Discord, etc.) can only be used by **one** profile at a time. If two profiles try to use the same token, the second gateway will fail to connect. Use a separate bot token per profile. +::: + +## Configuring profiles + +Each profile has its own independent configuration files: + +``` +~/.hermes/profiles/work/ +├── config.yaml # Model, provider, gateway settings +├── .env # API keys, bot tokens +├── SOUL.md # Personality / system prompt +├── skills/ # Installed skills +├── memories/ # Agent memories +├── state.db # Sessions, conversation history +└── logs/ # Gateway and agent logs +``` + +Edit a profile's config directly: + +```bash +hermes-work config edit # Opens work profile's config.yaml +hermes -p work setup # Run setup wizard for work profile +``` + +Or edit the files manually: + +```bash +nano ~/.hermes/profiles/work/config.yaml +nano ~/.hermes/profiles/work/.env +nano ~/.hermes/profiles/work/SOUL.md +``` + +The default profile lives at `~/.hermes/` (not in the `profiles/` subdirectory). + +## Updating + +```bash +hermes update +``` + +`hermes update` pulls the latest code and reinstalls dependencies once. It then syncs the updated skills to **all** profiles automatically. You don't need to run update separately for each profile — one update covers everything. + +## Managing profiles + +### List profiles + +```bash +hermes profile list +``` + +Shows all profiles with their status. The active profile is marked with an asterisk: + +``` + default +* work + mybot + backup +``` + +### Show profile details + +```bash +hermes profile show work +``` + +Displays the profile's home directory, config path, active model, configured platforms, and other details. + +### Rename a profile + +```bash +hermes profile rename mybot assistant +``` + +Renames the profile directory and updates the shell alias from `hermes-mybot` to `hermes-assistant`. + +### Export a profile + +```bash +hermes profile export work ./work-backup.tar.gz +``` + +Packages the entire profile into a portable archive. Useful for backups or transferring to another machine. + +### Import a profile + +```bash +hermes profile import ./work-backup.tar.gz work-restored +``` + +Imports a previously exported profile archive as a new profile. + +## Deleting a profile + +```bash +hermes profile delete mybot +``` + +Removes the profile directory and its shell alias. You'll be prompted to confirm. This permanently deletes all config, memories, sessions, and skills for that profile. + +:::warning +Deletion is irreversible. Export the profile first if you might need it later: `hermes profile export mybot ./mybot-backup.tar.gz` +::: + +You cannot delete the currently active profile. Switch to a different one first: + +```bash +hermes profile use default +hermes profile delete mybot +``` + +## Tab completion + +Enable shell completions for profile names and subcommands: + +```bash +# Generate completions for your shell +hermes completion bash >> ~/.bashrc +hermes completion zsh >> ~/.zshrc +hermes completion fish > ~/.config/fish/completions/hermes.fish + +# Reload your shell +source ~/.bashrc # or ~/.zshrc +``` + +After setup, `hermes profile ` autocompletes subcommands and `hermes -p ` autocompletes profile names. + +## How it works + +Under the hood, each profile is just a separate `HERMES_HOME` directory. When you run `hermes -p work` or `hermes-work`, Hermes sets `HERMES_HOME=~/.hermes/profiles/work` before starting. Everything — config loading, memory access, session storage, gateway operation — reads from and writes to that directory. + +The sticky default (`hermes profile use`) writes the profile name to `~/.hermes/active_profile`. On startup, if no `-p` flag is given, Hermes checks this file and sets `HERMES_HOME` accordingly. + +Profile aliases in `~/.local/bin/` are thin wrapper scripts that set `HERMES_HOME` and exec the real `hermes` binary. This means profiles work with all existing Hermes commands, flags, and features without any special handling. diff --git a/website/sidebars.ts b/website/sidebars.ts index 10f34cd00..6f065bcc1 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -38,6 +38,7 @@ const sidebars: SidebarsConfig = { 'user-guide/sessions', 'user-guide/security', 'user-guide/docker', + 'user-guide/profiles', { type: 'category', label: 'Messaging Gateway', @@ -153,6 +154,7 @@ const sidebars: SidebarsConfig = { 'reference/mcp-config-reference', 'reference/skills-catalog', 'reference/optional-skills-catalog', + 'reference/profile-commands', 'reference/environment-variables', 'reference/faq', ],