151 lines
4.9 KiB
Python
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
|