From b02cd5c92f3c4b0d5795a614a0658c7cf75d8b40 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 30 Mar 2026 13:03:34 -0400 Subject: [PATCH 01/11] feat(honcho): scope host and peer resolution to active Hermes profile Derives the Honcho host key from the active Hermes profile so that each profile gets its own Honcho host block, workspace, and AI peer identity. Profile "coder" resolves to host "hermes.coder", reads from hosts["hermes.coder"] in honcho.json, and defaults workspace + aiPeer to the derived host name. Resolution order: HERMES_HONCHO_HOST env var > active profile name > "hermes" (default). Complements #3681 (profiles) with the Honcho identity layer that was part of #2845 (named instances), adapted to the merged profiles system. --- honcho_integration/cli.py | 37 +++++----- honcho_integration/client.py | 52 +++++++++++--- tests/honcho_integration/test_client.py | 96 +++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 26 deletions(-) diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index f6cbcedf6606..12806248ecaa 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -11,9 +11,12 @@ from pathlib import Path from hermes_constants import get_hermes_home -from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH +from honcho_integration.client import resolve_active_host, resolve_config_path, GLOBAL_CONFIG_PATH, HOST -HOST = "hermes" + +def _host_key() -> str: + """Return the active Honcho host key, derived from the current Hermes profile.""" + return resolve_active_host() def _config_path() -> Path: @@ -52,7 +55,7 @@ def _write_config(cfg: dict, path: Path | None = None) -> None: def _resolve_api_key(cfg: dict) -> str: """Resolve API key with host -> root -> env fallback.""" - host_key = ((cfg.get("hosts") or {}).get(HOST) or {}).get("apiKey") + host_key = ((cfg.get("hosts") or {}).get(_host_key()) or {}).get("apiKey") return host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "") @@ -118,10 +121,10 @@ def cmd_setup(args) -> None: if not _ensure_sdk_installed(): return - # All writes go to hosts.hermes — root keys are managed by the user - # or the honcho CLI only. + # All writes go to the active host block — root keys are managed by + # the user or the honcho CLI only. hosts = cfg.setdefault("hosts", {}) - hermes_host = hosts.setdefault(HOST, {}) + hermes_host = hosts.setdefault(_host_key(), {}) # API key — shared credential, lives at root so all hosts can read it current_key = cfg.get("apiKey", "") @@ -148,7 +151,7 @@ def cmd_setup(args) -> None: if new_workspace: hermes_host["workspace"] = new_workspace - hermes_host.setdefault("aiPeer", HOST) + hermes_host.setdefault("aiPeer", _host_key()) # Memory mode current_mode = hermes_host.get("memoryMode") or cfg.get("memoryMode", "hybrid") @@ -354,9 +357,9 @@ def cmd_peer(args) -> None: if user_name is None and ai_name is None and reasoning is None: # Show current values hosts = cfg.get("hosts", {}) - hermes = hosts.get(HOST, {}) + hermes = hosts.get(_host_key(), {}) user = hermes.get('peerName') or cfg.get('peerName') or '(not set)' - ai = hermes.get('aiPeer') or cfg.get('aiPeer') or HOST + ai = hermes.get('aiPeer') or cfg.get('aiPeer') or _host_key() lvl = hermes.get("dialecticReasoningLevel") or cfg.get("dialecticReasoningLevel") or "low" max_chars = hermes.get("dialecticMaxChars") or cfg.get("dialecticMaxChars") or 600 print("\nHoncho peers\n" + "─" * 40) @@ -371,12 +374,12 @@ def cmd_peer(args) -> None: return if user_name is not None: - cfg.setdefault("hosts", {}).setdefault(HOST, {})["peerName"] = user_name.strip() + cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["peerName"] = user_name.strip() changed = True print(f" User peer → {user_name.strip()}") if ai_name is not None: - cfg.setdefault("hosts", {}).setdefault(HOST, {})["aiPeer"] = ai_name.strip() + cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["aiPeer"] = ai_name.strip() changed = True print(f" AI peer → {ai_name.strip()}") @@ -384,7 +387,7 @@ def cmd_peer(args) -> None: if reasoning not in REASONING_LEVELS: print(f" Invalid reasoning level '{reasoning}'. Options: {', '.join(REASONING_LEVELS)}") return - cfg.setdefault("hosts", {}).setdefault(HOST, {})["dialecticReasoningLevel"] = reasoning + cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["dialecticReasoningLevel"] = reasoning changed = True print(f" Dialectic reasoning level → {reasoning}") @@ -404,7 +407,7 @@ def cmd_mode(args) -> None: if mode_arg is None: current = ( - (cfg.get("hosts") or {}).get(HOST, {}).get("memoryMode") + (cfg.get("hosts") or {}).get(_host_key(), {}).get("memoryMode") or cfg.get("memoryMode") or "hybrid" ) @@ -419,7 +422,7 @@ def cmd_mode(args) -> None: print(f" Invalid mode '{mode_arg}'. Options: {', '.join(MODES)}\n") return - cfg.setdefault("hosts", {}).setdefault(HOST, {})["memoryMode"] = mode_arg + cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["memoryMode"] = mode_arg _write_config(cfg) print(f" Memory mode → {mode_arg} ({MODES[mode_arg]})\n") @@ -428,7 +431,7 @@ def cmd_tokens(args) -> None: """Show or set token budget settings.""" cfg = _read_config() hosts = cfg.get("hosts", {}) - hermes = hosts.get(HOST, {}) + hermes = hosts.get(_host_key(), {}) context = getattr(args, "context", None) dialectic = getattr(args, "dialectic", None) @@ -453,11 +456,11 @@ def cmd_tokens(args) -> None: changed = False if context is not None: - cfg.setdefault("hosts", {}).setdefault(HOST, {})["contextTokens"] = context + cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["contextTokens"] = context print(f" context tokens → {context}") changed = True if dialectic is not None: - cfg.setdefault("hosts", {}).setdefault(HOST, {})["dialecticMaxChars"] = dialectic + cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["dialecticMaxChars"] = dialectic print(f" dialectic cap → {dialectic} chars") changed = True diff --git a/honcho_integration/client.py b/honcho_integration/client.py index 50f7af30a28d..fdd3fc2e77af 100644 --- a/honcho_integration/client.py +++ b/honcho_integration/client.py @@ -31,6 +31,28 @@ HOST = "hermes" +def resolve_active_host() -> str: + """Derive the Honcho host key from the active Hermes profile. + + Resolution order: + 1. HERMES_HONCHO_HOST env var (explicit override) + 2. Active profile name via profiles system -> ``hermes.`` + 3. Fallback: ``"hermes"`` (default profile) + """ + explicit = os.environ.get("HERMES_HONCHO_HOST", "").strip() + if explicit: + return explicit + + try: + from hermes_cli.profiles import get_active_profile_name + profile = get_active_profile_name() + if profile and profile not in ("default", "custom"): + return f"{HOST}.{profile}" + except Exception: + pass + return HOST + + def resolve_config_path() -> Path: """Return the active Honcho config path. @@ -135,40 +157,52 @@ def peer_memory_mode(self, peer_name: str) -> str: explicitly_configured: bool = False @classmethod - def from_env(cls, workspace_id: str = "hermes") -> HonchoClientConfig: + def from_env( + cls, + workspace_id: str = "hermes", + host: str | None = None, + ) -> HonchoClientConfig: """Create config from environment variables (fallback).""" + resolved_host = host or resolve_active_host() api_key = os.environ.get("HONCHO_API_KEY") base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None + effective_workspace = workspace_id + if effective_workspace == HOST and resolved_host != HOST: + effective_workspace = resolved_host return cls( - workspace_id=workspace_id, + host=resolved_host, + workspace_id=effective_workspace, api_key=api_key, environment=os.environ.get("HONCHO_ENVIRONMENT", "production"), base_url=base_url, + ai_peer=resolved_host, enabled=bool(api_key or base_url), ) @classmethod def from_global_config( cls, - host: str = HOST, + host: str | None = None, config_path: Path | None = None, ) -> HonchoClientConfig: """Create config from the resolved Honcho config path. Resolution: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars. + When host is None, derives it from the active Hermes profile. """ + resolved_host = host or resolve_active_host() path = config_path or resolve_config_path() if not path.exists(): logger.debug("No global Honcho config at %s, falling back to env", path) - return cls.from_env() + return cls.from_env(host=resolved_host) try: raw = json.loads(path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as e: logger.warning("Failed to read %s: %s, falling back to env", path, e) - return cls.from_env() + return cls.from_env(host=resolved_host) - host_block = (raw.get("hosts") or {}).get(host, {}) + host_block = (raw.get("hosts") or {}).get(resolved_host, {}) # A hosts.hermes block or explicit enabled flag means the user # intentionally configured Honcho for this host. _explicitly_configured = bool(host_block) or raw.get("enabled") is True @@ -177,12 +211,12 @@ def from_global_config( workspace = ( host_block.get("workspace") or raw.get("workspace") - or host + or resolved_host ) ai_peer = ( host_block.get("aiPeer") or raw.get("aiPeer") - or host + or resolved_host ) linked_hosts = host_block.get("linkedHosts", []) @@ -242,7 +276,7 @@ def from_global_config( ) return cls( - host=host, + host=resolved_host, workspace_id=workspace, api_key=api_key, environment=environment, diff --git a/tests/honcho_integration/test_client.py b/tests/honcho_integration/test_client.py index d784887c678b..ef9a3ad0207c 100644 --- a/tests/honcho_integration/test_client.py +++ b/tests/honcho_integration/test_client.py @@ -11,6 +11,7 @@ HonchoClientConfig, get_honcho_client, reset_honcho_client, + resolve_active_host, resolve_config_path, GLOBAL_CONFIG_PATH, HOST, @@ -372,6 +373,101 @@ def test_from_global_config_uses_local_path(self, tmp_path): assert config.workspace_id == "local-ws" +class TestResolveActiveHost: + def test_default_returns_hermes(self): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("HERMES_HONCHO_HOST", None) + os.environ.pop("HERMES_HOME", None) + assert resolve_active_host() == "hermes" + + def test_explicit_env_var_wins(self): + with patch.dict(os.environ, {"HERMES_HONCHO_HOST": "hermes.coder"}): + assert resolve_active_host() == "hermes.coder" + + def test_profile_name_derives_host(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("HERMES_HONCHO_HOST", None) + with patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"): + assert resolve_active_host() == "hermes.coder" + + def test_default_profile_returns_hermes(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("HERMES_HONCHO_HOST", None) + with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"): + assert resolve_active_host() == "hermes" + + def test_custom_profile_returns_hermes(self): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("HERMES_HONCHO_HOST", None) + with patch("hermes_cli.profiles.get_active_profile_name", return_value="custom"): + assert resolve_active_host() == "hermes" + + def test_profiles_import_failure_falls_back(self): + import importlib + import sys + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("HERMES_HONCHO_HOST", None) + # Temporarily remove hermes_cli.profiles to simulate import failure + saved = sys.modules.get("hermes_cli.profiles") + sys.modules["hermes_cli.profiles"] = None # type: ignore + try: + assert resolve_active_host() == "hermes" + finally: + if saved is not None: + sys.modules["hermes_cli.profiles"] = saved + else: + sys.modules.pop("hermes_cli.profiles", None) + + +class TestProfileScopedConfig: + def test_from_env_uses_profile_host(self): + with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}): + config = HonchoClientConfig.from_env(host="hermes.coder") + assert config.host == "hermes.coder" + assert config.workspace_id == "hermes.coder" + assert config.ai_peer == "hermes.coder" + + def test_from_env_default_workspace_preserved_for_default_host(self): + with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}): + config = HonchoClientConfig.from_env(host="hermes") + assert config.host == "hermes" + assert config.workspace_id == "hermes" + + def test_from_global_config_reads_profile_host_block(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "shared-key", + "hosts": { + "hermes": {"aiPeer": "hermes", "peerName": "alice"}, + "hermes.coder": { + "aiPeer": "hermes.coder", + "peerName": "alice-coder", + "workspace": "coder-ws", + }, + }, + })) + config = HonchoClientConfig.from_global_config( + host="hermes.coder", config_path=config_file, + ) + assert config.host == "hermes.coder" + assert config.workspace_id == "coder-ws" + assert config.ai_peer == "hermes.coder" + assert config.peer_name == "alice-coder" + + def test_from_global_config_auto_resolves_host(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "key", + "hosts": { + "hermes.dreamer": {"peerName": "dreamer-user"}, + }, + })) + with patch("honcho_integration.client.resolve_active_host", return_value="hermes.dreamer"): + config = HonchoClientConfig.from_global_config(config_path=config_file) + assert config.host == "hermes.dreamer" + assert config.peer_name == "dreamer-user" + + class TestResetHonchoClient: def test_reset_clears_singleton(self): import honcho_integration.client as mod From 8577167eb639b6d86fc52b01dc4394a0d9a5233e Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 30 Mar 2026 14:10:01 -0400 Subject: [PATCH 02/11] feat(honcho): add cross-profile observability for Honcho integration - hermes honcho status: shows active profile name + host key - hermes honcho status --all: compact table of all profiles with mode, recall, write frequency per host block - hermes honcho peers: cross-profile peer identity table (user peer, AI peer, linked hosts) - All write commands (peer, mode, tokens) print [host_key] label when operating on a non-default profile --- hermes_cli/main.py | 4 +- honcho_integration/cli.py | 132 ++++++++++++++++++++++++++++++++++---- 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6514a55819c9..6cb22c4d500c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4514,7 +4514,9 @@ def cmd_plugins(args): honcho_subparsers = honcho_parser.add_subparsers(dest="honcho_command") honcho_subparsers.add_parser("setup", help="Interactive setup wizard for Honcho integration") - honcho_subparsers.add_parser("status", help="Show current Honcho config and connection status") + honcho_status = honcho_subparsers.add_parser("status", help="Show current Honcho config and connection status") + honcho_status.add_argument("--all", action="store_true", help="Show config overview across all profiles") + honcho_subparsers.add_parser("peers", help="Show peer identities across all profiles") honcho_subparsers.add_parser("sessions", help="List known Honcho session mappings") honcho_map = honcho_subparsers.add_parser( diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index 12806248ecaa..a3856ed3a617 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -240,8 +240,52 @@ def cmd_setup(args) -> None: print(" hermes honcho map — map this directory to a session name\n") +def _active_profile_name() -> str: + """Return the active Hermes profile name.""" + try: + from hermes_cli.profiles import get_active_profile_name + return get_active_profile_name() + except Exception: + return "default" + + +def _all_profile_host_configs() -> list[tuple[str, str, dict]]: + """Return (profile_name, host_key, host_block) for every known profile. + + Reads honcho.json once and maps each profile to its host block. + """ + try: + from honcho_integration.client import HOST + from hermes_cli.profiles import list_profiles + profiles = list_profiles() + except Exception: + return [(_active_profile_name(), _host_key(), {})] + + cfg = _read_config() + hosts = cfg.get("hosts", {}) + results = [] + + # Default profile + default_block = hosts.get(HOST, {}) + results.append(("default", HOST, default_block)) + + for p in profiles: + if p.name == "default": + continue + h = f"{HOST}.{p.name}" + results.append((p.name, h, hosts.get(h, {}))) + + return results + + def cmd_status(args) -> None: """Show current Honcho config and connection status.""" + show_all = getattr(args, "all", False) + + if show_all: + _cmd_status_all() + return + try: import honcho # noqa: F401 except ImportError: @@ -268,11 +312,16 @@ def cmd_status(args) -> None: api_key = hcfg.api_key or "" masked = f"...{api_key[-8:]}" if len(api_key) > 8 else ("set" if api_key else "not set") - print("\nHoncho status\n" + "─" * 40) + profile = _active_profile_name() + profile_label = f" [{hcfg.host}]" if profile != "default" else "" + + print(f"\nHoncho status{profile_label}\n" + "─" * 40) + if profile != "default": + print(f" Profile: {profile}") + print(f" Host: {hcfg.host}") print(f" Enabled: {hcfg.enabled}") print(f" API key: {masked}") print(f" Workspace: {hcfg.workspace_id}") - print(f" Host: {hcfg.host}") print(f" Config path: {active_path}") if write_path != active_path: print(f" Write path: {write_path} (instance-local)") @@ -299,6 +348,52 @@ def cmd_status(args) -> None: print(f"\n Not connected ({reason})\n") +def _cmd_status_all() -> None: + """Show Honcho config overview across all profiles.""" + rows = _all_profile_host_configs() + cfg = _read_config() + active = _active_profile_name() + + print(f"\nHoncho profiles ({len(rows)})\n" + "─" * 60) + print(f" {'Profile':<14} {'Host':<22} {'Enabled':<9} {'Mode':<9} {'Recall':<9} {'Write'}") + print(f" {'─' * 14} {'─' * 22} {'─' * 9} {'─' * 9} {'─' * 9} {'─' * 9}") + + for name, host, block in rows: + enabled = block.get("enabled", cfg.get("enabled")) + if enabled is None: + # Auto-enable check: any credentials? + has_creds = bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY")) + enabled = has_creds if block else False + enabled_str = "yes" if enabled else "no" + + mode = block.get("memoryMode") or cfg.get("memoryMode", "hybrid") + recall = block.get("recallMode") or cfg.get("recallMode", "hybrid") + write = block.get("writeFrequency") or cfg.get("writeFrequency", "async") + + marker = " *" if name == active else "" + print(f" {name + marker:<14} {host:<22} {enabled_str:<9} {mode:<9} {recall:<9} {write}") + + print(f"\n * active profile\n") + + +def cmd_peers(args) -> None: + """Show peer identities across all profiles.""" + rows = _all_profile_host_configs() + cfg = _read_config() + + print(f"\nHoncho peer identities ({len(rows)} profiles)\n" + "─" * 60) + print(f" {'Profile':<14} {'User peer':<16} {'AI peer':<22} {'Linked hosts'}") + print(f" {'─' * 14} {'─' * 16} {'─' * 22} {'─' * 16}") + + for name, host, block in rows: + user = block.get("peerName") or cfg.get("peerName") or "(not set)" + ai = block.get("aiPeer") or cfg.get("aiPeer") or host + linked = ", ".join(block.get("linkedHosts", [])) or "--" + print(f" {name:<14} {user:<16} {ai:<22} {linked}") + + print() + + def cmd_sessions(args) -> None: """List known directory → session name mappings.""" cfg = _read_config() @@ -373,23 +468,26 @@ def cmd_peer(args) -> None: print(f" Dialectic cap: {max_chars} chars\n") return + host = _host_key() + label = f"[{host}] " if host != "hermes" else "" + if user_name is not None: - cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["peerName"] = user_name.strip() + cfg.setdefault("hosts", {}).setdefault(host, {})["peerName"] = user_name.strip() changed = True - print(f" User peer → {user_name.strip()}") + print(f" {label}User peer -> {user_name.strip()}") if ai_name is not None: - cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["aiPeer"] = ai_name.strip() + cfg.setdefault("hosts", {}).setdefault(host, {})["aiPeer"] = ai_name.strip() changed = True - print(f" AI peer → {ai_name.strip()}") + print(f" {label}AI peer -> {ai_name.strip()}") if reasoning is not None: if reasoning not in REASONING_LEVELS: print(f" Invalid reasoning level '{reasoning}'. Options: {', '.join(REASONING_LEVELS)}") return - cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["dialecticReasoningLevel"] = reasoning + cfg.setdefault("hosts", {}).setdefault(host, {})["dialecticReasoningLevel"] = reasoning changed = True - print(f" Dialectic reasoning level → {reasoning}") + print(f" {label}Dialectic reasoning level -> {reasoning}") if changed: _write_config(cfg) @@ -422,9 +520,11 @@ def cmd_mode(args) -> None: print(f" Invalid mode '{mode_arg}'. Options: {', '.join(MODES)}\n") return - cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["memoryMode"] = mode_arg + host = _host_key() + label = f"[{host}] " if host != "hermes" else "" + cfg.setdefault("hosts", {}).setdefault(host, {})["memoryMode"] = mode_arg _write_config(cfg) - print(f" Memory mode → {mode_arg} ({MODES[mode_arg]})\n") + print(f" {label}Memory mode -> {mode_arg} ({MODES[mode_arg]})\n") def cmd_tokens(args) -> None: @@ -454,14 +554,16 @@ def cmd_tokens(args) -> None: print("\n Set with: hermes honcho tokens [--context N] [--dialectic N]\n") return + host = _host_key() + label = f"[{host}] " if host != "hermes" else "" changed = False if context is not None: - cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["contextTokens"] = context - print(f" context tokens → {context}") + cfg.setdefault("hosts", {}).setdefault(host, {})["contextTokens"] = context + print(f" {label}context tokens -> {context}") changed = True if dialectic is not None: - cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["dialecticMaxChars"] = dialectic - print(f" dialectic cap → {dialectic} chars") + cfg.setdefault("hosts", {}).setdefault(host, {})["dialecticMaxChars"] = dialectic + print(f" {label}dialectic cap -> {dialectic} chars") changed = True if changed: @@ -778,6 +880,8 @@ def honcho_command(args) -> None: cmd_setup(args) elif sub == "status": cmd_status(args) + elif sub == "peers": + cmd_peers(args) elif sub == "sessions": cmd_sessions(args) elif sub == "map": From 9cd3bc3d735cd44c8aaa527ffef2f6505e7a07a4 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 30 Mar 2026 14:26:26 -0400 Subject: [PATCH 03/11] feat(honcho): auto-clone config to new profiles on creation When a profile is created and Honcho is already configured on the default host, automatically creates a host block for the new profile with inherited settings (memory mode, recall mode, write frequency, peer name, etc.) and auto-derived workspace/aiPeer. Zero-friction path: hermes profile create coder -> Honcho config cloned as hermes.coder with all settings inherited. --- hermes_cli/main.py | 8 +++ honcho_integration/cli.py | 53 ++++++++++++++++ tests/honcho_integration/test_cli.py | 90 +++++++++++++++++++++++++++- 3 files changed, 150 insertions(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6cb22c4d500c..847472ec6507 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3608,6 +3608,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 + # Seed bundled skills (skip if --clone-all already copied them) if not clone_all: result = seed_profile_skills(profile_dir) diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index a3856ed3a617..9273223098cf 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -14,6 +14,59 @@ from honcho_integration.client import resolve_active_host, resolve_config_path, GLOBAL_CONFIG_PATH, HOST +def clone_honcho_for_profile(profile_name: str) -> bool: + """Auto-clone Honcho config for a new profile from the default host block. + + Called during profile creation. If Honcho is configured on the default + host, creates a new host block for the profile with inherited settings + and auto-derived workspace/aiPeer. + + Returns True if a host block was created, False if Honcho isn't configured. + """ + cfg = _read_config() + if not cfg: + return False + + hosts = cfg.get("hosts", {}) + default_block = hosts.get(HOST, {}) + + # No default host block and no root-level API key = Honcho not configured + has_key = bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY")) + if not default_block and not has_key: + return False + + new_host = f"{HOST}.{profile_name}" + if new_host in hosts: + return False # already exists + + # Clone settings from default block, override identity fields + new_block = {} + for key in ("memoryMode", "recallMode", "writeFrequency", "sessionStrategy", + "sessionPeerPrefix", "contextTokens", "dialecticReasoningLevel", + "dialecticMaxChars", "saveMessages"): + val = default_block.get(key) + if val is not None: + new_block[key] = val + + # Inherit peer name from default + peer_name = default_block.get("peerName") or cfg.get("peerName") + if peer_name: + new_block["peerName"] = peer_name + + # 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 + new_block["workspace"] = default_block.get("workspace") or cfg.get("workspace") or HOST + new_block["enabled"] = default_block.get("enabled", True) + + cfg.setdefault("hosts", {})[new_host] = new_block + _write_config(cfg) + + # Eagerly create the peer in Honcho so it exists before first message + _ensure_peer_exists(new_host) + return True + + def _host_key() -> str: """Return the active Honcho host key, derived from the current Hermes profile.""" return resolve_active_host() diff --git a/tests/honcho_integration/test_cli.py b/tests/honcho_integration/test_cli.py index b5a1c9f618be..6f757ac8ac29 100644 --- a/tests/honcho_integration/test_cli.py +++ b/tests/honcho_integration/test_cli.py @@ -1,6 +1,9 @@ """Tests for Honcho CLI helpers.""" -from honcho_integration.cli import _resolve_api_key +import json +from unittest.mock import patch + +from honcho_integration.cli import _resolve_api_key, clone_honcho_for_profile class TestResolveApiKey: @@ -27,3 +30,88 @@ def test_falls_back_to_env_key(self, monkeypatch): assert _resolve_api_key({}) == "env-key" monkeypatch.delenv("HONCHO_API_KEY", raising=False) + +class TestCloneHonchoForProfile: + def test_clones_default_settings_to_new_profile(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "test-key", + "hosts": { + "hermes": { + "peerName": "alice", + "memoryMode": "honcho", + "recallMode": "tools", + "writeFrequency": "turn", + "dialecticReasoningLevel": "medium", + "enabled": True, + }, + }, + })) + + with patch("honcho_integration.cli._config_path", return_value=config_file): + result = clone_honcho_for_profile("coder") + + assert result is True + + cfg = json.loads(config_file.read_text()) + new_block = cfg["hosts"]["hermes.coder"] + assert new_block["peerName"] == "alice" + assert new_block["memoryMode"] == "honcho" + assert new_block["recallMode"] == "tools" + assert new_block["writeFrequency"] == "turn" + assert new_block["aiPeer"] == "hermes.coder" + assert new_block["workspace"] == "hermes.coder" + assert new_block["enabled"] is True + + def test_skips_when_no_honcho_configured(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text("{}") + + with patch("honcho_integration.cli._config_path", return_value=config_file): + result = clone_honcho_for_profile("coder") + + assert result is False + + def test_skips_when_host_block_already_exists(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "key", + "hosts": { + "hermes": {"peerName": "alice"}, + "hermes.coder": {"peerName": "existing"}, + }, + })) + + with patch("honcho_integration.cli._config_path", return_value=config_file): + result = clone_honcho_for_profile("coder") + + assert result is False + cfg = json.loads(config_file.read_text()) + assert cfg["hosts"]["hermes.coder"]["peerName"] == "existing" + + def test_inherits_peer_name_from_root_when_not_in_host(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "key", + "peerName": "root-alice", + "hosts": {"hermes": {}}, + })) + + with patch("honcho_integration.cli._config_path", return_value=config_file): + clone_honcho_for_profile("dreamer") + + cfg = json.loads(config_file.read_text()) + assert cfg["hosts"]["hermes.dreamer"]["peerName"] == "root-alice" + + def test_works_with_api_key_only_no_host_block(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"apiKey": "key"})) + + with patch("honcho_integration.cli._config_path", return_value=config_file): + result = clone_honcho_for_profile("coder") + + assert result is True + cfg = json.loads(config_file.read_text()) + assert cfg["hosts"]["hermes.coder"]["aiPeer"] == "hermes.coder" + assert cfg["hosts"]["hermes.coder"]["workspace"] == "hermes.coder" + From 0947779d863771c0c79cc2db6d860574f48b4422 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 30 Mar 2026 14:32:51 -0400 Subject: [PATCH 04/11] feat(honcho): eager peer creation + enable/disable per profile - Eagerly create AI and user peers in Honcho when a profile is created (not deferred to first message). Uses idempotent peer() SDK call. - hermes honcho enable: turn on Honcho for active profile, clone settings from default if first time, create peer immediately - hermes honcho disable: turn off Honcho for active profile - _ensure_peer_exists() helper for idempotent peer creation --- hermes_cli/main.py | 2 + honcho_integration/cli.py | 83 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 847472ec6507..1eb77572b773 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4584,6 +4584,8 @@ def cmd_plugins(args): "migrate", help="Step-by-step migration guide from openclaw-honcho to Hermes Honcho", ) + honcho_subparsers.add_parser("enable", help="Enable Honcho for the active profile") + honcho_subparsers.add_parser("disable", help="Disable Honcho for the active profile") def cmd_honcho(args): from honcho_integration.cli import honcho_command diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index 9273223098cf..9c5fb2e44c1b 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -67,6 +67,83 @@ def clone_honcho_for_profile(profile_name: str) -> bool: return True +def _ensure_peer_exists(host_key: str | None = None) -> bool: + """Create the AI peer in Honcho if it doesn't already exist. + + Idempotent -- safe to call multiple times. Returns True if the peer + was created or already exists, False on failure. + """ + try: + from honcho_integration.client import HonchoClientConfig, get_honcho_client + hcfg = HonchoClientConfig.from_global_config(host=host_key) + if not hcfg.enabled or not (hcfg.api_key or hcfg.base_url): + return False + client = get_honcho_client(hcfg) + # peer() is idempotent -- creates if missing, returns if exists + client.peer(hcfg.ai_peer) + if hcfg.peer_name: + client.peer(hcfg.peer_name) + return True + except Exception: + return False + + +def cmd_enable(args) -> None: + """Enable Honcho for the active profile.""" + cfg = _read_config() + host = _host_key() + label = f"[{host}] " if host != "hermes" else "" + block = cfg.setdefault("hosts", {}).setdefault(host, {}) + + if block.get("enabled") is True: + print(f" {label}Honcho is already enabled.\n") + return + + block["enabled"] = True + + # If this is a new profile host block with no settings, clone from default + if not block.get("aiPeer"): + default_block = cfg.get("hosts", {}).get(HOST, {}) + for key in ("memoryMode", "recallMode", "writeFrequency", "sessionStrategy", + "contextTokens", "dialecticReasoningLevel", "dialecticMaxChars"): + val = default_block.get(key) + if val is not None and key not in block: + block[key] = val + 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) + block.setdefault("workspace", host) + + _write_config(cfg) + print(f" {label}Honcho enabled.") + + # Create peer eagerly + if _ensure_peer_exists(host): + print(f" {label}Peer '{block.get('aiPeer', host)}' ready.") + else: + print(f" {label}Peer creation deferred (no connection).") + + print(f" Saved to {_config_path()}\n") + + +def cmd_disable(args) -> None: + """Disable Honcho for the active profile.""" + cfg = _read_config() + host = _host_key() + label = f"[{host}] " if host != "hermes" else "" + block = cfg.get("hosts", {}).get(host, {}) + + if not block or block.get("enabled") is False: + print(f" {label}Honcho is already disabled.\n") + return + + block["enabled"] = False + _write_config(cfg) + print(f" {label}Honcho disabled.") + print(f" Saved to {_config_path()}\n") + + def _host_key() -> str: """Return the active Honcho host key, derived from the current Hermes profile.""" return resolve_active_host() @@ -949,6 +1026,10 @@ def honcho_command(args) -> None: cmd_identity(args) elif sub == "migrate": cmd_migrate(args) + elif sub == "enable": + cmd_enable(args) + elif sub == "disable": + cmd_disable(args) else: print(f" Unknown honcho command: {sub}") - print(" Available: setup, status, sessions, map, peer, mode, tokens, identity, migrate\n") + print(" Available: setup, status, sessions, map, peer, mode, tokens, identity, migrate, enable, disable\n") From 13d358ccd2421a0904b3ac95e103e0b287ec08a1 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 30 Mar 2026 14:35:07 -0400 Subject: [PATCH 05/11] fix(honcho): remove linkedHosts from peers table --- honcho_integration/cli.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index 9c5fb2e44c1b..39179e1be72a 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -511,15 +511,14 @@ def cmd_peers(args) -> None: rows = _all_profile_host_configs() cfg = _read_config() - print(f"\nHoncho peer identities ({len(rows)} profiles)\n" + "─" * 60) - print(f" {'Profile':<14} {'User peer':<16} {'AI peer':<22} {'Linked hosts'}") - print(f" {'─' * 14} {'─' * 16} {'─' * 22} {'─' * 16}") + print(f"\nHoncho peer identities ({len(rows)} profiles)\n" + "─" * 50) + print(f" {'Profile':<14} {'User peer':<16} {'AI peer'}") + print(f" {'─' * 14} {'─' * 16} {'─' * 18}") for name, host, block in rows: user = block.get("peerName") or cfg.get("peerName") or "(not set)" ai = block.get("aiPeer") or cfg.get("aiPeer") or host - linked = ", ".join(block.get("linkedHosts", [])) or "--" - print(f" {name:<14} {user:<16} {ai:<22} {linked}") + print(f" {name:<14} {user:<16} {ai}") print() From 84388de0fe337d7b19773dc19a820422065b5782 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 30 Mar 2026 16:38:36 -0400 Subject: [PATCH 06/11] fix(honcho): share workspace across profiles by default Profiles inherit the default workspace instead of deriving a separate one. All profiles see the same user context, sessions, and project history. Each profile is a different AI peer in a shared space. Workspace can still be overridden per-profile via config if isolation is needed. --- honcho_integration/cli.py | 2 +- tests/honcho_integration/test_cli.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index 39179e1be72a..2bd74c73b56c 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -113,7 +113,7 @@ def cmd_enable(args) -> None: if peer_name and "peerName" not in block: block["peerName"] = peer_name block.setdefault("aiPeer", host) - block.setdefault("workspace", host) + block.setdefault("workspace", default_block.get("workspace") or cfg.get("workspace") or HOST) _write_config(cfg) print(f" {label}Honcho enabled.") diff --git a/tests/honcho_integration/test_cli.py b/tests/honcho_integration/test_cli.py index 6f757ac8ac29..80ef4ddbc1f7 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["recallMode"] == "tools" assert new_block["writeFrequency"] == "turn" assert new_block["aiPeer"] == "hermes.coder" - assert new_block["workspace"] == "hermes.coder" + assert new_block["workspace"] == "hermes" # shared, not profile-derived assert new_block["enabled"] is True def test_skips_when_no_honcho_configured(self, tmp_path): @@ -113,5 +113,5 @@ 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"]["workspace"] == "hermes.coder" + assert cfg["hosts"]["hermes.coder"]["workspace"] == "hermes" # shared From 501ef6a1d4c052cae609d22d2202ba0ba5e2c682 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 30 Mar 2026 16:52:45 -0400 Subject: [PATCH 07/11] feat(honcho): --target-profile flag + peer card display in status - hermes honcho --target-profile : target another profile's Honcho config without switching profiles. Works with all subcommands (status, peer, mode, tokens, enable, disable, etc.) - hermes honcho status now shows user peer card and AI peer representation when connected (fetched live from Honcho API) --- hermes_cli/main.py | 4 +++ honcho_integration/cli.py | 53 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1eb77572b773..933de9154c72 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4519,6 +4519,10 @@ def cmd_plugins(args): ), formatter_class=__import__("argparse").RawDescriptionHelpFormatter, ) + honcho_parser.add_argument( + "--target-profile", metavar="NAME", dest="target_profile", + help="Target a specific profile's Honcho config without switching", + ) honcho_subparsers = honcho_parser.add_subparsers(dest="honcho_command") honcho_subparsers.add_parser("setup", help="Interactive setup wizard for Honcho integration") diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index 2bd74c73b56c..4945bbb04e6b 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -144,8 +144,15 @@ def cmd_disable(args) -> None: print(f" Saved to {_config_path()}\n") +_profile_override: str | None = None + + def _host_key() -> str: """Return the active Honcho host key, derived from the current Hermes profile.""" + if _profile_override: + if _profile_override in ("default", "custom"): + return HOST + return f"{HOST}.{_profile_override}" return resolve_active_host() @@ -371,7 +378,9 @@ def cmd_setup(args) -> None: def _active_profile_name() -> str: - """Return the active Hermes profile name.""" + """Return the active Hermes profile name (respects --target-profile override).""" + if _profile_override: + return _profile_override try: from hermes_cli.profiles import get_active_profile_name return get_active_profile_name() @@ -469,8 +478,9 @@ def cmd_status(args) -> None: if hcfg.enabled and (hcfg.api_key or hcfg.base_url): print("\n Connection... ", end="", flush=True) try: - get_honcho_client(hcfg) - print("OK\n") + client = get_honcho_client(hcfg) + print("OK") + _show_peer_cards(hcfg, client) except Exception as e: print(f"FAILED ({e})\n") else: @@ -478,6 +488,40 @@ def cmd_status(args) -> None: print(f"\n Not connected ({reason})\n") +def _show_peer_cards(hcfg, client) -> None: + """Fetch and display peer cards for the active profile.""" + try: + from honcho_integration.session import HonchoSessionManager + mgr = HonchoSessionManager(honcho=client, config=hcfg) + session_key = hcfg.resolve_session_name() + session = mgr.get_or_create(session_key) + + # User peer card + card = mgr.get_peer_card(session_key) + if card: + print(f"\n User peer card ({len(card)} facts):") + for fact in card[:10]: + print(f" - {fact}") + if len(card) > 10: + print(f" ... and {len(card) - 10} more") + + # AI peer representation + ai_rep = mgr.get_ai_representation(session_key) + ai_text = ai_rep.get("representation", "") + if ai_text: + # Truncate to first 200 chars + display = ai_text[:200] + ("..." if len(ai_text) > 200 else "") + print(f"\n AI peer representation:") + print(f" {display}") + + if not card and not ai_text: + print("\n No peer data yet (accumulates after first conversation)") + + print() + except Exception as e: + print(f"\n Peer data unavailable: {e}\n") + + def _cmd_status_all() -> None: """Show Honcho config overview across all profiles.""" rows = _all_profile_host_configs() @@ -1004,6 +1048,9 @@ def cmd_migrate(args) -> None: def honcho_command(args) -> None: """Route honcho subcommands.""" + global _profile_override + _profile_override = getattr(args, "target_profile", None) + sub = getattr(args, "honcho_command", None) if sub == "setup" or sub is None: cmd_setup(args) From e305ea71a2ca6299321d0203561da1ed72052cbe Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 30 Mar 2026 17:11:46 -0400 Subject: [PATCH 08/11] feat(honcho): sync command + auto-sync on hermes update - hermes honcho sync: scan all profiles, create missing host blocks - hermes update: automatically syncs Honcho config to all profiles after skill sync (existing users get profile mapping on next update) - sync_honcho_profiles_quiet() for silent use from update path --- hermes_cli/main.py | 10 ++++ honcho_integration/cli.py | 115 +++++++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 933de9154c72..ec00a4c0973d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3266,6 +3266,15 @@ def cmd_update(args): except Exception: pass # profiles module not available or no profiles + # Sync Honcho host blocks to all profiles + try: + from honcho_integration.cli import sync_honcho_profiles_quiet + synced = sync_honcho_profiles_quiet() + if synced: + print(f"\n-> Honcho: synced {synced} profile(s)") + except Exception: + pass # honcho not installed or not configured + # Check for config migrations print() print("→ Checking configuration for new options...") @@ -4590,6 +4599,7 @@ def cmd_plugins(args): ) honcho_subparsers.add_parser("enable", help="Enable Honcho for the active profile") honcho_subparsers.add_parser("disable", help="Disable Honcho for the active profile") + honcho_subparsers.add_parser("sync", help="Sync Honcho config to all existing profiles") def cmd_honcho(args): from honcho_integration.cli import honcho_command diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index 4945bbb04e6b..66ae56eb8b65 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -144,6 +144,117 @@ def cmd_disable(args) -> None: print(f" Saved to {_config_path()}\n") +def cmd_sync(args) -> None: + """Sync Honcho config to all existing profiles. + + Scans all Hermes profiles and creates host blocks for any that don't + have one yet. Inherits settings from the default host block. + """ + try: + from hermes_cli.profiles import list_profiles + profiles = list_profiles() + except Exception as e: + print(f" Could not list profiles: {e}\n") + return + + cfg = _read_config() + if not cfg: + print(" No Honcho config found. Run 'hermes honcho setup' first.\n") + return + + hosts = cfg.get("hosts", {}) + default_block = hosts.get(HOST, {}) + has_key = bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY")) + + if not default_block and not has_key: + print(" Honcho not configured on default profile. Run 'hermes honcho setup' first.\n") + return + + created = 0 + skipped = 0 + for p in profiles: + if p.name == "default": + continue + if clone_honcho_for_profile(p.name): + print(f" + {p.name} -> hermes.{p.name}") + created += 1 + else: + skipped += 1 + + if created: + print(f"\n {created} profile(s) synced.") + else: + print(" All profiles already have Honcho config.") + if skipped: + print(f" {skipped} profile(s) already configured (skipped).") + print() + + +def cmd_sync(args) -> None: + """Sync Honcho config to all existing profiles. + + Scans all Hermes profiles and creates host blocks for any that don't + have one yet. Inherits settings from the default host block. + Also called automatically during `hermes update`. + """ + try: + from hermes_cli.profiles import list_profiles + profiles = list_profiles() + except Exception as e: + print(f" Could not list profiles: {e}\n") + return + + cfg = _read_config() + if not cfg: + return + + default_block = cfg.get("hosts", {}).get(HOST, {}) + has_key = bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY")) + + if not default_block and not has_key: + return + + created = 0 + for p in profiles: + if p.name == "default": + continue + if clone_honcho_for_profile(p.name): + print(f" Honcho: + {p.name} -> hermes.{p.name}") + created += 1 + + if created: + print(f" Honcho: {created} profile(s) synced.") + + +def sync_honcho_profiles_quiet() -> int: + """Sync Honcho host blocks for all profiles. Returns count of newly created blocks. + + Called from `hermes update` -- no output, no exceptions. + """ + try: + from hermes_cli.profiles import list_profiles + profiles = list_profiles() + except Exception: + return 0 + + cfg = _read_config() + if not cfg: + return 0 + + default_block = cfg.get("hosts", {}).get(HOST, {}) + has_key = bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY")) + if not default_block and not has_key: + return 0 + + created = 0 + for p in profiles: + if p.name == "default": + continue + if clone_honcho_for_profile(p.name): + created += 1 + return created + + _profile_override: str | None = None @@ -1076,6 +1187,8 @@ def honcho_command(args) -> None: cmd_enable(args) elif sub == "disable": cmd_disable(args) + elif sub == "sync": + cmd_sync(args) else: print(f" Unknown honcho command: {sub}") - print(" Available: setup, status, sessions, map, peer, mode, tokens, identity, migrate, enable, disable\n") + print(" Available: setup, status, sessions, map, peer, mode, tokens, identity, migrate, enable, disable, sync\n") From dff9c5676344e927a35239ff794838caf83e9c31 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 30 Mar 2026 17:21:38 -0400 Subject: [PATCH 09/11] fix(honcho): address PR review findings - Remove duplicate cmd_sync definition (kept version with error output) - Fix from_env workspace to stay shared (hermes) not profile-derived - Add docstring clarifying get_or_create is idempotent in status - Remove unused import importlib in test - Fix test assertion for shared workspace in from_env path - Add 3 tests for sync_honcho_profiles_quiet --- honcho_integration/cli.py | 45 +++---------------- honcho_integration/client.py | 5 +-- tests/honcho_integration/test_cli.py | 57 ++++++++++++++++++++++++- tests/honcho_integration/test_client.py | 3 +- 4 files changed, 65 insertions(+), 45 deletions(-) diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index 66ae56eb8b65..f646f4494a70 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -190,42 +190,6 @@ def cmd_sync(args) -> None: print() -def cmd_sync(args) -> None: - """Sync Honcho config to all existing profiles. - - Scans all Hermes profiles and creates host blocks for any that don't - have one yet. Inherits settings from the default host block. - Also called automatically during `hermes update`. - """ - try: - from hermes_cli.profiles import list_profiles - profiles = list_profiles() - except Exception as e: - print(f" Could not list profiles: {e}\n") - return - - cfg = _read_config() - if not cfg: - return - - default_block = cfg.get("hosts", {}).get(HOST, {}) - has_key = bool(cfg.get("apiKey") or os.environ.get("HONCHO_API_KEY")) - - if not default_block and not has_key: - return - - created = 0 - for p in profiles: - if p.name == "default": - continue - if clone_honcho_for_profile(p.name): - print(f" Honcho: + {p.name} -> hermes.{p.name}") - created += 1 - - if created: - print(f" Honcho: {created} profile(s) synced.") - - def sync_honcho_profiles_quiet() -> int: """Sync Honcho host blocks for all profiles. Returns count of newly created blocks. @@ -600,12 +564,17 @@ def cmd_status(args) -> None: def _show_peer_cards(hcfg, client) -> None: - """Fetch and display peer cards for the active profile.""" + """Fetch and display peer cards for the active profile. + + Uses get_or_create to ensure the session exists with peers configured. + This is idempotent -- if the session already exists on the server it's + just retrieved, not duplicated. + """ try: from honcho_integration.session import HonchoSessionManager mgr = HonchoSessionManager(honcho=client, config=hcfg) session_key = hcfg.resolve_session_name() - session = mgr.get_or_create(session_key) + mgr.get_or_create(session_key) # User peer card card = mgr.get_peer_card(session_key) diff --git a/honcho_integration/client.py b/honcho_integration/client.py index fdd3fc2e77af..6a567b073406 100644 --- a/honcho_integration/client.py +++ b/honcho_integration/client.py @@ -166,12 +166,9 @@ def from_env( resolved_host = host or resolve_active_host() api_key = os.environ.get("HONCHO_API_KEY") base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None - effective_workspace = workspace_id - if effective_workspace == HOST and resolved_host != HOST: - effective_workspace = resolved_host return cls( host=resolved_host, - workspace_id=effective_workspace, + workspace_id=workspace_id, api_key=api_key, environment=os.environ.get("HONCHO_ENVIRONMENT", "production"), base_url=base_url, diff --git a/tests/honcho_integration/test_cli.py b/tests/honcho_integration/test_cli.py index 80ef4ddbc1f7..d3535479ee11 100644 --- a/tests/honcho_integration/test_cli.py +++ b/tests/honcho_integration/test_cli.py @@ -3,7 +3,7 @@ import json from unittest.mock import patch -from honcho_integration.cli import _resolve_api_key, clone_honcho_for_profile +from honcho_integration.cli import _resolve_api_key, clone_honcho_for_profile, sync_honcho_profiles_quiet class TestResolveApiKey: @@ -115,3 +115,58 @@ def test_works_with_api_key_only_no_host_block(self, tmp_path): assert cfg["hosts"]["hermes.coder"]["aiPeer"] == "hermes.coder" assert cfg["hosts"]["hermes.coder"]["workspace"] == "hermes" # shared + +class TestSyncHonchoProfilesQuiet: + def test_syncs_missing_profiles(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "key", + "hosts": {"hermes": {"peerName": "alice", "memoryMode": "honcho"}}, + })) + + class FakeProfile: + def __init__(self, name): + self.name = name + self.is_default = name == "default" + + profiles = [FakeProfile("default"), FakeProfile("coder"), FakeProfile("dreamer")] + + with patch("honcho_integration.cli._config_path", return_value=config_file), \ + patch("hermes_cli.profiles.list_profiles", return_value=profiles): + count = sync_honcho_profiles_quiet() + + assert count == 2 + cfg = json.loads(config_file.read_text()) + assert "hermes.coder" in cfg["hosts"] + assert "hermes.dreamer" in cfg["hosts"] + + def test_returns_zero_when_no_honcho(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text("{}") + + with patch("honcho_integration.cli._config_path", return_value=config_file): + count = sync_honcho_profiles_quiet() + + assert count == 0 + + def test_skips_already_synced(self, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({ + "apiKey": "key", + "hosts": { + "hermes": {"peerName": "alice"}, + "hermes.coder": {"peerName": "existing"}, + }, + })) + + class FakeProfile: + def __init__(self, name): + self.name = name + self.is_default = name == "default" + + with patch("honcho_integration.cli._config_path", return_value=config_file), \ + patch("hermes_cli.profiles.list_profiles", return_value=[FakeProfile("default"), FakeProfile("coder")]): + count = sync_honcho_profiles_quiet() + + assert count == 0 + diff --git a/tests/honcho_integration/test_client.py b/tests/honcho_integration/test_client.py index ef9a3ad0207c..655e786c4322 100644 --- a/tests/honcho_integration/test_client.py +++ b/tests/honcho_integration/test_client.py @@ -403,7 +403,6 @@ def test_custom_profile_returns_hermes(self): assert resolve_active_host() == "hermes" def test_profiles_import_failure_falls_back(self): - import importlib import sys with patch.dict(os.environ, {}, clear=False): os.environ.pop("HERMES_HONCHO_HOST", None) @@ -424,7 +423,7 @@ def test_from_env_uses_profile_host(self): with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}): config = HonchoClientConfig.from_env(host="hermes.coder") assert config.host == "hermes.coder" - assert config.workspace_id == "hermes.coder" + assert config.workspace_id == "hermes" # shared workspace assert config.ai_peer == "hermes.coder" def test_from_env_default_workspace_preserved_for_default_host(self): From 2d5ef86530676267aaf8d3a566e8b36075990fed Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 30 Mar 2026 17:35:47 -0400 Subject: [PATCH 10/11] fix: patch _local_config_path in tests for write isolation --- tests/honcho_integration/test_cli.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/honcho_integration/test_cli.py b/tests/honcho_integration/test_cli.py index d3535479ee11..ed4337061dcb 100644 --- a/tests/honcho_integration/test_cli.py +++ b/tests/honcho_integration/test_cli.py @@ -48,7 +48,8 @@ def test_clones_default_settings_to_new_profile(self, tmp_path): }, })) - with patch("honcho_integration.cli._config_path", return_value=config_file): + with patch("honcho_integration.cli._config_path", return_value=config_file), \ + patch("honcho_integration.cli._local_config_path", return_value=config_file): result = clone_honcho_for_profile("coder") assert result is True @@ -67,7 +68,8 @@ def test_skips_when_no_honcho_configured(self, tmp_path): config_file = tmp_path / "config.json" config_file.write_text("{}") - with patch("honcho_integration.cli._config_path", return_value=config_file): + with patch("honcho_integration.cli._config_path", return_value=config_file), \ + patch("honcho_integration.cli._local_config_path", return_value=config_file): result = clone_honcho_for_profile("coder") assert result is False @@ -82,7 +84,8 @@ def test_skips_when_host_block_already_exists(self, tmp_path): }, })) - with patch("honcho_integration.cli._config_path", return_value=config_file): + with patch("honcho_integration.cli._config_path", return_value=config_file), \ + patch("honcho_integration.cli._local_config_path", return_value=config_file): result = clone_honcho_for_profile("coder") assert result is False @@ -97,7 +100,8 @@ def test_inherits_peer_name_from_root_when_not_in_host(self, tmp_path): "hosts": {"hermes": {}}, })) - with patch("honcho_integration.cli._config_path", return_value=config_file): + with patch("honcho_integration.cli._config_path", return_value=config_file), \ + patch("honcho_integration.cli._local_config_path", return_value=config_file): clone_honcho_for_profile("dreamer") cfg = json.loads(config_file.read_text()) @@ -107,7 +111,8 @@ def test_works_with_api_key_only_no_host_block(self, tmp_path): config_file = tmp_path / "config.json" config_file.write_text(json.dumps({"apiKey": "key"})) - with patch("honcho_integration.cli._config_path", return_value=config_file): + with patch("honcho_integration.cli._config_path", return_value=config_file), \ + patch("honcho_integration.cli._local_config_path", return_value=config_file): result = clone_honcho_for_profile("coder") assert result is True @@ -132,6 +137,7 @@ def __init__(self, name): profiles = [FakeProfile("default"), FakeProfile("coder"), FakeProfile("dreamer")] with patch("honcho_integration.cli._config_path", return_value=config_file), \ + patch("honcho_integration.cli._local_config_path", return_value=config_file), \ patch("hermes_cli.profiles.list_profiles", return_value=profiles): count = sync_honcho_profiles_quiet() @@ -144,7 +150,8 @@ def test_returns_zero_when_no_honcho(self, tmp_path): config_file = tmp_path / "config.json" config_file.write_text("{}") - with patch("honcho_integration.cli._config_path", return_value=config_file): + with patch("honcho_integration.cli._config_path", return_value=config_file), \ + patch("honcho_integration.cli._local_config_path", return_value=config_file): count = sync_honcho_profiles_quiet() assert count == 0 From 350c51500e5f218ec0251ecef17d0f7d5bff0e00 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 2 Apr 2026 09:23:19 -0700 Subject: [PATCH 11/11] fix(honcho): remove redundant local HOST import in _all_profile_host_configs HOST is already imported at module level from honcho_integration.client. The local import inside _all_profile_host_configs() was unnecessary. --- honcho_integration/cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index f646f4494a70..51f686dea7e7 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -469,7 +469,6 @@ def _all_profile_host_configs() -> list[tuple[str, str, dict]]: Reads honcho.json once and maps each profile to its host block. """ try: - from honcho_integration.client import HOST from hermes_cli.profiles import list_profiles profiles = list_profiles() except Exception: