mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 23:11:37 +08:00
Compare commits
2 Commits
opencode-p
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08f3bcaa66 | ||
|
|
aa1848d15d |
78
AGENTS.md
78
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/<name>` 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
|
||||
|
||||
12
cli.py
12
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 "❯ ", "❯ "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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 <custom>")
|
||||
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(
|
||||
@@ -4332,7 +4673,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: <name>.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
|
||||
# =========================================================================
|
||||
|
||||
906
hermes_cli/profiles.py
Normal file
906
hermes_cli/profiles.py
Normal file
@@ -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/<name>/`` 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/<name>.
|
||||
|
||||
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/<name>``.
|
||||
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 <archive> --name <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)
|
||||
622
tests/hermes_cli/test_profiles.py
Normal file
622
tests/hermes_cli/test_profiles.py
Normal file
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
280
website/docs/reference/profile-commands.md
Normal file
280
website/docs/reference/profile-commands.md
Normal file
@@ -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 <subcommand>
|
||||
```
|
||||
|
||||
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 <name>
|
||||
```
|
||||
|
||||
Sets `<name>` as the active profile. All subsequent `hermes` commands (without `-p`) will use this profile.
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `<name>` | 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 <name> [options]
|
||||
```
|
||||
|
||||
Creates a new profile.
|
||||
|
||||
| Argument / Option | Description |
|
||||
|-------------------|-------------|
|
||||
| `<name>` | 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 <profile>` | 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 <name> [options]
|
||||
```
|
||||
|
||||
Deletes a profile and removes its shell alias.
|
||||
|
||||
| Argument / Option | Description |
|
||||
|-------------------|-------------|
|
||||
| `<name>` | 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 <name>
|
||||
```
|
||||
|
||||
Regenerates the shell alias script at `~/.local/bin/hermes-<name>`. Useful if the alias was accidentally deleted or if you need to update it after moving your Hermes installation.
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `<name>` | 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 <old-name> <new-name>
|
||||
```
|
||||
|
||||
Renames a profile. Updates the directory and shell alias.
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `<old-name>` | Current profile name. |
|
||||
| `<new-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 <name> <output-path>
|
||||
```
|
||||
|
||||
Exports a profile as a compressed tar.gz archive.
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `<name>` | Profile to export. |
|
||||
| `<output-path>` | 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 <archive-path> [name]
|
||||
```
|
||||
|
||||
Imports a profile from a tar.gz archive.
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `<archive-path>` | 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 <name> <command> [options]
|
||||
hermes --profile <name> <command> [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 <name>`, `--profile <name>` | 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 <shell>
|
||||
```
|
||||
|
||||
Generates shell completion scripts. Includes completions for profile names and profile subcommands.
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `<shell>` | 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 <TAB>` — subcommands (list, use, create, etc.)
|
||||
- `hermes profile use <TAB>` — profile names
|
||||
- `hermes -p <TAB>` — profile names
|
||||
|
||||
## See also
|
||||
|
||||
- [Profiles User Guide](../user-guide/profiles.md)
|
||||
- [CLI Commands Reference](./cli-commands.md)
|
||||
- [FAQ — Profiles section](./faq.md#profiles)
|
||||
244
website/docs/user-guide/profiles.md
Normal file
244
website/docs/user-guide/profiles.md
Normal file
@@ -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 <TAB>` autocompletes subcommands and `hermes -p <TAB>` 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.
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user