Files
echo/echo-memory.plugin.src/skills/echo-memory/scripts/echo_config.py
T
Jason Stedwell 9ee1530f97 v1.4.1 bump
2026-06-26 15:39:17 -05:00

229 lines
8.8 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 ...`.
Resolution — a COMPLETE baked credential set wins unconditionally:
If the artifact carries both a baked endpoint AND key (a per-user build), those
baked values are used as-is and CANNOT be shadowed by env vars or a config file.
This is the default path: a delivered per-user plugin "just works" with no setup,
and a stale ECHO_* env var or a leftover/placeholder ~/.claude config.json can
never override or break it.
Otherwise (nothing baked — the generic published plugin), fall back per-field,
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.
BAKED CREDENTIALS (the default tier): the DEFAULT_* constants below are EMPTY in
source — the committed plugin ships zero credentials. `build.py --bake-key`
substitutes a specific user's owner/endpoint/key into them at package time,
producing a per-user artifact that needs no config file. This is how the plugin
works in the CoWork sandbox, where the user's ~/.claude is not bridged: the plugin
itself (mounted read-only every session) carries the values. A baked artifact is
secret-bearing — deliver it directly to that one user, NEVER commit or publish it.
On a normal host the empty defaults mean the env/config-file path takes over.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
# Build-time injection targets — MUST stay empty string literals in source so the
# committed tree ships no secret. `build.py --bake-key` rewrites these lines.
DEFAULT_OWNER = ""
DEFAULT_BASE = ""
DEFAULT_KEY = ""
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 baked_complete() -> bool:
"""True when the artifact carries a usable baked endpoint AND key — i.e. a
per-user `--bake-key` build. When True, the baked values win unconditionally."""
return bool(DEFAULT_BASE and DEFAULT_KEY)
def load() -> dict:
"""Return {owner, endpoint, key}. A complete baked set wins unconditionally;
otherwise env overrides 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()
if baked_complete():
# Per-user artifact: baked creds are authoritative and must not be shadowed
# by a stale env var or a leftover/placeholder config file. Owner is purely
# descriptive, so it may still fall back when not baked.
return {
"owner": DEFAULT_OWNER or os.environ.get("ECHO_OWNER") or f.get("owner") or "",
"endpoint": DEFAULT_BASE.rstrip("/"),
"key": DEFAULT_KEY,
}
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."""
baked = {"owner": DEFAULT_OWNER, "endpoint": DEFAULT_BASE, "key": DEFAULT_KEY}[field]
# A complete baked set wins unconditionally (see load()): endpoint/key always
# come from the artifact, and owner too whenever it was baked.
if baked_complete() and (field in ("endpoint", "key") or baked):
return "baked-in (plugin)"
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"
if baked:
return "baked-in (plugin)"
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