mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 07:51:45 +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.
|
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.
|
**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
|
## 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
|
### 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.
|
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/`
|
### 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.
|
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
|
## Testing
|
||||||
|
|||||||
12
cli.py
12
cli.py
@@ -5944,6 +5944,9 @@ class HermesCLI:
|
|||||||
``normal_prompt`` is the full ``branding.prompt_symbol``.
|
``normal_prompt`` is the full ``branding.prompt_symbol``.
|
||||||
``state_suffix`` is what special states (sudo/secret/approval/agent)
|
``state_suffix`` is what special states (sudo/secret/approval/agent)
|
||||||
should render after their leading icon.
|
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:
|
try:
|
||||||
from hermes_cli.skin_engine import get_active_prompt_symbol
|
from hermes_cli.skin_engine import get_active_prompt_symbol
|
||||||
@@ -5952,6 +5955,15 @@ class HermesCLI:
|
|||||||
symbol = "❯ "
|
symbol = "❯ "
|
||||||
|
|
||||||
symbol = (symbol or "❯ ").rstrip() + " "
|
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()
|
stripped = symbol.rstrip()
|
||||||
if not stripped:
|
if not stripped:
|
||||||
return "❯ ", "❯ "
|
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}/resume", self._handle_resume_job)
|
||||||
self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_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)
|
self._runner = web.AppRunner(self._app)
|
||||||
await self._runner.setup()
|
await self._runner.setup()
|
||||||
self._site = web.TCPSite(self._runner, self._host, self._port)
|
self._site = web.TCPSite(self._runner, self._host, self._port)
|
||||||
|
|||||||
@@ -486,6 +486,16 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
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
|
# Set up intents -- members intent needed for username-to-ID resolution
|
||||||
intents = Intents.default()
|
intents = Intents.default()
|
||||||
intents.message_content = True
|
intents.message_content = True
|
||||||
@@ -638,6 +648,14 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
self._running = False
|
self._running = False
|
||||||
self._client = None
|
self._client = None
|
||||||
self._ready_event.clear()
|
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)
|
logger.info("[%s] Disconnected", self.name)
|
||||||
|
|
||||||
async def send(
|
async def send(
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ class SignalAdapter(BasePlatformAdapter):
|
|||||||
self._recent_sent_timestamps: set = set()
|
self._recent_sent_timestamps: set = set()
|
||||||
self._max_recent_timestamps = 50
|
self._max_recent_timestamps = 50
|
||||||
|
|
||||||
|
self._phone_lock_identity: Optional[str] = None
|
||||||
|
|
||||||
logger.info("Signal adapter initialized: url=%s account=%s groups=%s",
|
logger.info("Signal adapter initialized: url=%s account=%s groups=%s",
|
||||||
self.http_url, _redact_phone(self.account),
|
self.http_url, _redact_phone(self.account),
|
||||||
"enabled" if self.group_allow_from else "disabled")
|
"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")
|
logger.error("Signal: SIGNAL_HTTP_URL and SIGNAL_ACCOUNT are required")
|
||||||
return False
|
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)
|
self.client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
|
||||||
# Health check — verify signal-cli daemon is reachable
|
# Health check — verify signal-cli daemon is reachable
|
||||||
@@ -245,6 +270,14 @@ class SignalAdapter(BasePlatformAdapter):
|
|||||||
await self.client.aclose()
|
await self.client.aclose()
|
||||||
self.client = None
|
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")
|
logger.info("Signal: disconnected")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -93,6 +93,16 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
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)
|
self._app = AsyncApp(token=bot_token)
|
||||||
|
|
||||||
# Get our own bot user ID for mention detection
|
# 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
|
except Exception as e: # pragma: no cover - defensive logging
|
||||||
logger.warning("[Slack] Error while closing Socket Mode handler: %s", e, exc_info=True)
|
logger.warning("[Slack] Error while closing Socket Mode handler: %s", e, exc_info=True)
|
||||||
self._running = False
|
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")
|
logger.info("[Slack] Disconnected")
|
||||||
|
|
||||||
async def send(
|
async def send(
|
||||||
|
|||||||
@@ -118,6 +118,17 @@ class WebhookAdapter(BasePlatformAdapter):
|
|||||||
app.router.add_get("/health", self._handle_health)
|
app.router.add_get("/health", self._handle_health)
|
||||||
app.router.add_post("/webhooks/{route_name}", self._handle_webhook)
|
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)
|
self._runner = web.AppRunner(app)
|
||||||
await self._runner.setup()
|
await self._runner.setup()
|
||||||
site = web.TCPSite(self._runner, self._host, self._port)
|
site = web.TCPSite(self._runner, self._host, self._port)
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
|||||||
self._bridge_log_fh = None
|
self._bridge_log_fh = None
|
||||||
self._bridge_log: Optional[Path] = None
|
self._bridge_log: Optional[Path] = None
|
||||||
self._poll_task: Optional[asyncio.Task] = None
|
self._poll_task: Optional[asyncio.Task] = None
|
||||||
|
self._session_lock_identity: Optional[str] = None
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -160,6 +161,29 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
|||||||
|
|
||||||
logger.info("[%s] Bridge found at %s", self.name, bridge_path)
|
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
|
# Auto-install npm dependencies if node_modules doesn't exist
|
||||||
bridge_dir = bridge_path.parent
|
bridge_dir = bridge_path.parent
|
||||||
if not (bridge_dir / "node_modules").exists():
|
if not (bridge_dir / "node_modules").exists():
|
||||||
@@ -313,6 +337,12 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
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)
|
logger.error("[%s] Failed to start bridge: %s", self.name, e, exc_info=True)
|
||||||
self._close_bridge_log()
|
self._close_bridge_log()
|
||||||
return False
|
return False
|
||||||
@@ -371,9 +401,17 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
|||||||
# Bridge was not started by us, don't kill it
|
# Bridge was not started by us, don't kill it
|
||||||
print(f"[{self.name}] Disconnecting (external bridge left running)")
|
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._mark_disconnected()
|
||||||
self._bridge_process = None
|
self._bridge_process = None
|
||||||
self._close_bridge_log()
|
self._close_bridge_log()
|
||||||
|
self._session_lock_identity = None
|
||||||
print(f"[{self.name}] Disconnected")
|
print(f"[{self.name}] Disconnected")
|
||||||
|
|
||||||
async def send(
|
async def send(
|
||||||
|
|||||||
@@ -959,6 +959,13 @@ class GatewayRunner:
|
|||||||
"""
|
"""
|
||||||
logger.info("Starting Hermes Gateway...")
|
logger.info("Starting Hermes Gateway...")
|
||||||
logger.info("Session storage: %s", self.config.sessions_dir)
|
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:
|
try:
|
||||||
from gateway.status import write_runtime_status
|
from gateway.status import write_runtime_status
|
||||||
write_runtime_status(gateway_state="starting", exit_reason=None)
|
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:
|
if mcp_connected:
|
||||||
summary_parts.append(f"{mcp_connected} MCP servers")
|
summary_parts.append(f"{mcp_connected} MCP servers")
|
||||||
summary_parts.append("/help for commands")
|
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)}[/]")
|
right_lines.append(f"[dim {dim}]{' · '.join(summary_parts)}[/]")
|
||||||
|
|
||||||
# Update check — use prefetched result if available
|
# Update check — use prefetched result if available
|
||||||
|
|||||||
@@ -730,6 +730,53 @@ def run_doctor(args):
|
|||||||
except Exception as _e:
|
except Exception as _e:
|
||||||
check_warn("Honcho check failed", str(_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
|
# Summary
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -54,6 +54,71 @@ from typing import Optional
|
|||||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
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.
|
# Load .env from ~/.hermes/.env first, then project root as dev fallback.
|
||||||
# User-managed env files should override stale shell exports on restart.
|
# User-managed env files should override stale shell exports on restart.
|
||||||
from hermes_cli.config import get_hermes_home
|
from hermes_cli.config import get_hermes_home
|
||||||
@@ -2924,7 +2989,35 @@ def cmd_update(args):
|
|||||||
print(" ✓ Skills are up to date")
|
print(" ✓ Skills are up to date")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Skills sync during update failed: %s", 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
|
# Check for config migrations
|
||||||
print()
|
print()
|
||||||
print("→ Checking configuration for new options...")
|
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",
|
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout",
|
||||||
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
|
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
|
||||||
"mcp", "sessions", "insights", "version", "update", "uninstall",
|
"mcp", "sessions", "insights", "version", "update", "uninstall",
|
||||||
|
"profile",
|
||||||
}
|
}
|
||||||
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
|
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
|
||||||
|
|
||||||
@@ -3145,6 +3239,253 @@ def _coalesce_session_name_args(argv: list) -> list:
|
|||||||
return result
|
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():
|
def main():
|
||||||
"""Main entry point for hermes CLI."""
|
"""Main entry point for hermes CLI."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -4332,7 +4673,75 @@ For more help on a command:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
acp_parser.set_defaults(func=cmd_acp)
|
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
|
# 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
|
- **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
|
- **Error handling**: Catch specific exceptions. Use `logger.warning()`/`logger.error()` with `exc_info=True` for unexpected errors
|
||||||
- **Cross-platform**: Never assume Unix (see below)
|
- **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
|
## 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?
|
## Still Stuck?
|
||||||
|
|
||||||
If your issue isn't covered here:
|
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/sessions',
|
||||||
'user-guide/security',
|
'user-guide/security',
|
||||||
'user-guide/docker',
|
'user-guide/docker',
|
||||||
|
'user-guide/profiles',
|
||||||
{
|
{
|
||||||
type: 'category',
|
type: 'category',
|
||||||
label: 'Messaging Gateway',
|
label: 'Messaging Gateway',
|
||||||
@@ -153,6 +154,7 @@ const sidebars: SidebarsConfig = {
|
|||||||
'reference/mcp-config-reference',
|
'reference/mcp-config-reference',
|
||||||
'reference/skills-catalog',
|
'reference/skills-catalog',
|
||||||
'reference/optional-skills-catalog',
|
'reference/optional-skills-catalog',
|
||||||
|
'reference/profile-commands',
|
||||||
'reference/environment-variables',
|
'reference/environment-variables',
|
||||||
'reference/faq',
|
'reference/faq',
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user