Skip to content
Open
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
3 changes: 2 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10177,7 +10177,8 @@ def process_command(self, command: str) -> bool:
elif canonical == "help":
self.show_help()
elif canonical == "profile":
self._handle_profile_command()
if self._handle_profile_command(cmd_original):
return False
elif canonical == "tools":
self._handle_tools_command(cmd_original)
elif canonical == "toolsets":
Expand Down
3 changes: 3 additions & 0 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,9 @@ async def _handle_profile_command(self, event: MessageEvent) -> str:
``_run_agent`` and ``_reset_notice_session_info`` — and the command
reports the active profile and default home, byte-identical to before.
"""
if event.get_command_args().strip():
return "Profile switching is only available in terminal chat."

from hermes_constants import display_hermes_home
from hermes_cli.slash_exec import CommandContext, execute_command

Expand Down
44 changes: 42 additions & 2 deletions hermes_cli/cli_commands_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,10 +765,49 @@ def isatty(self) -> bool:
self.new_session()
_cprint(f"{_DIM}Session reset. New tool configuration is active.{_RST}")

def _handle_profile_command(self):
"""Display active profile name and home directory."""
def _handle_profile_command(self, command: str = "/profile") -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main now routes informational /profile through execute_command("profile", ...) (hermes_cli/cli_commands_mixin.py:694-705) and its shared executor (hermes_cli/slash_exec.py:83-106). Please salvage the switch branch into that command architecture so CLI, gateway, and TUI retain the current status-output parity.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed on the rebased branch.

Status stays on the shared path: bare /profile uses execute_command("profile", …) so CLI / gateway / TUI keep status-output parity. Switching is intentionally outside that executor — sticky write + deferred process relaunch — so we never mutate HERMES_HOME or rebuild tools/prompt mid-conversation.

Resume (new): on /profile <name>, relaunch is now:

--profile <name> --cli|--tui chat [--resume <target-profile last session>]

Lookup is workspace-scoped then global MRU against the target profile’s state.db. Fresh profiles (no sessions) omit --resume and open a clean chat — no hard fail.

Why keep the simple restart (KISS):

  • One clear process boundary; no in-process profile surgery
  • Preserves prompt-cache / toolset isolation (AGENTS.md mid-conversation constraints)
  • Reuses existing --profile + --resume machinery instead of a second live-swap path
  • Easy to reason about and test; fewer failure modes than mid-session bundle swap

Happy to adjust further if maintainers want a different public contract.

"""Display the active profile or relaunch chat under another profile.

Status output stays on the shared ``execute_command("profile")`` path
so CLI/gateway/TUI keep status-output parity. Switching is a process
boundary (sticky selection + deferred relaunch) — not an in-place
HERMES_HOME mutation — and is terminal-CLI only.
"""
from hermes_cli.profiles import get_active_profile_name, set_active_profile
from hermes_cli.slash_exec import CommandContext, execute_command

parts = command.strip().split(maxsplit=1)
target = parts[1].strip() if len(parts) > 1 else ""

if target:
from hermes_cli.profiles import (
build_profile_switch_relaunch_argv,
normalize_profile_name,
)

try:
set_active_profile(target)
except (FileNotFoundError, ValueError) as exc:
print(f" Error: {exc}")
return False

current = get_active_profile_name()
selected = normalize_profile_name(target)
if selected == current:
print(f" Profile '{current}' is already active.")
return False

relaunch_argv = build_profile_switch_relaunch_argv(selected, ui="cli")
if "--resume" in relaunch_argv:
print(
f" Switching to profile '{selected}' "
f"(resuming last session)..."
)
else:
print(f" Switching to profile '{selected}'...")
self._pending_relaunch = relaunch_argv
return True

reply = execute_command("profile", CommandContext(surface="cli"))
profile_name = reply.data["profile"]
display = reply.data["home"]
Expand All @@ -777,6 +816,7 @@ def _handle_profile_command(self):
print(f" Profile: {profile_name}")
print(f" Home: {display}")
print()
return False

def _handle_handoff_command(self, cmd_original: str) -> bool:
"""Handle ``/handoff <platform>`` — transfer this CLI session to a gateway platform.
Expand Down
10 changes: 8 additions & 2 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,14 @@ class CommandDef:
aliases=("ctx",), args_hint="[all]", subcommands=("all",),
busy_policy="dispatch"),
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
CommandDef("profile", "Show active profile name and home directory", "Info",
busy_policy="dispatch", execute="profile"),
CommandDef(
"profile",
"Show the active profile; terminal chat can switch with /profile <name> (resumes last session)",
"Info",
args_hint="[name]",
busy_policy="dispatch",
execute="profile",
),
CommandDef("sethome", "Set this chat as the home channel", "Session",
gateway_only=True, aliases=("set-home",)),
CommandDef("resume", "Resume a previously-named session", "Session",
Expand Down
19 changes: 19 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2439,6 +2439,25 @@ def _launch_tui(
print()
relaunch(["update"], preserve_inherited=False)

# Exit code 43 = standalone TUI selected a profile. The RPC validated and
# persisted the target before the child exited, so relaunch a TUI process
# under that profile (resuming its last session when one exists) instead
# of mutating HERMES_HOME in place.
if code == 43:
from hermes_cli.profiles import (
build_profile_switch_relaunch_argv,
get_active_profile,
)
from hermes_cli.relaunch import relaunch

profile = get_active_profile()
relaunch_argv = build_profile_switch_relaunch_argv(profile, ui="tui")
if "--resume" in relaunch_argv:
print(f"\nSwitching to profile '{profile}' (resuming last session)...\n")
else:
print(f"\nSwitching to profile '{profile}'...\n")
relaunch(relaunch_argv, preserve_inherited=False)

sys.exit(code)


Expand Down
84 changes: 84 additions & 0 deletions hermes_cli/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,90 @@ def profile_exists(name: str) -> bool:
return get_profile_dir(canon).is_dir()


def _current_workspace_key() -> Optional[str]:
"""Workspace identity for cwd-scoped resume (git root, else cwd)."""
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
return os.path.abspath(result.stdout.strip())
except Exception:
pass
try:
return os.getcwd()
except Exception:
return None


def resolve_profile_last_session(
name: str,
*,
source: str = "cli",
workspace_key: Optional[str] = None,
) -> Optional[str]:
"""Most recent session id in a profile's ``state.db`` (read-only).

Mirrors ``hermes -c`` resolution for that profile home: workspace-scoped
MRU first when *workspace_key* is set, then global MRU for *source*.
Returns ``None`` when the profile has no matching session (or no DB).
"""
try:
from hermes_state import SessionDB

db_path = get_profile_dir(name) / "state.db"
if not db_path.exists():
return None
db = SessionDB(db_path=db_path, read_only=True)
try:
if workspace_key:
sessions = db.search_sessions(
source=source, limit=1, workspace_key=workspace_key
)
if sessions:
return sessions[0]["id"]
sessions = db.search_sessions(source=source, limit=1)
return sessions[0]["id"] if sessions else None
finally:
db.close()
except Exception:
return None


def build_profile_switch_relaunch_argv(name: str, *, ui: str = "cli") -> list[str]:
"""Build argv for a clean chat relaunch under *name*, resuming when possible.

Always includes ``--profile`` + chat UI flags. When the target profile has
a prior session for this UI (TUI falls back to CLI sessions), appends
``--resume <id>`` so the relaunch lands on that conversation instead of a
blank chat. Fresh profiles with no sessions get a normal new chat.
"""
selected = normalize_profile_name(name)
if ui == "tui":
argv = ["--profile", selected, "--tui", "chat"]
sources = ("tui", "cli")
else:
argv = ["--profile", selected, "--cli", "chat"]
sources = ("cli",)

ws_key = _current_workspace_key()
session_id: Optional[str] = None
for source in sources:
session_id = resolve_profile_last_session(
selected, source=source, workspace_key=ws_key
)
if session_id:
break
if session_id:
argv.extend(["--resume", session_id])
return argv


# ---------------------------------------------------------------------------
# Alias / wrapper script management
# ---------------------------------------------------------------------------
Expand Down
179 changes: 179 additions & 0 deletions tests/cli/test_profile_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Tests for in-chat profile switching in the classic CLI."""

from types import SimpleNamespace
from unittest.mock import patch

from cli import HermesCLI


def _call(self_, command):
return HermesCLI._handle_profile_command(self_, command)


def test_profile_without_name_shows_runtime_profile(capsys):
self_ = SimpleNamespace(_pending_relaunch=None)

with (
patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"),
patch("hermes_constants.display_hermes_home", return_value="~/.hermes/profiles/coder"),
):
result = _call(self_, "/profile")

assert result is False
assert self_._pending_relaunch is None
output = capsys.readouterr().out
assert "Profile: coder" in output
assert "Home: ~/.hermes/profiles/coder" in output


def test_profile_name_sets_sticky_profile_and_requests_clean_relaunch(capsys):
self_ = SimpleNamespace(_pending_relaunch=None)

with (
patch("hermes_cli.profiles.get_active_profile_name", return_value="default"),
patch("hermes_cli.profiles.set_active_profile") as set_active,
patch(
"hermes_cli.profiles.build_profile_switch_relaunch_argv",
return_value=["--profile", "coder", "--cli", "chat"],
) as build_argv,
):
result = _call(self_, "/profile Coder")

assert result is True
set_active.assert_called_once_with("Coder")
build_argv.assert_called_once_with("coder", ui="cli")
assert self_._pending_relaunch == ["--profile", "coder", "--cli", "chat"]
out = capsys.readouterr().out
assert "Switching to profile 'coder'" in out
assert "resuming" not in out


def test_profile_name_relaunches_with_resume_when_target_has_session(capsys):
self_ = SimpleNamespace(_pending_relaunch=None)
relaunch = [
"--profile",
"coder",
"--cli",
"chat",
"--resume",
"20260811_120000_abcdef",
]

with (
patch("hermes_cli.profiles.get_active_profile_name", return_value="default"),
patch("hermes_cli.profiles.set_active_profile"),
patch(
"hermes_cli.profiles.build_profile_switch_relaunch_argv",
return_value=relaunch,
),
):
result = _call(self_, "/profile coder")

assert result is True
assert self_._pending_relaunch == relaunch
assert "resuming last session" in capsys.readouterr().out


def test_profile_name_error_keeps_current_chat(capsys):
self_ = SimpleNamespace(_pending_relaunch=None)

with patch(
"hermes_cli.profiles.set_active_profile",
side_effect=FileNotFoundError("Profile 'missing' does not exist"),
):
result = _call(self_, "/profile missing")

assert result is False
assert self_._pending_relaunch is None
assert "does not exist" in capsys.readouterr().out


def test_profile_switch_relaunch_argv_includes_selected_profile(tmp_path, monkeypatch, capsys):
"""E2E-ish: sticky write + relaunch argv resolve the selected profile name."""
from hermes_cli.profiles import get_active_profile

profiles_root = tmp_path / "profiles"
coder = profiles_root / "coder"
coder.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "default-home"))
# Point sticky active_profile file into tmp root via profiles helpers.
monkeypatch.setattr(
"hermes_cli.profiles._get_active_profile_path",
lambda: tmp_path / "active_profile",
)
monkeypatch.setattr(
"hermes_cli.profiles._get_profiles_root",
lambda: profiles_root,
)
monkeypatch.setattr(
"hermes_cli.profiles.profile_exists",
lambda name: name == "coder",
)
monkeypatch.setattr(
"hermes_cli.profiles.build_profile_switch_relaunch_argv",
lambda name, *, ui="cli": ["--profile", name, f"--{ui}", "chat"],
)

self_ = SimpleNamespace(_pending_relaunch=None)
with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
result = _call(self_, "/profile coder")

assert result is True
assert get_active_profile() == "coder"
assert self_._pending_relaunch == ["--profile", "coder", "--cli", "chat"]
assert "Switching to profile 'coder'" in capsys.readouterr().out


def test_build_profile_switch_relaunch_argv_appends_resume_when_session_exists(monkeypatch):
from hermes_cli.profiles import build_profile_switch_relaunch_argv

monkeypatch.setattr(
"hermes_cli.profiles._current_workspace_key",
lambda: "/tmp/ws",
)
monkeypatch.setattr(
"hermes_cli.profiles.resolve_profile_last_session",
lambda name, *, source="cli", workspace_key=None: (
"sess-cli" if source == "cli" else None
),
)

assert build_profile_switch_relaunch_argv("Coder", ui="cli") == [
"--profile",
"coder",
"--cli",
"chat",
"--resume",
"sess-cli",
]
assert build_profile_switch_relaunch_argv("coder", ui="tui") == [
"--profile",
"coder",
"--tui",
"chat",
"--resume",
"sess-cli",
]


def test_build_profile_switch_relaunch_argv_omits_resume_when_no_session(monkeypatch):
from hermes_cli.profiles import build_profile_switch_relaunch_argv

monkeypatch.setattr("hermes_cli.profiles._current_workspace_key", lambda: None)
monkeypatch.setattr(
"hermes_cli.profiles.resolve_profile_last_session",
lambda *a, **k: None,
)

assert build_profile_switch_relaunch_argv("coder", ui="cli") == [
"--profile",
"coder",
"--cli",
"chat",
]
assert build_profile_switch_relaunch_argv("coder", ui="tui") == [
"--profile",
"coder",
"--tui",
"chat",
]
Loading