diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 75e55b2cd212..b3af3fb0ac5d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3555,13 +3555,14 @@ def cmd_profile(args): else: print(f"Cloned config, .env, SOUL.md from {source_label}.") - # Auto-clone Honcho config for the new profile - try: - from honcho_integration.cli import clone_honcho_for_profile - if clone_honcho_for_profile(name): - print(f"Honcho config cloned (host: hermes.{name})") - except Exception: - pass # Honcho not installed or not configured + # Auto-clone Honcho config for the new profile (only with --clone/--clone-all) + if clone or clone_all: + try: + from honcho_integration.cli import clone_honcho_for_profile + if clone_honcho_for_profile(name): + print(f"Honcho config cloned (peer: {name})") + except Exception: + pass # Honcho not installed or not configured # Seed bundled skills (skip if --clone-all already copied them) if not clone_all: diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index 51f686dea7e7..36f06d727a3e 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -55,7 +55,8 @@ def clone_honcho_for_profile(profile_name: str) -> bool: # AI peer is profile-specific; workspace is shared so all profiles # see the same user context, sessions, and project history. - new_block["aiPeer"] = new_host + # Use the bare profile name as the peer identity (not the host key). + new_block["aiPeer"] = profile_name new_block["workspace"] = default_block.get("workspace") or cfg.get("workspace") or HOST new_block["enabled"] = default_block.get("enabled", True) @@ -112,7 +113,9 @@ def cmd_enable(args) -> None: peer_name = default_block.get("peerName") or cfg.get("peerName") if peer_name and "peerName" not in block: block["peerName"] = peer_name - block.setdefault("aiPeer", host) + # Use bare profile name as AI peer, not the host key + ai_peer = host.split(".", 1)[1] if "." in host else host + block.setdefault("aiPeer", ai_peer) block.setdefault("workspace", default_block.get("workspace") or cfg.get("workspace") or HOST) _write_config(cfg) @@ -422,7 +425,7 @@ def cmd_setup(args) -> None: try: from honcho_integration.client import HonchoClientConfig, get_honcho_client, reset_honcho_client reset_honcho_client() - hcfg = HonchoClientConfig.from_global_config() + hcfg = HonchoClientConfig.from_global_config(host=_host_key()) get_honcho_client(hcfg) print("OK") except Exception as e: @@ -517,7 +520,7 @@ def cmd_status(args) -> None: try: from honcho_integration.client import HonchoClientConfig, get_honcho_client - hcfg = HonchoClientConfig.from_global_config() + hcfg = HonchoClientConfig.from_global_config(host=_host_key()) except Exception as e: print(f" Config error: {e}\n") return @@ -836,7 +839,7 @@ def cmd_identity(args) -> None: try: from honcho_integration.client import HonchoClientConfig, get_honcho_client from honcho_integration.session import HonchoSessionManager - hcfg = HonchoClientConfig.from_global_config() + hcfg = HonchoClientConfig.from_global_config(host=_host_key()) client = get_honcho_client(hcfg) mgr = HonchoSessionManager(honcho=client, config=hcfg) session_key = hcfg.resolve_session_name() diff --git a/honcho_integration/client.py b/honcho_integration/client.py index 6a567b073406..09606af2409f 100644 --- a/honcho_integration/client.py +++ b/honcho_integration/client.py @@ -56,13 +56,22 @@ def resolve_active_host() -> str: def resolve_config_path() -> Path: """Return the active Honcho config path. - Checks $HERMES_HOME/honcho.json first (instance-local), then falls back - to ~/.honcho/config.json (global). Returns the global path if neither - exists (for first-time setup writes). + Resolution order: + 1. $HERMES_HOME/honcho.json (profile-local, if it exists) + 2. ~/.hermes/honcho.json (default profile — shared host blocks live here) + 3. ~/.honcho/config.json (global, cross-app interop) + + Returns the global path if none exist (for first-time setup writes). """ local_path = get_hermes_home() / "honcho.json" if local_path.exists(): return local_path + + # Default profile's config — host blocks accumulate here via setup/clone + default_path = Path.home() / ".hermes" / "honcho.json" + if default_path != local_path and default_path.exists(): + return default_path + return GLOBAL_CONFIG_PATH diff --git a/honcho_integration/session.py b/honcho_integration/session.py index 23b96d1cb196..b86ceae0ad22 100644 --- a/honcho_integration/session.py +++ b/honcho_integration/session.py @@ -157,16 +157,24 @@ def _get_or_create_honcho_session( logger.debug("Honcho session '%s' retrieved from cache", session_id) return self._sessions_cache[session_id], [] + # honcho.session() makes a get-or-create API call on the server. + # This ensures the session exists before add_peers / context calls. session = self.honcho.session(session_id) # Configure peer observation settings. # observe_me=True for AI peer so Honcho watches what the agent says # and builds its representation over time — enabling identity formation. - from honcho.session import SessionPeerConfig - user_config = SessionPeerConfig(observe_me=True, observe_others=True) - ai_config = SessionPeerConfig(observe_me=True, observe_others=True) + try: + from honcho.session import SessionPeerConfig + user_config = SessionPeerConfig(observe_me=True, observe_others=True) + ai_config = SessionPeerConfig(observe_me=True, observe_others=True) - session.add_peers([(user_peer, user_config), (assistant_peer, ai_config)]) + session.add_peers([(user_peer, user_config), (assistant_peer, ai_config)]) + except Exception as e: + logger.warning( + "Honcho session '%s' add_peers failed (non-fatal): %s", + session_id, e, + ) # Load existing messages via context() - single call for messages + metadata existing_messages = [] @@ -231,7 +239,7 @@ def get_or_create(self, key: str) -> HonchoSession: chat_id = parts[1] if len(parts) > 1 else key user_peer_id = self._sanitize_id(f"user-{channel}-{chat_id}") - assistant_peer_id = ( + assistant_peer_id = self._sanitize_id( self._config.ai_peer if self._config else "hermes-assistant" ) diff --git a/tests/honcho_integration/test_cli.py b/tests/honcho_integration/test_cli.py index ed4337061dcb..0c17f9ab4aea 100644 --- a/tests/honcho_integration/test_cli.py +++ b/tests/honcho_integration/test_cli.py @@ -60,7 +60,7 @@ def test_clones_default_settings_to_new_profile(self, tmp_path): assert new_block["memoryMode"] == "honcho" assert new_block["recallMode"] == "tools" assert new_block["writeFrequency"] == "turn" - assert new_block["aiPeer"] == "hermes.coder" + assert new_block["aiPeer"] == "coder" assert new_block["workspace"] == "hermes" # shared, not profile-derived assert new_block["enabled"] is True @@ -117,7 +117,7 @@ def test_works_with_api_key_only_no_host_block(self, tmp_path): assert result is True cfg = json.loads(config_file.read_text()) - assert cfg["hosts"]["hermes.coder"]["aiPeer"] == "hermes.coder" + assert cfg["hosts"]["hermes.coder"]["aiPeer"] == "coder" assert cfg["hosts"]["hermes.coder"]["workspace"] == "hermes" # shared diff --git a/tests/honcho_integration/test_client.py b/tests/honcho_integration/test_client.py index 655e786c4322..a347df9788cd 100644 --- a/tests/honcho_integration/test_client.py +++ b/tests/honcho_integration/test_client.py @@ -346,15 +346,18 @@ def test_prefers_hermes_home_when_exists(self, tmp_path): def test_falls_back_to_global_when_no_local(self, tmp_path): hermes_home = tmp_path / "hermes" hermes_home.mkdir() - # No honcho.json in HERMES_HOME - - with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + # No honcho.json in HERMES_HOME — and no ~/.hermes/honcho.json either + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), \ + patch.object(Path, "home", staticmethod(lambda: tmp_path)): result = resolve_config_path() assert result == GLOBAL_CONFIG_PATH - def test_falls_back_to_global_without_hermes_home_env(self): - with patch.dict(os.environ, {}, clear=False): - os.environ.pop("HERMES_HOME", None) + def test_falls_back_to_global_without_hermes_home_env(self, tmp_path): + # Point HERMES_HOME to a temp dir that has NO honcho.json + empty_home = tmp_path / ".hermes" + empty_home.mkdir() + with patch.dict(os.environ, {"HERMES_HOME": str(empty_home)}, clear=False), \ + patch.object(Path, "home", staticmethod(lambda: tmp_path)): result = resolve_config_path() assert result == GLOBAL_CONFIG_PATH diff --git a/tests/honcho_integration/test_config_isolation.py b/tests/honcho_integration/test_config_isolation.py index 4d9898e681d4..96e8141b342c 100644 --- a/tests/honcho_integration/test_config_isolation.py +++ b/tests/honcho_integration/test_config_isolation.py @@ -92,7 +92,7 @@ def test_explicit_path_override_still_works(self, isolated_home): class TestReadConfigFallback: - """_read_config falls back to global when no local file exists.""" + """_read_config falls back through the config chain.""" def test_reads_local_when_exists(self, isolated_home): isolated_home["local_config"].write_text( @@ -121,6 +121,69 @@ def test_local_takes_priority_over_global(self, isolated_home): assert cfg["source"] == "local" +class TestDefaultProfileFallback: + """Non-default profiles fall back to ~/.hermes/honcho.json (default profile config).""" + + def test_profile_reads_default_profile_config(self, tmp_path, monkeypatch): + """A non-default profile with no local honcho.json reads ~/.hermes/honcho.json.""" + home = tmp_path / "home" + default_hermes = home / ".hermes" + default_hermes.mkdir(parents=True) + profile_hermes = home / ".hermes" / "profiles" / "coder" + profile_hermes.mkdir(parents=True) + global_dir = home / ".honcho" + global_dir.mkdir(parents=True) + + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + + # Default profile has honcho.json with host blocks + default_config = default_hermes / "honcho.json" + default_config.write_text(json.dumps({ + "apiKey": "key", + "hosts": { + "hermes": {"peerName": "alice"}, + "hermes.coder": {"peerName": "alice", "aiPeer": "hermes.coder"}, + }, + })) + + # Global config has different data + (global_dir / "config.json").write_text(json.dumps({"source": "global"})) + + import honcho_integration.client as _client_mod + import honcho_integration.cli as _cli_mod + monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_dir / "config.json") + monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_dir / "config.json") + + # Profile's HERMES_HOME points to its own dir (no honcho.json) + monkeypatch.setenv("HERMES_HOME", str(profile_hermes)) + assert not (profile_hermes / "honcho.json").exists() + + # Should find ~/.hermes/honcho.json, not fall through to global + cfg = _read_config() + assert cfg.get("apiKey") == "key" + assert "hermes.coder" in cfg.get("hosts", {}) + + def test_default_profile_skips_self(self, tmp_path, monkeypatch): + """Default profile doesn't double-read its own config.""" + home = tmp_path / "home" + default_hermes = home / ".hermes" + default_hermes.mkdir(parents=True) + + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + monkeypatch.setenv("HERMES_HOME", str(default_hermes)) + + # No honcho.json anywhere + import honcho_integration.client as _client_mod + import honcho_integration.cli as _cli_mod + global_cfg = home / ".honcho" / "config.json" + global_cfg.parent.mkdir(parents=True) + monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_cfg) + monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_cfg) + + cfg = _read_config() + assert cfg == {} + + class TestMultiProfileIsolation: """Two profiles writing config don't interfere with each other.""" diff --git a/tests/honcho_integration/test_profile_capabilities.py b/tests/honcho_integration/test_profile_capabilities.py new file mode 100644 index 000000000000..16b3d16e70cd --- /dev/null +++ b/tests/honcho_integration/test_profile_capabilities.py @@ -0,0 +1,700 @@ +"""Tests for Honcho profile capabilities (PR #4616). + +Covers: clone_honcho_for_profile, cmd_enable, cmd_disable, cmd_sync, +cmd_status --all, cmd_peers, _host_key, _all_profile_host_configs, +--target-profile flag, and honcho_command routing. +""" + +import json +import os +from io import StringIO +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch, MagicMock + +import pytest + +from honcho_integration.cli import ( + _host_key, + _all_profile_host_configs, + _read_config, + _write_config, + clone_honcho_for_profile, + cmd_enable, + cmd_disable, + cmd_sync, + cmd_peers, + honcho_command, + sync_honcho_profiles_quiet, +) + + +# ── Fixtures ──────────────────────────────────────────────────────────────── + + +class FakeProfile: + def __init__(self, name): + self.name = name + self.is_default = name == "default" + + +@pytest.fixture +def honcho_env(tmp_path, monkeypatch): + """Isolated Honcho config environment for testing.""" + hermes_home = tmp_path / "hermes_home" + hermes_home.mkdir() + config_file = hermes_home / "honcho.json" + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Patch _config_path and _local_config_path to use our temp file + monkeypatch.setattr( + "honcho_integration.cli._config_path", lambda: config_file + ) + monkeypatch.setattr( + "honcho_integration.cli._local_config_path", lambda: config_file + ) + # Prevent real peer creation + monkeypatch.setattr( + "honcho_integration.cli._ensure_peer_exists", lambda host_key=None: True + ) + + return {"config_file": config_file, "hermes_home": hermes_home} + + +def _write_cfg(path, data): + path.write_text(json.dumps(data, indent=2)) + + +def _read_cfg(path): + return json.loads(path.read_text()) + + +# ── _host_key() ───────────────────────────────────────────────────────────── + + +class TestHostKey: + def test_default_profile_returns_hermes(self, monkeypatch): + import honcho_integration.cli as mod + monkeypatch.setattr(mod, "_profile_override", None) + with patch("honcho_integration.cli.resolve_active_host", return_value="hermes"): + assert _host_key() == "hermes" + + def test_profile_override_default_returns_base_host(self, monkeypatch): + import honcho_integration.cli as mod + monkeypatch.setattr(mod, "_profile_override", "default") + assert _host_key() == "hermes" + + def test_profile_override_custom_returns_base_host(self, monkeypatch): + import honcho_integration.cli as mod + monkeypatch.setattr(mod, "_profile_override", "custom") + assert _host_key() == "hermes" + + def test_profile_override_named_returns_scoped(self, monkeypatch): + import honcho_integration.cli as mod + monkeypatch.setattr(mod, "_profile_override", "coder") + assert _host_key() == "hermes.coder" + + def test_no_override_delegates_to_resolve(self, monkeypatch): + import honcho_integration.cli as mod + monkeypatch.setattr(mod, "_profile_override", None) + with patch("honcho_integration.cli.resolve_active_host", return_value="hermes.dreamer"): + assert _host_key() == "hermes.dreamer" + + +# ── _all_profile_host_configs() ───────────────────────────────────────────── + + +class TestAllProfileHostConfigs: + def test_returns_default_and_named_profiles(self, honcho_env): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": { + "hermes": {"peerName": "alice"}, + "hermes.coder": {"peerName": "alice-code"}, + }, + }) + + profiles = [FakeProfile("default"), FakeProfile("coder")] + with patch("hermes_cli.profiles.list_profiles", return_value=profiles), \ + patch("honcho_integration.cli._active_profile_name", return_value="default"): + rows = _all_profile_host_configs() + + assert len(rows) == 2 + names = [r[0] for r in rows] + assert "default" in names + assert "coder" in names + + # Default profile should have its host block + default_row = [r for r in rows if r[0] == "default"][0] + assert default_row[1] == "hermes" + assert default_row[2].get("peerName") == "alice" + + # Coder profile gets hermes.coder + coder_row = [r for r in rows if r[0] == "coder"][0] + assert coder_row[1] == "hermes.coder" + assert coder_row[2].get("peerName") == "alice-code" + + def test_missing_host_block_returns_empty_dict(self, honcho_env): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"peerName": "alice"}}, + }) + + profiles = [FakeProfile("default"), FakeProfile("newprofile")] + with patch("hermes_cli.profiles.list_profiles", return_value=profiles), \ + patch("honcho_integration.cli._active_profile_name", return_value="default"): + rows = _all_profile_host_configs() + + new_row = [r for r in rows if r[0] == "newprofile"][0] + assert new_row[1] == "hermes.newprofile" + assert new_row[2] == {} # no host block yet + + def test_profiles_import_failure_returns_active_only(self, honcho_env): + _write_cfg(honcho_env["config_file"], {"apiKey": "key"}) + + with patch("honcho_integration.cli._active_profile_name", return_value="default"), \ + patch("hermes_cli.profiles.list_profiles", side_effect=ImportError("nope")): + rows = _all_profile_host_configs() + + assert len(rows) == 1 + assert rows[0][0] == "default" + + +# ── cmd_enable ────────────────────────────────────────────────────────────── + + +class TestCmdEnable: + def test_enables_existing_profile(self, honcho_env, capsys, monkeypatch): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes.coder": {"enabled": False, "aiPeer": "hermes.coder"}}, + }) + monkeypatch.setattr("honcho_integration.cli._host_key", lambda: "hermes.coder") + + cmd_enable(SimpleNamespace()) + + cfg = _read_cfg(honcho_env["config_file"]) + assert cfg["hosts"]["hermes.coder"]["enabled"] is True + output = capsys.readouterr().out + assert "enabled" in output.lower() + + def test_already_enabled_prints_message(self, honcho_env, capsys, monkeypatch): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"enabled": True, "aiPeer": "hermes"}}, + }) + monkeypatch.setattr("honcho_integration.cli._host_key", lambda: "hermes") + + cmd_enable(SimpleNamespace()) + + output = capsys.readouterr().out + assert "already enabled" in output.lower() + + def test_enable_new_profile_clones_from_default(self, honcho_env, capsys, monkeypatch): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": { + "hermes": { + "memoryMode": "honcho", + "recallMode": "tools", + "peerName": "alice", + "workspace": "shared", + }, + }, + }) + monkeypatch.setattr("honcho_integration.cli._host_key", lambda: "hermes.writer") + + cmd_enable(SimpleNamespace()) + + cfg = _read_cfg(honcho_env["config_file"]) + block = cfg["hosts"]["hermes.writer"] + assert block["enabled"] is True + assert block["memoryMode"] == "honcho" + assert block["recallMode"] == "tools" + assert block["peerName"] == "alice" + assert block["aiPeer"] == "writer" + assert block["workspace"] == "shared" + + +# ── cmd_disable ───────────────────────────────────────────────────────────── + + +class TestCmdDisable: + def test_disables_profile(self, honcho_env, capsys, monkeypatch): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes.coder": {"enabled": True}}, + }) + monkeypatch.setattr("honcho_integration.cli._host_key", lambda: "hermes.coder") + + cmd_disable(SimpleNamespace()) + + cfg = _read_cfg(honcho_env["config_file"]) + assert cfg["hosts"]["hermes.coder"]["enabled"] is False + + def test_already_disabled_prints_message(self, honcho_env, capsys, monkeypatch): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"enabled": False}}, + }) + monkeypatch.setattr("honcho_integration.cli._host_key", lambda: "hermes") + + cmd_disable(SimpleNamespace()) + + output = capsys.readouterr().out + assert "already disabled" in output.lower() + + def test_disable_nonexistent_block_prints_message(self, honcho_env, capsys, monkeypatch): + _write_cfg(honcho_env["config_file"], {"apiKey": "key", "hosts": {}}) + monkeypatch.setattr("honcho_integration.cli._host_key", lambda: "hermes.ghost") + + cmd_disable(SimpleNamespace()) + + output = capsys.readouterr().out + assert "already disabled" in output.lower() + + +# ── cmd_sync ──────────────────────────────────────────────────────────────── + + +class TestCmdSync: + def test_syncs_new_profiles(self, honcho_env, capsys): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"peerName": "alice", "memoryMode": "honcho"}}, + }) + + profiles = [FakeProfile("default"), FakeProfile("coder"), FakeProfile("dreamer")] + with patch("hermes_cli.profiles.list_profiles", return_value=profiles): + cmd_sync(SimpleNamespace()) + + cfg = _read_cfg(honcho_env["config_file"]) + assert "hermes.coder" in cfg["hosts"] + assert "hermes.dreamer" in cfg["hosts"] + + output = capsys.readouterr().out + assert "coder" in output + assert "dreamer" in output + assert "2 profile(s) synced" in output + + def test_sync_skips_already_configured(self, honcho_env, capsys): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": { + "hermes": {"peerName": "alice"}, + "hermes.coder": {"peerName": "existing"}, + }, + }) + + profiles = [FakeProfile("default"), FakeProfile("coder")] + with patch("hermes_cli.profiles.list_profiles", return_value=profiles): + cmd_sync(SimpleNamespace()) + + output = capsys.readouterr().out + assert "All profiles already have Honcho config" in output + + def test_sync_with_no_config_prints_error(self, honcho_env, capsys): + _write_cfg(honcho_env["config_file"], {}) + + cmd_sync(SimpleNamespace()) + + output = capsys.readouterr().out + assert "No Honcho config found" in output + + def test_sync_with_no_default_block_but_api_key_works(self, honcho_env, capsys): + _write_cfg(honcho_env["config_file"], {"apiKey": "key"}) + + profiles = [FakeProfile("default"), FakeProfile("writer")] + with patch("hermes_cli.profiles.list_profiles", return_value=profiles): + cmd_sync(SimpleNamespace()) + + cfg = _read_cfg(honcho_env["config_file"]) + assert "hermes.writer" in cfg["hosts"] + + +# ── cmd_peers ─────────────────────────────────────────────────────────────── + + +class TestCmdPeers: + def test_shows_all_profile_peers(self, honcho_env, capsys): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": { + "hermes": {"peerName": "alice", "aiPeer": "hermes"}, + "hermes.coder": {"peerName": "alice-code", "aiPeer": "hermes.coder"}, + }, + }) + + profiles = [FakeProfile("default"), FakeProfile("coder")] + with patch("hermes_cli.profiles.list_profiles", return_value=profiles), \ + patch("honcho_integration.cli._active_profile_name", return_value="default"): + cmd_peers(SimpleNamespace()) + + output = capsys.readouterr().out + assert "alice" in output + assert "hermes.coder" in output + assert "alice-code" in output + + def test_shows_not_set_for_missing_peers(self, honcho_env, capsys): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {}}, + }) + + profiles = [FakeProfile("default")] + with patch("hermes_cli.profiles.list_profiles", return_value=profiles), \ + patch("honcho_integration.cli._active_profile_name", return_value="default"): + cmd_peers(SimpleNamespace()) + + output = capsys.readouterr().out + assert "(not set)" in output + + +# ── --target-profile routing ──────────────────────────────────────────────── + + +class TestTargetProfile: + def test_target_profile_sets_override(self, honcho_env, monkeypatch): + """--target-profile should route to the specified profile's host block.""" + import honcho_integration.cli as mod + + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": { + "hermes": {"enabled": True}, + "hermes.coder": {"enabled": True, "aiPeer": "hermes.coder"}, + }, + }) + + # Simulate: hermes honcho --target-profile coder disable + args = SimpleNamespace(target_profile="coder", honcho_command="disable") + + honcho_command(args) + + cfg = _read_cfg(honcho_env["config_file"]) + assert cfg["hosts"]["hermes.coder"]["enabled"] is False + # Default should be untouched + assert cfg["hosts"]["hermes"]["enabled"] is True + + # Clean up global state + mod._profile_override = None + + def test_target_profile_enable_for_new_profile(self, honcho_env, monkeypatch): + """Enable with --target-profile on a profile that has no block yet.""" + import honcho_integration.cli as mod + + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": { + "hermes": {"peerName": "alice", "memoryMode": "hybrid"}, + }, + }) + + args = SimpleNamespace(target_profile="dreamer", honcho_command="enable") + honcho_command(args) + + cfg = _read_cfg(honcho_env["config_file"]) + assert "hermes.dreamer" in cfg["hosts"] + assert cfg["hosts"]["hermes.dreamer"]["enabled"] is True + assert cfg["hosts"]["hermes.dreamer"]["aiPeer"] == "dreamer" + + mod._profile_override = None + + def test_target_profile_default_maps_to_base_host(self, honcho_env, monkeypatch): + """--target-profile default should use 'hermes' as the host key.""" + import honcho_integration.cli as mod + + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"enabled": True}}, + }) + + args = SimpleNamespace(target_profile="default", honcho_command="disable") + honcho_command(args) + + cfg = _read_cfg(honcho_env["config_file"]) + assert cfg["hosts"]["hermes"]["enabled"] is False + + mod._profile_override = None + + +# ── clone_honcho_for_profile edge cases ───────────────────────────────────── + + +class TestCloneEdgeCases: + def test_clone_calls_ensure_peer_exists(self, honcho_env, monkeypatch): + """Cloning should eagerly create the peer.""" + calls = [] + monkeypatch.setattr( + "honcho_integration.cli._ensure_peer_exists", + lambda host_key=None: (calls.append(host_key), True)[1], + ) + + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"peerName": "alice"}}, + }) + + clone_honcho_for_profile("coder") + assert "hermes.coder" in calls + + def test_clone_shares_workspace_not_profile_derived(self, honcho_env): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"workspace": "team-workspace"}}, + }) + + clone_honcho_for_profile("analyst") + + cfg = _read_cfg(honcho_env["config_file"]) + assert cfg["hosts"]["hermes.analyst"]["workspace"] == "team-workspace" + + def test_clone_uses_hermes_as_default_workspace(self, honcho_env): + """When no workspace is set anywhere, defaults to 'hermes'.""" + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {}}, + }) + + clone_honcho_for_profile("tester") + + cfg = _read_cfg(honcho_env["config_file"]) + assert cfg["hosts"]["hermes.tester"]["workspace"] == "hermes" + + def test_clone_inherits_enabled_state(self, honcho_env): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"enabled": False, "peerName": "alice"}}, + }) + + clone_honcho_for_profile("quiet") + + cfg = _read_cfg(honcho_env["config_file"]) + assert cfg["hosts"]["hermes.quiet"]["enabled"] is False + + def test_clone_copies_dialectic_reasoning_level(self, honcho_env): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"dialecticReasoningLevel": "high"}}, + }) + + clone_honcho_for_profile("thinker") + + cfg = _read_cfg(honcho_env["config_file"]) + assert cfg["hosts"]["hermes.thinker"]["dialecticReasoningLevel"] == "high" + + def test_clone_does_not_copy_unknown_keys(self, honcho_env): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"customField": "should-not-copy", "peerName": "alice"}}, + }) + + clone_honcho_for_profile("strict") + + cfg = _read_cfg(honcho_env["config_file"]) + assert "customField" not in cfg["hosts"]["hermes.strict"] + assert cfg["hosts"]["hermes.strict"]["peerName"] == "alice" + + +# ── sync_honcho_profiles_quiet edge cases ─────────────────────────────────── + + +class TestSyncQuietEdgeCases: + def test_sync_quiet_no_profiles_module(self, honcho_env, monkeypatch): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {}}, + }) + with patch("hermes_cli.profiles.list_profiles", side_effect=ImportError): + count = sync_honcho_profiles_quiet() + assert count == 0 + + def test_sync_quiet_no_api_key_no_default_block(self, honcho_env, monkeypatch): + _write_cfg(honcho_env["config_file"], {"hosts": {}}) + monkeypatch.delenv("HONCHO_API_KEY", raising=False) + + count = sync_honcho_profiles_quiet() + assert count == 0 + + def test_sync_quiet_with_env_api_key(self, honcho_env, monkeypatch): + """Should sync even when apiKey is in env, not config.""" + _write_cfg(honcho_env["config_file"], { + "hosts": {"hermes": {"peerName": "alice"}}, + }) + monkeypatch.setenv("HONCHO_API_KEY", "env-key") + + profiles = [FakeProfile("default"), FakeProfile("envprofile")] + with patch("hermes_cli.profiles.list_profiles", return_value=profiles): + count = sync_honcho_profiles_quiet() + + assert count == 1 + cfg = _read_cfg(honcho_env["config_file"]) + assert "hermes.envprofile" in cfg["hosts"] + + +# ── honcho_command routing ────────────────────────────────────────────────── + + +class TestHonchoCommandRouting: + def test_routes_enable(self, honcho_env, monkeypatch, capsys): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"enabled": False, "aiPeer": "hermes"}}, + }) + monkeypatch.setattr("honcho_integration.cli._host_key", lambda: "hermes") + + import honcho_integration.cli as mod + args = SimpleNamespace(target_profile=None, honcho_command="enable") + honcho_command(args) + mod._profile_override = None + + cfg = _read_cfg(honcho_env["config_file"]) + assert cfg["hosts"]["hermes"]["enabled"] is True + + def test_routes_disable(self, honcho_env, monkeypatch, capsys): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"enabled": True}}, + }) + monkeypatch.setattr("honcho_integration.cli._host_key", lambda: "hermes") + + import honcho_integration.cli as mod + args = SimpleNamespace(target_profile=None, honcho_command="disable") + honcho_command(args) + mod._profile_override = None + + cfg = _read_cfg(honcho_env["config_file"]) + assert cfg["hosts"]["hermes"]["enabled"] is False + + def test_routes_sync(self, honcho_env, capsys): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"peerName": "alice"}}, + }) + + profiles = [FakeProfile("default"), FakeProfile("coder")] + import honcho_integration.cli as mod + with patch("hermes_cli.profiles.list_profiles", return_value=profiles): + args = SimpleNamespace(target_profile=None, honcho_command="sync") + honcho_command(args) + mod._profile_override = None + + cfg = _read_cfg(honcho_env["config_file"]) + assert "hermes.coder" in cfg["hosts"] + + def test_routes_peers(self, honcho_env, capsys): + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": {"hermes": {"peerName": "alice"}}, + }) + + profiles = [FakeProfile("default")] + import honcho_integration.cli as mod + with patch("hermes_cli.profiles.list_profiles", return_value=profiles), \ + patch("honcho_integration.cli._active_profile_name", return_value="default"): + args = SimpleNamespace(target_profile=None, honcho_command="peers") + honcho_command(args) + mod._profile_override = None + + output = capsys.readouterr().out + assert "alice" in output + + def test_unknown_command_prints_error(self, honcho_env, capsys): + import honcho_integration.cli as mod + args = SimpleNamespace(target_profile=None, honcho_command="foobar") + honcho_command(args) + mod._profile_override = None + + output = capsys.readouterr().out + assert "Unknown honcho command" in output + + +# ── Integration: full enable→disable→enable cycle ────────────────────────── + + +# ── cmd_status with --target-profile ──────────────────────────────────────── + + +class TestCmdStatusTargetProfile: + """Verify cmd_status passes _host_key() to from_global_config so + --target-profile actually shows the targeted profile's config.""" + + def test_status_uses_host_key_for_config(self, honcho_env, monkeypatch, capsys): + """from_global_config must receive the overridden host, not the + default active host.""" + import honcho_integration.cli as mod + + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": { + "hermes": {"peerName": "alice", "aiPeer": "hermes", "enabled": True}, + "hermes.dreamer": {"peerName": "alice", "aiPeer": "dreamer", "enabled": True}, + }, + }) + + # Track what host= value gets passed to from_global_config + captured_hosts = [] + original_from_global = None + + from honcho_integration.client import HonchoClientConfig + original_from_global = HonchoClientConfig.from_global_config + + def spy_from_global_config(**kwargs): + captured_hosts.append(kwargs.get("host")) + # Return a minimal config that won't trigger connection + return HonchoClientConfig( + host=kwargs.get("host", "hermes"), + ai_peer=kwargs.get("host", "hermes"), + enabled=False, # skip connection attempt + ) + + monkeypatch.setattr(HonchoClientConfig, "from_global_config", + staticmethod(spy_from_global_config)) + + # Simulate --target-profile dreamer + mod._profile_override = "dreamer" + try: + cmd_status_args = SimpleNamespace(all=False) + # Import honcho at module scope so the import check passes + monkeypatch.setitem(__import__("sys").modules, "honcho", MagicMock()) + from honcho_integration.cli import cmd_status + cmd_status(cmd_status_args) + finally: + mod._profile_override = None + + # The critical assertion: from_global_config was called with host="hermes.dreamer" + assert "hermes.dreamer" in captured_hosts + + +# ── Integration: full enable→disable→enable cycle ────────────────────────── + + +class TestEnableDisableCycle: + def test_full_cycle(self, honcho_env, monkeypatch, capsys): + """Enable, disable, then re-enable a profile and verify state.""" + _write_cfg(honcho_env["config_file"], { + "apiKey": "key", + "hosts": { + "hermes": {"peerName": "alice", "memoryMode": "hybrid"}, + }, + }) + monkeypatch.setattr("honcho_integration.cli._host_key", lambda: "hermes.tester") + + # Enable (should create block) + cmd_enable(SimpleNamespace()) + cfg = _read_cfg(honcho_env["config_file"]) + assert cfg["hosts"]["hermes.tester"]["enabled"] is True + assert cfg["hosts"]["hermes.tester"]["aiPeer"] == "tester" + assert cfg["hosts"]["hermes.tester"]["memoryMode"] == "hybrid" + + # Disable + cmd_disable(SimpleNamespace()) + cfg = _read_cfg(honcho_env["config_file"]) + assert cfg["hosts"]["hermes.tester"]["enabled"] is False + + # Re-enable (block already exists with aiPeer, should just flip) + capsys.readouterr() # clear + cmd_enable(SimpleNamespace()) + cfg = _read_cfg(honcho_env["config_file"]) + assert cfg["hosts"]["hermes.tester"]["enabled"] is True diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index 4adb015c2c3c..7af4afeffeb2 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -352,6 +352,12 @@ hermes honcho tokens --dialectic N # Set dialectic char cap hermes honcho identity # Show AI peer identity hermes honcho identity # Seed AI peer identity from file (SOUL.md, etc.) hermes honcho migrate # Migration guide: OpenClaw → Hermes + Honcho +hermes honcho enable # Enable Honcho for the active profile +hermes honcho disable # Disable Honcho for the active profile +hermes honcho sync # Create host blocks for all profiles missing one +hermes honcho status --all # Overview of Honcho config across all profiles +hermes honcho peers # Peer identities across all profiles +hermes honcho --target-profile NAME status # Inspect another profile's Honcho config ``` ### Doctor Integration @@ -399,6 +405,65 @@ Shows the current AI peer representation from Honcho. - **Cross-platform memory** — same user understanding across CLI, Telegram, Discord, etc. - **Multi-user support** — each user (via messaging platforms) gets their own user model +## Profiles + +Honcho is profile-aware. When you create a [profile](../profiles.md) with `--clone` or `--clone-all`, Honcho automatically creates a host block with inherited settings. Each profile gets its own AI peer while sharing the user peer and workspace — so your user model carries across agents, but each agent develops its own identity. + +### What `profile create --clone` does + +```bash +hermes profile create research --clone +``` + +| Created | Value | Purpose | +|---------|-------|---------| +| Host key | `hermes.` | Scopes config to this profile | +| AI peer | `` | Distinct identity in Honcho | +| User peer | *(inherited)* | Same person across all agents | +| Workspace | *(inherited)* | Shared user history | + +Bare `profile create` (without `--clone`) does not create a Honcho peer — run `hermes honcho setup` or `hermes honcho enable` from that profile to configure it later. + +No per-profile `hermes honcho setup` required when using `--clone`. + +### Cross-profile commands + +```bash +hermes honcho status --all # table of all profiles +hermes honcho peers # peer identities across profiles +hermes honcho --target-profile coder status # inspect without switching +hermes honcho --target-profile coder disable # disable for one profile +hermes honcho sync # backfill profiles created before Honcho +``` + +`--target-profile` reads or modifies another profile's host block without switching context. Use `-p` to fully activate a profile: + +```bash +hermes -p coder honcho status # runs as the coder profile +``` + +### Config resolution + +Honcho config uses a three-tier lookup so profiles find host blocks regardless of where they were written: + +| Priority | Path | Purpose | +|----------|------|---------| +| 1 | `$HERMES_HOME/honcho.json` | Profile-local config | +| 2 | `~/.hermes/honcho.json` | Default profile (where host blocks accumulate) | +| 3 | `~/.honcho/config.json` | Global (shared with Cursor, SillyTavern, etc.) | + +Writes go to the active profile's local config. Reads check all three tiers. Host blocks created by `profile create` are written to tier 2 and are visible to all profiles automatically. + +### Syncing + +If you created profiles before configuring Honcho, or before upgrading to profile-aware Honcho: + +```bash +hermes honcho sync +``` + +Creates missing host blocks for all existing profiles. Also runs automatically during `hermes update`. + :::tip Honcho is fully opt-in — zero behavior change when disabled or unconfigured. All Honcho calls are non-fatal; if the service is unreachable, the agent continues normally. ::: diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index 5da6d8ab2af7..46724defc7a2 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -200,3 +200,9 @@ Add the line to your `~/.bashrc` or `~/.zshrc` for persistent completion. Comple Profiles use the `HERMES_HOME` environment variable. When you run `coder chat`, the wrapper script sets `HERMES_HOME=~/.hermes/profiles/coder` before launching hermes. Since 119+ files in the codebase resolve paths via `get_hermes_home()`, everything automatically scopes to the profile's directory — config, sessions, memory, skills, state database, gateway PID, logs, and cron jobs. The default profile is simply `~/.hermes` itself. No migration needed — existing installs work identically. + +## Honcho memory + +If [Honcho](../features/honcho.md) is configured, profiles created with `--clone` or `--clone-all` are automatically memory-aware — each gets its own AI peer while sharing your user identity and workspace. + +See the [Profiles section in the Honcho docs](../features/honcho.md#profiles) for details on cross-profile commands, config resolution, and syncing.