Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 65 additions & 18 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3231,27 +3231,74 @@ def show_toolsets(self):
print(" Example: python cli.py --toolsets web,terminal")
print()

def _handle_profile_command(self):
"""Display active profile name and home directory."""
from hermes_constants import get_hermes_home, display_hermes_home
def _reexec_with_profile(self, profile_name: str) -> None:
"""Replace the current process with Hermes started for profile_name."""
hermes_bin = shutil.which("hermes")
if hermes_bin:
os.execvp(hermes_bin, ["hermes", "--profile", profile_name])
return
os.execvp(
sys.executable,
[sys.executable, "-m", "hermes_cli.main", "--profile", profile_name],
)

home = get_hermes_home()
display = display_hermes_home()
def _handle_profile_command(self, cmd_original: str):
"""Show, list, or switch profiles from the interactive CLI."""
from hermes_constants import display_hermes_home
from hermes_cli.profiles import (
get_active_profile_name,
list_profiles,
set_active_profile,
)

profiles_parent = Path.home() / ".hermes" / "profiles"
try:
rel = home.relative_to(profiles_parent)
profile_name = str(rel).split("/")[0]
except ValueError:
profile_name = None
parts = cmd_original.strip().split()
args = parts[1:] if len(parts) > 1 else []
active_profile = get_active_profile_name()

print()
if profile_name:
print(f" Profile: {profile_name}")
if not args:
print()
print(f" Profile: {active_profile}")
print(f" Home: {display_hermes_home()}")
print()
return

if args == ["list"]:
profiles = list_profiles()
print()
print(f" Active profile: {active_profile}")
print(" Profiles:")
if not profiles:
print(" (none found)")
for profile in profiles:
marker = "*" if profile.name == active_profile else " "
suffix = " (default)" if profile.is_default else ""
print(f" {marker} {profile.name}{suffix}")
print()
return

if len(args) == 1 and args[0] not in {"list", "use"}:
target_profile = args[0]
elif len(args) == 2 and args[0] == "use":
target_profile = args[1]
else:
print(" Profile: default")
print(f" Home: {display}")
print()
print("(._.) Usage: /profile [list|use <name>|<name>]")
return

if target_profile == active_profile:
print(f" Already using profile: {target_profile}")
return

try:
set_active_profile(target_profile)
except (ValueError, FileNotFoundError) as exc:
print(f" Could not switch profile: {exc}")
return

print(f" Switching to profile: {target_profile}")
try:
self._reexec_with_profile(target_profile)
except OSError as exc:
print(f" Could not restart Hermes for profile '{target_profile}': {exc}")

def show_config(self):
"""Display current configuration with kawaii ASCII art."""
Expand Down Expand Up @@ -4400,7 +4447,7 @@ def process_command(self, command: str) -> bool:
elif canonical == "help":
self.show_help()
elif canonical == "profile":
self._handle_profile_command()
self._handle_profile_command(cmd_original)
elif canonical == "tools":
self._handle_tools_command(cmd_original)
elif canonical == "toolsets":
Expand Down
136 changes: 136 additions & 0 deletions tests/test_cli_profile_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""Tests for HermesCLI /profile slash-command behavior."""

from types import SimpleNamespace
from unittest.mock import MagicMock, patch

from cli import HermesCLI


def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.console = MagicMock()
cli_obj.config = {}
cli_obj._pending_input = MagicMock()
cli_obj._app = None
return cli_obj


class TestProfileSlashCommand:
def test_no_arg_profile_uses_active_profile_helpers(self, capsys):
cli_obj = _make_cli()

with patch("hermes_cli.profiles.get_active_profile_name", return_value="custom"), \
patch("hermes_constants.display_hermes_home", return_value="/tmp/hermes-home"):
assert cli_obj.process_command("/profile") is True

output = capsys.readouterr().out
assert "Profile: custom" in output
assert "Home: /tmp/hermes-home" in output
assert "default" not in output

def test_profile_list_marks_active_profile(self, capsys):
cli_obj = _make_cli()
profiles = [
SimpleNamespace(name="default", is_default=True),
SimpleNamespace(name="coder", is_default=False),
SimpleNamespace(name="turing", is_default=False),
]

with patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"), \
patch("hermes_cli.profiles.list_profiles", return_value=profiles):
assert cli_obj.process_command("/profile list") is True

output = capsys.readouterr().out
assert "Active profile: coder" in output
assert "Profiles:" in output
assert "* coder" in output
assert "default (default)" in output
assert "turing" in output

@patch("cli.os.execvp")
@patch("cli.shutil.which", return_value="/usr/local/bin/hermes")
def test_profile_use_switches_and_reexecs_with_hermes_shim(self, mock_which, mock_execvp, capsys):
cli_obj = _make_cli()

with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), \
patch("hermes_cli.profiles.set_active_profile") as mock_set_active:
assert cli_obj.process_command("/profile use coder") is True

output = capsys.readouterr().out
assert "Switching to profile: coder" in output
mock_set_active.assert_called_once_with("coder")
mock_execvp.assert_called_once_with("/usr/local/bin/hermes", ["hermes", "--profile", "coder"])

@patch("cli.os.execvp")
@patch("cli.shutil.which", return_value=None)
def test_profile_name_alias_switches_via_python_module_fallback(self, mock_which, mock_execvp, capsys):
cli_obj = _make_cli()

with patch("cli.sys.executable", "/usr/bin/python3"), \
patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), \
patch("hermes_cli.profiles.set_active_profile") as mock_set_active:
assert cli_obj.process_command("/profile coder") is True

output = capsys.readouterr().out
assert "Switching to profile: coder" in output
mock_set_active.assert_called_once_with("coder")
mock_execvp.assert_called_once_with(
"/usr/bin/python3",
["/usr/bin/python3", "-m", "hermes_cli.main", "--profile", "coder"],
)

@patch("cli.os.execvp")
@patch("cli.shutil.which", return_value="/usr/local/bin/hermes")
def test_same_profile_is_a_noop(self, mock_which, mock_execvp, capsys):
cli_obj = _make_cli()

with patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"), \
patch("hermes_cli.profiles.set_active_profile") as mock_set_active:
assert cli_obj.process_command("/profile coder") is True

output = capsys.readouterr().out
assert "Already using profile: coder" in output
mock_set_active.assert_not_called()
mock_execvp.assert_not_called()

@patch("cli.os.execvp")
@patch("cli.shutil.which", return_value="/usr/local/bin/hermes")
def test_profile_use_missing_name_prints_help_and_does_not_restart(self, mock_which, mock_execvp, capsys):
cli_obj = _make_cli()

with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), \
patch("hermes_cli.profiles.set_active_profile") as mock_set_active:
assert cli_obj.process_command("/profile use") is True

output = capsys.readouterr().out
assert "Usage: /profile [list|use <name>|<name>]" in output
mock_set_active.assert_not_called()
mock_execvp.assert_not_called()

@patch("cli.os.execvp")
@patch("cli.shutil.which", return_value="/usr/local/bin/hermes")
def test_profile_list_extra_args_prints_help_and_does_not_restart(self, mock_which, mock_execvp, capsys):
cli_obj = _make_cli()

with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), \
patch("hermes_cli.profiles.set_active_profile") as mock_set_active:
assert cli_obj.process_command("/profile list extra") is True

output = capsys.readouterr().out
assert "Usage: /profile [list|use <name>|<name>]" in output
mock_set_active.assert_not_called()
mock_execvp.assert_not_called()

@patch("cli.os.execvp")
@patch("cli.shutil.which", return_value="/usr/local/bin/hermes")
def test_profile_use_reports_profile_errors_without_restart(self, mock_which, mock_execvp, capsys):
cli_obj = _make_cli()

with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), \
patch("hermes_cli.profiles.set_active_profile", side_effect=FileNotFoundError("Profile 'ghost' does not exist.")) as mock_set_active:
assert cli_obj.process_command("/profile use ghost") is True

output = capsys.readouterr().out
assert "Could not switch profile" in output
mock_set_active.assert_called_once_with("ghost")
mock_execvp.assert_not_called()
2 changes: 1 addition & 1 deletion website/docs/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| `/insights` | Show usage insights and analytics (last 30 days) |
| `/platforms` (alias: `/gateway`) | Show gateway/messaging platform status |
| `/paste` | Check clipboard for an image and attach it |
| `/profile` | Show active profile name and home directory |
| `/profile` | Show the active profile, list available profiles, or switch CLI profiles (`/profile`, `/profile list`, `/profile use <name>`, `/profile <name>`). Switching restarts the CLI into the target profile. |

### Exit

Expand Down
15 changes: 15 additions & 0 deletions website/docs/user-guide/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,21 @@ The CLI always shows which profile is active:
- **Banner**: Shows `Profile: coder` on startup
- **`hermes profile`**: Shows current profile name, path, model, gateway status

### Interactive CLI `/profile`

Inside the interactive CLI, `/profile` is also a slash command for checking and switching profiles without leaving chat:

```text
/profile # show the active profile and home directory
/profile list # list profiles and mark the active one
/profile use coder # switch to coder
/profile coder # shorthand for /profile use coder
```

When you switch profiles from the CLI, Hermes restarts into the target profile. That rebuilds config, paths, dotenv loading, and state cleanly instead of trying to hot-swap profile state in-process.

This behavior is CLI-only. Messaging surfaces do not support `/profile` switching.

## Running gateways

Each profile runs its own gateway as a separate process with its own bot token:
Expand Down