"""Honcho client initialization and configuration. Reads the global ~/.honcho/config.json when available, falling back to environment variables. Resolution order for host-specific settings: 1. Explicit host block fields (always win) 2. Flat/global fields from config root 3. Defaults (host name as workspace/peer) """ from __future__ import annotations import json import os import logging from dataclasses import dataclass, field from pathlib import Path from typing import Any, TYPE_CHECKING if TYPE_CHECKING: from honcho import Honcho logger = logging.getLogger(__name__) GLOBAL_CONFIG_PATH = Path.home() / ".honcho" / "config.json" HOST = "hermes" @dataclass class HonchoClientConfig: """Configuration for Honcho client, resolved for a specific host.""" host: str = HOST workspace_id: str = "hermes" api_key: str | None = None environment: str = "production" # Identity peer_name: str | None = None ai_peer: str = "hermes" linked_hosts: list[str] = field(default_factory=list) # Toggles enabled: bool = False save_messages: bool = True # Prefetch budget context_tokens: int | None = None # Session resolution session_strategy: str = "per-directory" session_peer_prefix: bool = False sessions: dict[str, str] = field(default_factory=dict) # Raw global config for anything else consumers need raw: dict[str, Any] = field(default_factory=dict) @classmethod def from_env(cls, workspace_id: str = "hermes") -> HonchoClientConfig: """Create config from environment variables (fallback).""" return cls( workspace_id=workspace_id, api_key=os.environ.get("HONCHO_API_KEY"), environment=os.environ.get("HONCHO_ENVIRONMENT", "production"), enabled=True, ) @classmethod def from_global_config( cls, host: str = HOST, config_path: Path | None = None, ) -> HonchoClientConfig: """Create config from ~/.honcho/config.json. Falls back to environment variables if the file doesn't exist. """ path = config_path or GLOBAL_CONFIG_PATH if not path.exists(): logger.debug("No global Honcho config at %s, falling back to env", path) return cls.from_env() 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() host_block = (raw.get("hosts") or {}).get(host, {}) # Explicit host block fields win, then flat/global, then defaults workspace = ( host_block.get("workspace") or raw.get("workspace") or host ) ai_peer = ( host_block.get("aiPeer") or raw.get("aiPeer") or host ) linked_hosts = host_block.get("linkedHosts", []) api_key = raw.get("apiKey") or os.environ.get("HONCHO_API_KEY") # Auto-enable when API key is present (unless explicitly disabled) # This matches user expectations: setting an API key should activate the feature. explicit_enabled = raw.get("enabled") if explicit_enabled is None: # Not explicitly set in config -> auto-enable if API key exists enabled = bool(api_key) else: # Respect explicit setting enabled = explicit_enabled return cls( host=host, workspace_id=workspace, api_key=api_key, environment=raw.get("environment", "production"), peer_name=raw.get("peerName"), ai_peer=ai_peer, linked_hosts=linked_hosts, enabled=enabled, save_messages=raw.get("saveMessages", True), context_tokens=raw.get("contextTokens") or host_block.get("contextTokens"), session_strategy=raw.get("sessionStrategy", "per-directory"), session_peer_prefix=raw.get("sessionPeerPrefix", False), sessions=raw.get("sessions", {}), raw=raw, ) def resolve_session_name(self, cwd: str | None = None) -> str | None: """Resolve session name for a directory. Checks manual overrides first, then derives from directory name. """ if not cwd: cwd = os.getcwd() # Manual override manual = self.sessions.get(cwd) if manual: return manual # Derive from directory basename base = Path(cwd).name if self.session_peer_prefix and self.peer_name: return f"{self.peer_name}-{base}" return base def get_linked_workspaces(self) -> list[str]: """Resolve linked host keys to workspace names.""" hosts = self.raw.get("hosts", {}) workspaces = [] for host_key in self.linked_hosts: block = hosts.get(host_key, {}) ws = block.get("workspace") or host_key if ws != self.workspace_id: workspaces.append(ws) return workspaces _honcho_client: Honcho | None = None def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: """Get or create the Honcho client singleton. When no config is provided, attempts to load ~/.honcho/config.json first, falling back to environment variables. """ global _honcho_client if _honcho_client is not None: return _honcho_client if config is None: config = HonchoClientConfig.from_global_config() if not config.api_key: raise ValueError( "Honcho API key not found. Set it in ~/.honcho/config.json " "or the HONCHO_API_KEY environment variable. " "Get an API key from https://app.honcho.dev" ) try: from honcho import Honcho except ImportError: raise ImportError( "honcho-ai is required for Honcho integration. " "Install it with: pip install honcho-ai" ) logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id) _honcho_client = Honcho( workspace_id=config.workspace_id, api_key=config.api_key, environment=config.environment, ) return _honcho_client def reset_honcho_client() -> None: """Reset the Honcho client singleton (useful for testing).""" global _honcho_client _honcho_client = None