#!/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": "" } 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": "", } 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