diff --git a/honcho_integration/cli.py b/honcho_integration/cli.py index f6cbcedf66..12806248ec 100644 --- a/honcho_integration/cli.py +++ b/honcho_integration/cli.py @@ -11,9 +11,12 @@ import sys 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 50f7af30a2..fdd3fc2e77 100644 --- a/honcho_integration/client.py +++ b/honcho_integration/client.py @@ -31,6 +31,28 @@ GLOBAL_CONFIG_PATH = Path.home() / ".honcho" / "config.json" 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 @@ class HonchoClientConfig: 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 @@ class HonchoClientConfig: 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 @@ class HonchoClientConfig: ) 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 d784887c67..ef9a3ad020 100644 --- a/tests/honcho_integration/test_client.py +++ b/tests/honcho_integration/test_client.py @@ -11,6 +11,7 @@ from honcho_integration.client import ( HonchoClientConfig, get_honcho_client, reset_honcho_client, + resolve_active_host, resolve_config_path, GLOBAL_CONFIG_PATH, HOST, @@ -372,6 +373,101 @@ class TestResolveConfigPath: 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