honcho/honcho-cli/src/honcho_cli/config.py

151 lines
4.9 KiB
Python

"""Configuration management for Honcho CLI.
Config stored at ``~/.honcho/config.json`` with env var overrides.
The CLI owns exactly two top-level keys in that file:
apiKey -- Honcho admin JWT
environmentUrl -- Honcho API URL (full URL, e.g. https://api.honcho.dev)
All other top-level keys (``hosts``, ``sessions``, ``saveMessages``,
``sessionStrategy``, …) are written by sibling Honcho tools and are
preserved untouched on save.
Workspace / peer / session scoping is intentionally *not* persisted here —
pass ``-w`` / ``-p`` / ``-s`` flags or set ``HONCHO_WORKSPACE_ID`` /
``HONCHO_PEER_ID`` / ``HONCHO_SESSION_ID`` per command instead.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, fields
from pathlib import Path
CONFIG_DIR = Path.home() / ".honcho"
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_BASE_URL = "https://api.honcho.dev"
# Env var mapping for runtime overrides.
#
# Resolution order: flag > env var > config file > default.
ENV_MAP: dict[str, str] = {
"api_key": "HONCHO_API_KEY",
"base_url": "HONCHO_BASE_URL",
"workspace_id": "HONCHO_WORKSPACE_ID",
"peer_id": "HONCHO_PEER_ID",
"session_id": "HONCHO_SESSION_ID",
}
@dataclass
class CLIConfig:
"""CLI configuration with layered resolution: flag > env > file > default.
``workspace_id`` / ``peer_id`` / ``session_id`` exist on this dataclass so
flag/env overrides flow through ``get_client_kwargs()``, but they are
never read from or written to the config file — they're per-command.
"""
base_url: str = DEFAULT_BASE_URL
api_key: str = ""
workspace_id: str = ""
peer_id: str = ""
session_id: str = ""
@classmethod
def load(cls) -> CLIConfig:
"""Load config from file, then overlay env vars."""
config = cls()
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
data = {}
if isinstance(data, dict):
url = data.get("environmentUrl")
if isinstance(url, str) and url:
config.base_url = url
key = data.get("apiKey")
if isinstance(key, str):
config.api_key = key
for fld_name, env_var in ENV_MAP.items():
val = os.environ.get(env_var)
if val:
setattr(config, fld_name, val)
elif val == "":
# SDK reads these env vars directly and crashes on empty
# strings with a Pydantic ValidationError. Drop them so the
# SDK falls back to kwargs / defaults.
os.environ.pop(env_var, None)
return config
def save(self) -> None:
"""Write ``apiKey`` + ``environmentUrl`` to config.json.
Preserves unrelated top-level keys (``hosts``, ``sessions``,
``saveMessages``, ``sessionStrategy``, …) that other tools write.
"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
data: dict = {}
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
data = loaded
except (json.JSONDecodeError, OSError):
data = {}
data["environmentUrl"] = self.base_url
if self.api_key:
data["apiKey"] = self.api_key
else:
data.pop("apiKey", None)
CONFIG_FILE.write_text(json.dumps(data, indent=2) + "\n")
# API key in plaintext — restrict to the owner on multi-user hosts.
try:
os.chmod(CONFIG_FILE, 0o600)
except OSError:
pass
def redacted(self) -> dict[str, str]:
"""Return config dict with api_key redacted.
Only includes fields that have a value set — per-command fields
(workspace_id, peer_id, session_id) are omitted when empty.
"""
d: dict[str, str] = {}
for fld in fields(self):
val = getattr(self, fld.name)
if not val:
continue
if fld.name == "api_key":
# Show ``***<last4>`` only — enough to compare keys without
# leaking the header or body of the JWT.
d[fld.name] = "***" + val[-4:] if len(val) > 4 else "***"
else:
d[fld.name] = val
return d
def get_client_kwargs(config: CLIConfig) -> dict:
"""Build kwargs for Honcho client from config."""
kwargs: dict = {}
if config.base_url:
kwargs["base_url"] = config.base_url
if config.api_key:
kwargs["api_key"] = config.api_key
if config.workspace_id:
kwargs["workspace_id"] = config.workspace_id
return kwargs