/ hermes_cli / memory_setup.py
memory_setup.py
1 """hermes memory setup|status — configure memory provider plugins. 2 3 Auto-detects installed memory providers via the plugin system. 4 Interactive curses-based UI for provider selection, then walks through 5 the provider's config schema. Writes config to config.yaml + .env. 6 """ 7 8 from __future__ import annotations 9 10 import getpass 11 import os 12 import sys 13 from pathlib import Path 14 15 from hermes_constants import get_hermes_home 16 17 18 # --------------------------------------------------------------------------- 19 # Curses-based interactive picker (same pattern as hermes tools) 20 # --------------------------------------------------------------------------- 21 22 def _curses_select(title: str, items: list[tuple[str, str]], default: int = 0) -> int: 23 """Interactive single-select with arrow keys. 24 25 items: list of (label, description) tuples. 26 Returns selected index, or default on escape/quit. 27 """ 28 from hermes_cli.curses_ui import curses_radiolist 29 # Format (label, desc) tuples into display strings 30 display_items = [ 31 f"{label} {desc}" if desc else label 32 for label, desc in items 33 ] 34 return curses_radiolist(title, display_items, selected=default, cancel_returns=default) 35 36 37 def _prompt(label: str, default: str | None = None, secret: bool = False) -> str: 38 """Prompt for a value with optional default and secret masking.""" 39 suffix = f" [{default}]" if default else "" 40 if secret: 41 sys.stdout.write(f" {label}{suffix}: ") 42 sys.stdout.flush() 43 if sys.stdin.isatty(): 44 val = getpass.getpass(prompt="") 45 else: 46 val = sys.stdin.readline().strip() 47 else: 48 sys.stdout.write(f" {label}{suffix}: ") 49 sys.stdout.flush() 50 val = sys.stdin.readline().strip() 51 return val or (default or "") 52 53 54 # --------------------------------------------------------------------------- 55 # Provider discovery 56 # --------------------------------------------------------------------------- 57 58 def _install_dependencies(provider_name: str) -> None: 59 """Install pip dependencies declared in plugin.yaml.""" 60 import subprocess 61 from plugins.memory import find_provider_dir 62 63 plugin_dir = find_provider_dir(provider_name) 64 if not plugin_dir: 65 return 66 yaml_path = plugin_dir / "plugin.yaml" 67 if not yaml_path.exists(): 68 return 69 70 try: 71 import yaml 72 with open(yaml_path) as f: 73 meta = yaml.safe_load(f) or {} 74 except Exception: 75 return 76 77 pip_deps = meta.get("pip_dependencies", []) 78 if not pip_deps: 79 return 80 81 # pip name → import name mapping for packages where they differ 82 _IMPORT_NAMES = { 83 "honcho-ai": "honcho", 84 "mem0ai": "mem0", 85 "hindsight-client": "hindsight_client", 86 "hindsight-all": "hindsight", 87 } 88 89 # Check which packages are missing 90 missing = [] 91 for dep in pip_deps: 92 import_name = _IMPORT_NAMES.get(dep, dep.replace("-", "_").split("[")[0]) 93 try: 94 __import__(import_name) 95 except ImportError: 96 missing.append(dep) 97 98 if not missing: 99 return 100 101 print(f"\n Installing dependencies: {', '.join(missing)}") 102 103 import shutil 104 uv_path = shutil.which("uv") 105 if not uv_path: 106 print(f" ⚠ uv not found — cannot install dependencies") 107 print(f" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh") 108 print(f" Then re-run: hermes memory setup") 109 return 110 111 try: 112 subprocess.run( 113 [uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing, 114 check=True, timeout=120, 115 capture_output=True, 116 ) 117 print(f" ✓ Installed {', '.join(missing)}") 118 except subprocess.CalledProcessError as e: 119 print(f" ⚠ Failed to install {', '.join(missing)}") 120 stderr = (e.stderr or b"").decode()[:200] 121 if stderr: 122 print(f" {stderr}") 123 print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}") 124 except Exception as e: 125 print(f" ⚠ Install failed: {e}") 126 print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}") 127 128 # Also show external dependencies (non-pip) if any 129 ext_deps = meta.get("external_dependencies", []) 130 for dep in ext_deps: 131 dep_name = dep.get("name", "") 132 check_cmd = dep.get("check", "") 133 install_cmd = dep.get("install", "") 134 if check_cmd: 135 try: 136 subprocess.run( 137 check_cmd, shell=True, capture_output=True, timeout=5 138 ) 139 except Exception: 140 if install_cmd: 141 print(f"\n ⚠ '{dep_name}' not found. Install with:") 142 print(f" {install_cmd}") 143 144 145 def _get_available_providers() -> list: 146 """Discover memory providers from plugins/memory/. 147 148 Returns list of (name, description, provider_instance) tuples. 149 """ 150 try: 151 from plugins.memory import discover_memory_providers, load_memory_provider 152 raw = discover_memory_providers() 153 except Exception: 154 raw = [] 155 156 results = [] 157 for name, desc, available in raw: 158 try: 159 provider = load_memory_provider(name) 160 if not provider: 161 continue 162 except Exception: 163 continue 164 165 schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else [] 166 has_secrets = any(f.get("secret") for f in schema) 167 has_non_secrets = any(not f.get("secret") for f in schema) 168 if has_secrets and has_non_secrets: 169 setup_hint = "API key / local" 170 elif has_secrets: 171 setup_hint = "requires API key" 172 elif not schema: 173 setup_hint = "no setup needed" 174 else: 175 setup_hint = "local" 176 177 results.append((name, setup_hint, provider)) 178 return results 179 180 181 # --------------------------------------------------------------------------- 182 # Setup wizard 183 # --------------------------------------------------------------------------- 184 185 def cmd_setup_provider(provider_name: str) -> None: 186 """Run memory setup for a specific provider, skipping the picker.""" 187 from hermes_cli.config import load_config, save_config 188 189 providers = _get_available_providers() 190 match = None 191 for name, desc, provider in providers: 192 if name == provider_name: 193 match = (name, desc, provider) 194 break 195 196 if not match: 197 print(f"\n Memory provider '{provider_name}' not found.") 198 print(" Run 'hermes memory setup' to see available providers.\n") 199 return 200 201 name, _, provider = match 202 203 _install_dependencies(name) 204 205 config = load_config() 206 if not isinstance(config.get("memory"), dict): 207 config["memory"] = {} 208 209 if hasattr(provider, "post_setup"): 210 hermes_home = str(get_hermes_home()) 211 provider.post_setup(hermes_home, config) 212 return 213 214 # Fallback: generic schema-based setup (same as cmd_setup) 215 config["memory"]["provider"] = name 216 save_config(config) 217 print(f"\n Memory provider: {name}") 218 print(f" Activation saved to config.yaml\n") 219 220 221 def cmd_setup(args) -> None: 222 """Interactive memory provider setup wizard.""" 223 from hermes_cli.config import load_config, save_config 224 225 providers = _get_available_providers() 226 227 if not providers: 228 print("\n No memory provider plugins detected.") 229 print(" Install a plugin to ~/.hermes/plugins/ and try again.\n") 230 return 231 232 # Build picker items 233 items = [] 234 for name, desc, _ in providers: 235 items.append((name, f"— {desc}")) 236 items.append(("Built-in only", "— MEMORY.md / USER.md (default)")) 237 238 builtin_idx = len(items) - 1 239 selected = _curses_select("Memory provider setup", items, default=builtin_idx) 240 241 config = load_config() 242 if not isinstance(config.get("memory"), dict): 243 config["memory"] = {} 244 245 # Built-in only 246 if selected >= len(providers) or selected < 0: 247 config["memory"]["provider"] = "" 248 save_config(config) 249 print("\n ✓ Memory provider: built-in only") 250 print(" Saved to config.yaml\n") 251 return 252 253 name, _, provider = providers[selected] 254 255 # Install pip dependencies if declared in plugin.yaml 256 _install_dependencies(name) 257 258 # If the provider has a post_setup hook, delegate entirely to it. 259 # The hook handles its own config, connection test, and activation. 260 if hasattr(provider, "post_setup"): 261 hermes_home = str(get_hermes_home()) 262 provider.post_setup(hermes_home, config) 263 return 264 265 schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else [] 266 267 provider_config = config["memory"].get(name, {}) 268 if not isinstance(provider_config, dict): 269 provider_config = {} 270 271 env_path = get_hermes_home() / ".env" 272 env_writes = {} 273 274 if schema: 275 print(f"\n Configuring {name}:\n") 276 277 for field in schema: 278 key = field["key"] 279 desc = field.get("description", key) 280 default = field.get("default") 281 # Dynamic default: look up default from another field's value 282 default_from = field.get("default_from") 283 if default_from and isinstance(default_from, dict): 284 ref_field = default_from.get("field", "") 285 ref_map = default_from.get("map", {}) 286 ref_value = provider_config.get(ref_field, "") 287 if ref_value and ref_value in ref_map: 288 default = ref_map[ref_value] 289 is_secret = field.get("secret", False) 290 choices = field.get("choices") 291 env_var = field.get("env_var") 292 url = field.get("url") 293 294 # Skip fields whose "when" condition doesn't match 295 when = field.get("when") 296 if when and isinstance(when, dict): 297 if not all(provider_config.get(k) == v for k, v in when.items()): 298 continue 299 300 if choices and not is_secret: 301 # Use curses picker for choice fields 302 choice_items = [(c, "") for c in choices] 303 current = provider_config.get(key, default) 304 current_idx = 0 305 if current and current in choices: 306 current_idx = choices.index(current) 307 sel = _curses_select(f" {desc}", choice_items, default=current_idx) 308 provider_config[key] = choices[sel] 309 elif is_secret: 310 # Prompt for secret 311 existing = os.environ.get(env_var, "") if env_var else "" 312 if existing: 313 masked = f"...{existing[-4:]}" if len(existing) > 4 else "set" 314 val = _prompt(f"{desc} (current: {masked}, blank to keep)", secret=True) 315 else: 316 hint = f" Get yours at {url}" if url else "" 317 if hint: 318 print(hint) 319 val = _prompt(desc, secret=True) 320 if val and env_var: 321 env_writes[env_var] = val 322 else: 323 # Regular text prompt 324 current = provider_config.get(key) 325 effective_default = current or default 326 val = _prompt(desc, default=str(effective_default) if effective_default else None) 327 if val: 328 provider_config[key] = val 329 # Also write to .env if this field has an env_var 330 if env_var and env_var not in env_writes: 331 env_writes[env_var] = val 332 333 # Write activation key to config.yaml 334 config["memory"]["provider"] = name 335 save_config(config) 336 337 # Write non-secret config to provider's native location 338 hermes_home = str(get_hermes_home()) 339 if provider_config and hasattr(provider, "save_config"): 340 try: 341 provider.save_config(provider_config, hermes_home) 342 except Exception as e: 343 print(f" Failed to write provider config: {e}") 344 345 # Write secrets to .env 346 if env_writes: 347 _write_env_vars(env_path, env_writes) 348 349 print(f"\n Memory provider: {name}") 350 print(f" Activation saved to config.yaml") 351 if provider_config: 352 print(f" Provider config saved") 353 if env_writes: 354 print(f" API keys saved to .env") 355 print(f"\n Start a new session to activate.\n") 356 357 358 def _write_env_vars(env_path: Path, env_writes: dict) -> None: 359 """Append or update env vars in .env file.""" 360 env_path.parent.mkdir(parents=True, exist_ok=True) 361 362 existing_lines = [] 363 if env_path.exists(): 364 existing_lines = env_path.read_text(encoding="utf-8").splitlines() 365 366 updated_keys = set() 367 new_lines = [] 368 for line in existing_lines: 369 key_match = line.split("=", 1)[0].strip() if "=" in line else "" 370 if key_match in env_writes: 371 new_lines.append(f"{key_match}={env_writes[key_match]}") 372 updated_keys.add(key_match) 373 else: 374 new_lines.append(line) 375 376 for key, val in env_writes.items(): 377 if key not in updated_keys: 378 new_lines.append(f"{key}={val}") 379 380 env_path.write_text("\n".join(new_lines) + "\n") 381 382 383 # --------------------------------------------------------------------------- 384 # Status 385 # --------------------------------------------------------------------------- 386 387 def cmd_status(args) -> None: 388 """Show current memory provider config.""" 389 from hermes_cli.config import load_config 390 391 config = load_config() 392 mem_config = config.get("memory", {}) 393 provider_name = mem_config.get("provider", "") 394 395 print(f"\nMemory status\n" + "─" * 40) 396 print(f" Built-in: always active") 397 print(f" Provider: {provider_name or '(none — built-in only)'}") 398 399 if provider_name: 400 provider_config = mem_config.get(provider_name, {}) 401 if provider_config: 402 print(f"\n {provider_name} config:") 403 for key, val in provider_config.items(): 404 print(f" {key}: {val}") 405 406 providers = _get_available_providers() 407 found = any(name == provider_name for name, _, _ in providers) 408 if found: 409 print(f"\n Plugin: installed ✓") 410 for pname, _, p in providers: 411 if pname == provider_name: 412 if p.is_available(): 413 print(f" Status: available ✓") 414 else: 415 print(f" Status: not available ✗") 416 schema = p.get_config_schema() if hasattr(p, "get_config_schema") else [] 417 # Check all fields that have env_var (both secret and non-secret) 418 required_fields = [f for f in schema if f.get("env_var")] 419 if required_fields: 420 print(f" Missing:") 421 for f in required_fields: 422 env_var = f.get("env_var", "") 423 url = f.get("url", "") 424 is_set = bool(os.environ.get(env_var)) 425 mark = "✓" if is_set else "✗" 426 line = f" {mark} {env_var}" 427 if url and not is_set: 428 line += f" → {url}" 429 print(line) 430 break 431 else: 432 print(f"\n Plugin: NOT installed ✗") 433 print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/") 434 435 providers = _get_available_providers() 436 if providers: 437 print(f"\n Installed plugins:") 438 for pname, desc, _ in providers: 439 active = " ← active" if pname == provider_name else "" 440 print(f" • {pname} ({desc}){active}") 441 442 print() 443 444 445 # --------------------------------------------------------------------------- 446 # Router 447 # --------------------------------------------------------------------------- 448 449 def memory_command(args) -> None: 450 """Route memory subcommands.""" 451 sub = getattr(args, "memory_command", None) 452 if sub == "setup": 453 cmd_setup(args) 454 elif sub == "status": 455 cmd_status(args) 456 else: 457 cmd_status(args)