fleet-memory/hindsight-embed/hindsight_embed/daemon_client.py
Nicolò Boschi b42b35bf93
feat(embed): add programmatic UI (control plane) management (#683)
* feat(embed): add programmatic UI (control plane) management

Add ability to start/stop the web UI from hindsight-embed, with
configurable port (default: daemon_port + 10000) and hostname
(default: 0.0.0.0). Uses npx to run the published control plane
package, or node directly in dev mode.

New CLI commands:
  hindsight-embed ui start [--port PORT] [--hostname HOST]
  hindsight-embed ui stop [--port PORT]
  hindsight-embed ui status [--port PORT]
  hindsight-embed ui logs [-f] [-n N]

New programmatic API:
  daemon_client.start_ui(profile, ui_port, hostname)
  daemon_client.stop_ui(profile, ui_port)
  daemon_client.is_ui_running(profile, ui_port)
  daemon_client.get_ui_url(profile, ui_port)

* feat(embed): expose UI management on HindsightEmbedded

Add start_ui(), stop_ui(), is_ui_running(), and ui_url property
to HindsightEmbedded so the UI can be started programmatically:

  client = HindsightEmbedded(profile="myapp", ...)
  client.start_ui()  # starts daemon + UI
  print(client.ui_url)
2026-03-25 14:38:32 +01:00

316 lines
8.9 KiB
Python

"""
CLI utilities for daemon and CLI management.
This module provides CLI-specific functions for managing the daemon
and the hindsight Rust CLI binary.
"""
import logging
import os
from pathlib import Path
from .daemon_embed_manager import DaemonEmbedManager
from .profile_manager import ProfileManager, resolve_active_profile
logger = logging.getLogger(__name__)
# Singleton manager instance
_manager = DaemonEmbedManager()
# CLI paths - check multiple locations
CLI_INSTALL_DIRS = [
Path.home() / ".local" / "bin", # Standard location from get-cli installer
Path.home() / ".hindsight" / "bin", # Alternative location
]
CLI_INSTALLER_URL = "https://hindsight.vectorize.io/get-cli"
def get_daemon_port(profile: str | None = None) -> int:
"""Get daemon port for a profile.
Args:
profile: Profile name (None = resolve from priority).
Returns:
Port number for daemon.
"""
if profile is None:
profile = resolve_active_profile()
pm = ProfileManager()
paths = pm.resolve_profile_paths(profile)
return paths.port
def get_daemon_url(profile: str | None = None) -> str:
"""Get daemon URL for a profile.
Args:
profile: Profile name (None = resolve from priority).
Returns:
URL for daemon.
"""
if profile is None:
profile = resolve_active_profile()
return _manager.get_url(profile)
def ensure_daemon_running(config: dict, profile: str | None = None) -> bool:
"""
Ensure daemon is running, starting it if needed.
Args:
config: Configuration dict with LLM settings (accepts both simple keys
like "llm_api_key" and env var format like "HINDSIGHT_API_LLM_API_KEY").
profile: Profile name (None = resolve from priority).
Returns:
True if daemon is running.
"""
if profile is None:
profile = resolve_active_profile()
return _manager.ensure_running(config, profile)
def stop_daemon(profile: str | None = None) -> bool:
"""Stop the running daemon and wait for it to fully stop.
Args:
profile: Profile name (None = resolve from priority).
Returns:
True if daemon stopped successfully.
"""
if profile is None:
profile = resolve_active_profile()
return _manager.stop(profile)
def is_daemon_running(profile: str | None = None) -> bool:
"""Check if daemon is running for a profile.
Args:
profile: Profile name (None = resolve from priority).
Returns:
True if daemon is running and responsive.
"""
if profile is None:
profile = resolve_active_profile()
return _manager.is_running(profile)
def start_ui(profile: str | None = None, ui_port: int | None = None, hostname: str = "0.0.0.0") -> bool:
"""Start the control plane UI.
Args:
profile: Profile name (None = resolve from priority).
ui_port: Port for the UI. Defaults to daemon_port + 10000.
hostname: Hostname to bind to. Defaults to 0.0.0.0.
Returns:
True if UI started successfully.
"""
if profile is None:
profile = resolve_active_profile()
return _manager.start_ui(profile, ui_port, hostname)
def stop_ui(profile: str | None = None, ui_port: int | None = None) -> bool:
"""Stop the control plane UI.
Args:
profile: Profile name (None = resolve from priority).
ui_port: Port the UI is running on. Defaults to daemon_port + 10000.
Returns:
True if UI stopped successfully.
"""
if profile is None:
profile = resolve_active_profile()
return _manager.stop_ui(profile, ui_port)
def is_ui_running(profile: str | None = None, ui_port: int | None = None) -> bool:
"""Check if the UI is running.
Args:
profile: Profile name (None = resolve from priority).
ui_port: Port to check. Defaults to daemon_port + 10000.
Returns:
True if UI is running and responsive.
"""
if profile is None:
profile = resolve_active_profile()
return _manager.is_ui_running(profile, ui_port)
def get_ui_url(profile: str | None = None, ui_port: int | None = None) -> str:
"""Get UI URL for a profile.
Args:
profile: Profile name (None = resolve from priority).
ui_port: Port for the UI. Defaults to daemon_port + 10000.
Returns:
URL for the UI.
"""
if profile is None:
profile = resolve_active_profile()
return _manager.get_ui_url(profile, ui_port)
def find_cli_binary() -> Path | None:
"""Find the hindsight CLI binary in known locations or PATH."""
import shutil
# Check standard install locations
for install_dir in CLI_INSTALL_DIRS:
binary = install_dir / "hindsight"
if binary.exists() and os.access(binary, os.X_OK):
return binary
# Check PATH
path_binary = shutil.which("hindsight")
if path_binary:
return Path(path_binary)
return None
def is_cli_installed() -> bool:
"""Check if the hindsight CLI is installed."""
return find_cli_binary() is not None
def install_cli() -> bool:
"""
Install the hindsight CLI using the official installer.
Returns True if installation succeeded.
"""
import subprocess
import sys
from . import __version__
# Determine CLI version (use env var or match embed version)
cli_version = os.getenv("HINDSIGHT_EMBED_CLI_VERSION", __version__)
print(f"Installing hindsight CLI (version {cli_version})...")
print(f" Installer URL: {CLI_INSTALLER_URL}")
try:
# Download and run installer with version env var
env = os.environ.copy()
env["HINDSIGHT_CLI_VERSION"] = cli_version
result = subprocess.run(
["bash", "-c", f"curl -fsSL {CLI_INSTALLER_URL} | bash"],
capture_output=True,
text=True,
env=env,
)
if result.returncode != 0:
print(f"CLI installation failed (exit code {result.returncode}):", file=sys.stderr)
if result.stdout:
print(f" stdout: {result.stdout}", file=sys.stderr)
if result.stderr:
print(f" stderr: {result.stderr}", file=sys.stderr)
return False
cli_binary = find_cli_binary()
if cli_binary:
print(f"CLI installed to {cli_binary}")
return True
else:
print("CLI installation completed but binary not found", file=sys.stderr)
print(f" stdout: {result.stdout}", file=sys.stderr)
print(f" stderr: {result.stderr}", file=sys.stderr)
# Check known locations
for install_dir in CLI_INSTALL_DIRS:
binary = install_dir / "hindsight"
print(f" Checking {binary}: exists={binary.exists()}", file=sys.stderr)
return False
except Exception as e:
print(f"CLI installation failed: {e}", file=sys.stderr)
return False
def ensure_cli_installed() -> bool:
"""Ensure CLI is installed, installing if needed."""
if is_cli_installed():
return True
return install_cli()
def run_cli(args: list[str], config: dict, profile: str | None = None) -> int:
"""
Run the hindsight CLI with the given arguments.
Ensures daemon is running (unless HINDSIGHT_API_URL is already set) and passes the API URL.
Args:
args: CLI arguments (e.g., ["memory", "retain", "bank", "content"])
config: Configuration dict with llm settings
profile: Profile name (None = resolve from priority)
Returns:
Exit code from CLI
"""
import subprocess
import sys
if profile is None:
profile = resolve_active_profile()
# Ensure CLI is installed
if not ensure_cli_installed():
return 1
cli_binary = find_cli_binary()
if not cli_binary:
print("Error: hindsight CLI not found", file=sys.stderr)
return 1
# Build environment
env = os.environ.copy()
# Check if user wants to use external API
api_url = env.get("HINDSIGHT_EMBED_API_URL")
if not api_url:
# No external API specified - ensure our daemon is running
if not ensure_daemon_running(config, profile):
print("Error: Failed to start daemon", file=sys.stderr)
return 1
api_url = get_daemon_url(profile)
else:
# Using external API - skip daemon startup
logger.debug(f"Using external API at {api_url}")
# Set the API URL for the CLI (using the standard HINDSIGHT_API_URL var that the CLI expects)
env["HINDSIGHT_API_URL"] = api_url
# Pass through API token if set (using the standard HINDSIGHT_API_KEY var that the CLI expects)
api_token = env.get("HINDSIGHT_EMBED_API_TOKEN")
if api_token:
env["HINDSIGHT_API_KEY"] = api_token
# Run CLI
try:
result = subprocess.run(
[str(cli_binary)] + args,
env=env,
)
return result.returncode
except Exception as e:
print(f"Error running CLI: {e}", file=sys.stderr)
return 1