182 lines
6.3 KiB
Python
182 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""echo_config.py — resolve the vault owner, endpoint, and API key for ECHO.
|
|
|
|
The plugin ships NO owner, endpoint, or key — it is fully user-agnostic. On each
|
|
machine, a machine-local JSON config supplies them:
|
|
|
|
~/.claude/echo-memory/config.json
|
|
{
|
|
"owner": "Full Name (how the agent refers to the vault owner, third person)",
|
|
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
|
"key": "<obsidian-local-rest-api-bearer-token>"
|
|
}
|
|
|
|
This file is per-machine: it must be created on each fresh install, is never
|
|
committed, and is never written into a vault note. To create it, run
|
|
`python3 echo.py config init` (writes a placeholder template when absent) and edit
|
|
it, or `python3 echo.py config set --owner ... --endpoint ... --key ...`.
|
|
|
|
Per-field resolution (first hit wins):
|
|
owner env ECHO_OWNER -> config "owner"
|
|
endpoint env ECHO_BASE -> config "endpoint"
|
|
key env ECHO_KEY -> config "key"
|
|
|
|
The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config
|
|
file path itself can be overridden with $ECHO_CONFIG. Pure stdlib only.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
TEMPLATE = {
|
|
"owner": "Full Name — how the agent should refer to the vault owner (third person)",
|
|
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
|
"key": "<obsidian-local-rest-api-bearer-token>",
|
|
}
|
|
|
|
|
|
def claude_dir() -> Path:
|
|
"""The Claude config directory ($CLAUDE_CONFIG_DIR, else ~/.claude)."""
|
|
return Path(os.environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude"))
|
|
|
|
|
|
def config_path() -> Path:
|
|
"""Absolute path to the machine-local config file."""
|
|
override = os.environ.get("ECHO_CONFIG")
|
|
if override:
|
|
return Path(override).expanduser()
|
|
return claude_dir() / "echo-memory" / "config.json"
|
|
|
|
|
|
def _from_file() -> dict:
|
|
p = config_path()
|
|
if not p.exists():
|
|
return {}
|
|
try:
|
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError):
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def load() -> dict:
|
|
"""Return {owner, endpoint, key} with env overriding the file. Missing -> "".
|
|
|
|
Never raises — callers that REQUIRE a field use resolve_endpoint()/resolve_key(),
|
|
which raise a helpful error. This keeps `--help`, `config`, and `doctor` usable
|
|
even when nothing is configured yet."""
|
|
f = _from_file()
|
|
endpoint = (os.environ.get("ECHO_BASE") or f.get("endpoint") or "").rstrip("/")
|
|
return {
|
|
"owner": os.environ.get("ECHO_OWNER") or f.get("owner") or "",
|
|
"endpoint": endpoint,
|
|
"key": os.environ.get("ECHO_KEY") or f.get("key") or "",
|
|
}
|
|
|
|
|
|
def _missing(field: str, env: str) -> RuntimeError:
|
|
return RuntimeError(
|
|
f"echo: no {field} configured. Set {env}, or create {config_path()} "
|
|
f"(run `echo.py config init` to scaffold it, then edit). "
|
|
f'Expected JSON keys: "owner", "endpoint", "key".'
|
|
)
|
|
|
|
|
|
def resolve_endpoint() -> str:
|
|
ep = load()["endpoint"]
|
|
if not ep:
|
|
raise _missing("endpoint", "ECHO_BASE")
|
|
return ep
|
|
|
|
|
|
def resolve_key() -> str:
|
|
k = load()["key"]
|
|
if not k:
|
|
raise _missing("key", "ECHO_KEY")
|
|
return k
|
|
|
|
|
|
def resolve_owner() -> str:
|
|
"""The owner display name. Descriptive only, so an empty value is tolerated."""
|
|
return load()["owner"]
|
|
|
|
|
|
def is_configured(cfg: dict | None = None) -> bool:
|
|
"""True only when endpoint AND key are present and NOT the untouched template
|
|
placeholders — so a freshly `config init`-ed (but unedited) file reads as not yet
|
|
configured, which is what triggers the first-run "ask for the key file" prompt."""
|
|
c = cfg if cfg is not None else load()
|
|
ep, key = c.get("endpoint", ""), c.get("key", "")
|
|
if not ep or not key:
|
|
return False
|
|
if "your-obsidian-rest-endpoint" in ep or key.startswith("<"):
|
|
return False
|
|
return True
|
|
|
|
|
|
def source(field: str) -> str:
|
|
"""Where a field is currently coming from — for `config`/`doctor` reporting."""
|
|
env = {"owner": "ECHO_OWNER", "endpoint": "ECHO_BASE", "key": "ECHO_KEY"}[field]
|
|
if os.environ.get(env):
|
|
return f"{env} env"
|
|
if config_path().exists() and _from_file().get(field):
|
|
return "config file"
|
|
return "unset"
|
|
|
|
|
|
def template_text() -> str:
|
|
return json.dumps(TEMPLATE, indent=2, ensure_ascii=False) + "\n"
|
|
|
|
|
|
def _secure(p: Path) -> None:
|
|
try:
|
|
os.chmod(p, 0o600) # advisory on filesystems without POSIX mode bits (e.g. Windows)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def write_config(owner: str, endpoint: str, key: str) -> Path:
|
|
"""Persist a filled-in config (merging over any existing values), chmod 0600."""
|
|
cur = _from_file()
|
|
merged = {
|
|
"owner": owner if owner is not None else cur.get("owner", ""),
|
|
"endpoint": (endpoint if endpoint is not None else cur.get("endpoint", "")).rstrip("/"),
|
|
"key": key if key is not None else cur.get("key", ""),
|
|
}
|
|
p = config_path()
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(json.dumps(merged, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
_secure(p)
|
|
return p
|
|
|
|
|
|
def import_file(src) -> Path:
|
|
"""Adopt a config file someone was handed: validate it has a usable endpoint+key,
|
|
then copy it into config_path(). The 'provide the file -> plugin installs it' path."""
|
|
p = Path(src).expanduser()
|
|
if not p.exists():
|
|
raise RuntimeError(f"config import: file not found: {p}")
|
|
try:
|
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError) as exc:
|
|
raise RuntimeError(f"config import: {p} is not valid JSON ({exc})")
|
|
if not isinstance(data, dict) or not is_configured(data):
|
|
raise RuntimeError(
|
|
'config import: file must be JSON with a real "endpoint" and "key" '
|
|
'(and ideally "owner") — not the template placeholders.')
|
|
return write_config(data.get("owner", ""), data["endpoint"], data["key"])
|
|
|
|
|
|
def init_template() -> tuple[Path, bool]:
|
|
"""Write the placeholder template to config_path() if absent.
|
|
Returns (path, created) — created is False when a config already exists."""
|
|
p = config_path()
|
|
if p.exists():
|
|
return p, False
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(template_text(), encoding="utf-8")
|
|
_secure(p)
|
|
return p, True
|