#!/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 ...`. Per-field resolution (first hit wins): owner env ECHO_OWNER -> config "owner" -> baked DEFAULT_OWNER endpoint env ECHO_BASE -> config "endpoint" -> baked DEFAULT_BASE key env ECHO_KEY -> config "key" -> baked DEFAULT_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 FALLBACK (the lowest 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 behaviour is unchanged (env/config file still win). """ 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 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 DEFAULT_BASE or "").rstrip("/") return { "owner": os.environ.get("ECHO_OWNER") or f.get("owner") or DEFAULT_OWNER or "", "endpoint": endpoint, "key": os.environ.get("ECHO_KEY") or f.get("key") or DEFAULT_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" baked = {"owner": DEFAULT_OWNER, "endpoint": DEFAULT_BASE, "key": DEFAULT_KEY}[field] if baked: return "baked-in default" 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