1
0
forked from jason/echo

ver 1.4.0 - baked in values

This commit is contained in:
Jason Stedwell
2026-06-25 17:54:03 -05:00
parent b4605a52f2
commit a1f2f02d3a
23 changed files with 254 additions and 37 deletions
Vendored
BIN
View File
Binary file not shown.
+6
View File
@@ -18,3 +18,9 @@ eval/results/*.json
# ECHO config: the filled-in key file is yours to distribute, never commit it # ECHO config: the filled-in key file is yours to distribute, never commit it
/echo-memory.config.json /echo-memory.config.json
# Built plugin artifacts. A --bake-key build embeds a user's vault key, so NO
# .plugin artifact is ever committed — they are delivered out-of-band per user.
# (Matches files ending in .plugin, not the echo-memory.plugin.src/ source tree.)
*.plugin
/dist/
+33
View File
@@ -0,0 +1,33 @@
# Changelog
## 1.4.0
### Added — per-user baked-key builds (CoWork zero-touch)
The plugin is the only thing reliably mounted into a CoWork session (the sandbox's
`~/.claude` is synthetic, not your real one), so the config now *can* travel inside
the artifact. `echo_config` gains a lowest-priority fallback tier — the
`DEFAULT_OWNER` / `DEFAULT_BASE` / `DEFAULT_KEY` constants — **empty in source**, so
the committed tree still ships zero credentials.
- `build.py --bake-key --from <config.json> [--label <name>]` substitutes a single
user's owner/endpoint/key into those constants, producing a per-user artifact that
needs **no config file and no per-session paste**. Values may also come from
`--owner/--endpoint/--key` or `ECHO_OWNER/ECHO_BASE/ECHO_KEY`.
- Baked builds default to `dist/` (gitignored) and never touch the shared
`echo-memory.plugin` pointer.
- `build.py --strip-key` now force-blanks the same constants (guaranteed token-free).
- `config show` / `doctor` report `[baked-in default]` when a field resolves from the
baked tier.
### Changed — artifact hygiene
- `.gitignore` now excludes `*.plugin` and `dist/`; previously-tracked `.plugin`
artifacts were untracked. A baked artifact carries a vault key and must be
**delivered directly to that one user, never committed, pushed, or published.**
### Resolution order (unchanged for hosts)
`env``$ECHO_CONFIG` file → canonical `~/.claude/echo-memory/config.json`
**baked `DEFAULT_*`**. On a normal desktop the empty defaults mean behaviour is
identical to 1.3.x; the baked tier only matters in per-user artifacts.
+14 -1
View File
@@ -1,4 +1,4 @@
# echo-memory — v1.3.1 # echo-memory — v1.4.0
Persistent memory for Claude / CoWork sessions via the **ECHO** Obsidian vault, driven over the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api). The skill makes direct REST calls through a bundled validated client (`scripts/echo.py`). The whole toolchain is **pure Python** (stdlib only), so it runs identically on Windows, macOS, and Linux — no bash, no platform-specific `date`. Persistent memory for Claude / CoWork sessions via the **ECHO** Obsidian vault, driven over the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api). The skill makes direct REST calls through a bundled validated client (`scripts/echo.py`). The whole toolchain is **pure Python** (stdlib only), so it runs identically on Windows, macOS, and Linux — no bash, no platform-specific `date`.
@@ -33,6 +33,19 @@ The plugin ships **no** owner, endpoint, or key — it is user-agnostic. Each ma
- **Set up a machine:** `echo.py config import <file>` adopts a config you've been handed; `echo.py config set --owner … --endpoint … --key …` writes one from values; `echo.py config init` scaffolds a blank template to edit; `echo.py config` prints the resolved config (key redacted) and where each field came from. - **Set up a machine:** `echo.py config import <file>` adopts a config you've been handed; `echo.py config set --owner … --endpoint … --key …` writes one from values; `echo.py config init` scaffolds a blank template to edit; `echo.py config` prints the resolved config (key redacted) and where each field came from.
- **First run:** an unconfigured machine — or one left on the placeholder template — reports `NOT CONFIGURED` (`echo.py load` prints a banner and exits `78`; other verbs exit `2`; `/echo-doctor` flags it red), and the skill asks the operator for the key file before doing any memory work. The config is **never committed** and **never written into a vault note**; the key stays out of the plugin tree entirely. - **First run:** an unconfigured machine — or one left on the placeholder template — reports `NOT CONFIGURED` (`echo.py load` prints a banner and exits `78`; other verbs exit `2`; `/echo-doctor` flags it red), and the skill asks the operator for the key file before doing any memory work. The config is **never committed** and **never written into a vault note**; the key stays out of the plugin tree entirely.
### Per-user baked-key builds (v1.4.0) — for CoWork
The CoWork sandbox does **not** bridge your real `~/.claude` (the mounted `.claude` is the sandbox's own synthetic view), so a config file on your machine can't be read there. The only thing reliably mounted into every session is the **plugin itself**. So for a small set of known users, bake each user's credentials directly into a per-user artifact:
```bash
python build.py --bake-key --from alice-config.json --label alice
# -> dist/echo-memory-1.4.0-alice.plugin (carries Alice's vault key)
```
Resolution gains a lowest-priority **baked** tier (`DEFAULT_OWNER/BASE/KEY` in `echo_config.py`) that is **empty in source** — the committed tree still ships zero credentials, and host behaviour is unchanged (env/config-file still win). `build.py --bake-key` fills those constants from a `config.json` (or `--owner/--endpoint/--key`, or `ECHO_*` env). The end user just installs the artifact — **no config file, no per-session paste**, works on desktop and in every CoWork session.
> **A baked artifact contains a vault bearer token.** Baked builds land in `dist/` (gitignored) and never touch the shared `echo-memory.plugin` pointer. Deliver each one **directly** to its single user; **never commit, push, or publish it.** Use `--strip-key` (or just a clean source build) for a token-free artifact. Rotation = rebuild + reinstall.
Vault paths are addressed **at the root** (e.g. `GET /vault/_agent/context/current-context.md`). Prefer `scripts/echo.py` over raw `curl`: it injects auth, checks HTTP status (a failed write exits non-zero instead of looking like success), retries transient 5xx, verifies PUTs, and does idempotent appends. Vault paths are addressed **at the root** (e.g. `GET /vault/_agent/context/current-context.md`). Prefer `scripts/echo.py` over raw `curl`: it injects auth, checks HTTP status (a failed write exits non-zero instead of looking like success), retries transient 5xx, verifies PUTs, and does idempotent appends.
--- ---
+109 -28
View File
@@ -7,8 +7,13 @@ dev cruft. The version is read from the manifest, so the output is named automat
Usage: Usage:
python build.py # build echo-memory-<version>.plugin + refresh echo-memory.plugin python build.py # build echo-memory-<version>.plugin + refresh echo-memory.plugin
python build.py --strip-key # ALSO blank echo.py's DEFAULT_KEY -> a token-free artifact # (token-free: ships no credentials; user configures at runtime)
# (requires ECHO_KEY or ~/.echo-memory/credentials at runtime) python build.py --bake-key --from coworker.json [--label alice]
# SECRET-BEARING per-user artifact: bake owner/endpoint/key from a
# config.json (or --owner/--endpoint/--key, or ECHO_* env) into the
# echo_config DEFAULT_* constants. Defaults to dist/ and skips the
# shared pointer. Deliver directly to that one user — NEVER commit it.
python build.py --strip-key # force-blank the DEFAULT_* constants -> guaranteed token-free artifact
python build.py --no-pointer # don't update the echo-memory.plugin "current" pointer python build.py --no-pointer # don't update the echo-memory.plugin "current" pointer
python build.py --outdir dist # write artifacts somewhere other than the repo root python build.py --outdir dist # write artifacts somewhere other than the repo root
@@ -20,6 +25,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
import re import re
import sys import sys
import zipfile import zipfile
@@ -35,7 +41,8 @@ for _stream in (sys.stdout, sys.stderr):
REPO = Path(__file__).resolve().parent REPO = Path(__file__).resolve().parent
SRC = REPO / "echo-memory.plugin.src" SRC = REPO / "echo-memory.plugin.src"
MANIFEST = "/".join([".claude-plugin", "plugin.json"]) MANIFEST = "/".join([".claude-plugin", "plugin.json"])
ECHO_PY = "/".join(["skills", "echo-memory", "scripts", "echo.py"]) # The baked DEFAULT_* constants live in echo_config.py (resolution lowest tier).
CONFIG_PY = "/".join(["skills", "echo-memory", "scripts", "echo_config.py"])
EXCLUDE_DIRS = {"__pycache__", ".git"} EXCLUDE_DIRS = {"__pycache__", ".git"}
EXCLUDE_NAMES = {".DS_Store"} EXCLUDE_NAMES = {".DS_Store"}
@@ -44,7 +51,13 @@ FIXED_DATE = (2026, 1, 1, 0, 0, 0) # stable timestamp for reproducible archives
MAX_DESCRIPTION = 500 # plugin-marketplace cap: plugin.json "description" must be UNDER this MAX_DESCRIPTION = 500 # plugin-marketplace cap: plugin.json "description" must be UNDER this
# (it has silently regressed past the limit before — fail the build now) # (it has silently regressed past the limit before — fail the build now)
_KEY_RE = re.compile(r'(DEFAULT_KEY\s*=\s*")([0-9a-fA-F]{16,})(")') # field name -> the constant assigned in echo_config.py
_CONSTS = {"owner": "DEFAULT_OWNER", "endpoint": "DEFAULT_BASE", "key": "DEFAULT_KEY"}
def _const_re(const: str) -> re.Pattern:
"""Match a whole `CONST = <anything>` assignment line."""
return re.compile(r'^(' + re.escape(const) + r'\s*=\s*).*$', re.M)
def included_files() -> list[Path]: def included_files() -> list[Path]:
@@ -60,24 +73,33 @@ def included_files() -> list[Path]:
return out return out
def file_bytes(path: Path, arcname: str, strip_key: bool) -> bytes: def file_bytes(path: Path, arcname: str, bake: dict | None, strip_key: bool) -> bytes:
"""Return the bytes to archive. For echo_config.py, optionally rewrite the
DEFAULT_* constants — inject `bake` values, or blank them with --strip-key."""
data = path.read_bytes() data = path.read_bytes()
if strip_key and arcname == ECHO_PY: if arcname != CONFIG_PY or (bake is None and not strip_key):
text = data.decode("utf-8")
new, n = _KEY_RE.subn(r'\1\3', text) # collapse the hex value to ""
if n:
text = new.replace('DEFAULT_KEY = ""',
'DEFAULT_KEY = "" # stripped at build time — set ECHO_KEY or run write-key')
data = text.encode("utf-8")
return data return data
text = data.decode("utf-8")
for field, const in _CONSTS.items():
repl = json.dumps(bake[field]) if bake is not None else '""'
# function replacement so backslashes in repl (e.g. \uXXXX) stay literal
text, n = _const_re(const).subn(lambda m, r=repl: m.group(1) + r, text, count=1)
if n != 1:
raise RuntimeError(f"build: could not find `{const} = ...` in {arcname}")
return text.encode("utf-8")
def token_present(path: Path) -> bool: def token_present(path: Path) -> bool:
m = _KEY_RE.search(path.read_text(encoding="utf-8")) """True if any DEFAULT_* constant in echo_config.py holds a non-empty value."""
return bool(m and m.group(2)) text = path.read_text(encoding="utf-8")
for const in _CONSTS.values():
m = re.search(r'^' + re.escape(const) + r'\s*=\s*"(.*)"\s*$', text, re.M)
if m and m.group(1):
return True
return False
def build(out: Path, files: list[Path], strip_key: bool) -> int: def build(out: Path, files: list[Path], bake: dict | None, strip_key: bool) -> int:
if out.exists(): if out.exists():
out.unlink() out.unlink()
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
@@ -86,23 +108,73 @@ def build(out: Path, files: list[Path], strip_key: bool) -> int:
info = zipfile.ZipInfo(arc, date_time=FIXED_DATE) info = zipfile.ZipInfo(arc, date_time=FIXED_DATE)
info.compress_type = zipfile.ZIP_DEFLATED info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o644 << 16 info.external_attr = 0o644 << 16
z.writestr(info, file_bytes(p, arc, strip_key)) z.writestr(info, file_bytes(p, arc, bake, strip_key))
return out.stat().st_size return out.stat().st_size
def bake_values(args) -> dict:
"""Resolve owner/endpoint/key for --bake-key from --owner/--endpoint/--key,
else --from <config.json>, else ECHO_OWNER/ECHO_BASE/ECHO_KEY env."""
src = {}
if args.from_file:
fp = Path(args.from_file).expanduser()
if not fp.exists():
raise RuntimeError(f"build: --from file not found: {fp}")
try:
loaded = json.loads(fp.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise RuntimeError(f"build: --from file is not valid JSON ({exc})")
if isinstance(loaded, dict):
src = loaded
vals = {
"owner": args.owner or src.get("owner") or os.environ.get("ECHO_OWNER") or "",
"endpoint": (args.endpoint or src.get("endpoint") or os.environ.get("ECHO_BASE") or "").rstrip("/"),
"key": args.key or src.get("key") or os.environ.get("ECHO_KEY") or "",
}
missing = [f for f in ("endpoint", "key") if not vals[f]]
if missing:
raise RuntimeError(
"build: --bake-key needs a real endpoint and key — missing "
+ ", ".join(missing) + ". Provide --from <config.json>, the --owner/"
"--endpoint/--key flags, or set ECHO_OWNER/ECHO_BASE/ECHO_KEY.")
if "your-obsidian-rest-endpoint" in vals["endpoint"] or vals["key"].startswith("<"):
raise RuntimeError("build: --bake-key got the template placeholders, not real values.")
return vals
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Build the echo-memory .plugin artifact") ap = argparse.ArgumentParser(description="Build the echo-memory .plugin artifact")
ap.add_argument("--bake-key", action="store_true",
help="bake a user's owner/endpoint/key into the artifact (secret-bearing — never commit)")
ap.add_argument("--from", dest="from_file", metavar="FILE",
help="config.json to read owner/endpoint/key from when baking")
ap.add_argument("--owner", help="baked owner (overrides --from / env)")
ap.add_argument("--endpoint", help="baked endpoint (overrides --from / env)")
ap.add_argument("--key", help="baked key (overrides --from / env)")
ap.add_argument("--label", help="suffix for the artifact name, e.g. --label alice")
ap.add_argument("--strip-key", action="store_true", ap.add_argument("--strip-key", action="store_true",
help="blank echo.py DEFAULT_KEY so no token ships in the artifact") help="force-blank the DEFAULT_* constants so no token ships in the artifact")
ap.add_argument("--no-pointer", action="store_true", ap.add_argument("--no-pointer", action="store_true",
help="do not refresh the echo-memory.plugin 'current' pointer") help="do not refresh the echo-memory.plugin 'current' pointer")
ap.add_argument("--outdir", default=str(REPO), help="output directory (default: repo root)") ap.add_argument("--outdir", help="output directory (default: repo root; dist/ when --bake-key)")
args = ap.parse_args(argv) args = ap.parse_args(argv)
if args.bake_key and args.strip_key:
print("build: --bake-key and --strip-key are mutually exclusive", file=sys.stderr)
return 2
if not SRC.is_dir(): if not SRC.is_dir():
print(f"build: source tree not found at {SRC}", file=sys.stderr) print(f"build: source tree not found at {SRC}", file=sys.stderr)
return 1 return 1
bake = None
if args.bake_key:
try:
bake = bake_values(args)
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 2
manifest_path = SRC / ".claude-plugin" / "plugin.json" manifest_path = SRC / ".claude-plugin" / "plugin.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
version = manifest["version"] version = manifest["version"]
@@ -118,22 +190,31 @@ def main(argv: list[str] | None = None) -> int:
print(f"build: {MANIFEST} missing from the archive root — aborting", file=sys.stderr) print(f"build: {MANIFEST} missing from the archive root — aborting", file=sys.stderr)
return 1 return 1
outdir = Path(args.outdir) # Baked artifacts are secret-bearing: default them into dist/ (gitignored) and
# never touch the shared, committable echo-memory.plugin pointer.
default_outdir = (REPO / "dist") if bake else REPO
outdir = Path(args.outdir) if args.outdir else default_outdir
outdir.mkdir(parents=True, exist_ok=True) outdir.mkdir(parents=True, exist_ok=True)
targets = [outdir / f"echo-memory-{version}.plugin"]
if not args.no_pointer: label = f"-{args.label}" if args.label else ""
targets = [outdir / f"echo-memory-{version}{label}.plugin"]
if not args.no_pointer and not bake:
targets.append(outdir / "echo-memory.plugin") targets.append(outdir / "echo-memory.plugin")
for out in targets: for out in targets:
size = build(out, files, args.strip_key) size = build(out, files, bake, args.strip_key)
print(f"built {out.name} ({len(files)} entries, {size:,} bytes)") print(f"built {out.name} ({len(files)} entries, {size:,} bytes)")
echo_src = SRC / "skills" / "echo-memory" / "scripts" / "echo.py" if bake:
if args.strip_key: who = bake.get("owner") or "(no owner)"
print("note: --strip-key set — artifact is token-free; runtime needs ECHO_KEY or a credentials file.") print(f"note: --bake-key baked credentials for {who} into {CONFIG_PY}.")
elif token_present(echo_src): print("WARNING: this artifact carries a vault key — deliver it directly to that one "
print("WARNING: the baked-in DEFAULT_KEY is present in this artifact — do not share it. " "user and NEVER commit, push, or publish it (dist/ is gitignored).")
"Set ECHO_KEY everywhere (API-KEY-SETUP.md), then build with --strip-key.") elif args.strip_key:
print("note: --strip-key set — artifact is token-free; user configures owner/endpoint/key at runtime.")
elif token_present(SRC / "skills" / "echo-memory" / "scripts" / "echo_config.py"):
print("WARNING: a DEFAULT_* constant in echo_config.py is non-empty — this artifact carries a "
"secret. Do not commit it. Build from a clean source tree, or use --strip-key.")
return 0 return 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+66
View File
@@ -0,0 +1,66 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Squircle background: Deep Navy -> Royal -> Signal -->
<linearGradient id="bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#25335C"/>
<stop offset="0.55" stop-color="#204C84"/>
<stop offset="1" stop-color="#1A6E96"/>
</linearGradient>
<!-- Orb glow: teal core -->
<radialGradient id="orb" cx="0.42" cy="0.40" r="0.70">
<stop offset="0" stop-color="#7FF0EC"/>
<stop offset="0.35" stop-color="#1FC9C4"/>
<stop offset="1" stop-color="#009B98"/>
</radialGradient>
<!-- Vault enclosure subtle gradient -->
<linearGradient id="vault" x1="106" y1="160" x2="406" y2="406" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#2A86AE"/>
<stop offset="1" stop-color="#155D80"/>
</linearGradient>
<filter id="soft" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="14"/>
</filter>
<filter id="softer" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="26"/>
</filter>
<clipPath id="squircle">
<path d="M256 8
C 120 8, 8 120, 8 256
C 8 392, 120 504, 256 504
C 392 504, 504 392, 504 256
C 504 120, 392 8, 256 8 Z"/>
</clipPath>
</defs>
<!-- Background -->
<g clip-path="url(#squircle)">
<rect x="0" y="0" width="512" height="512" fill="url(#bg)"/>
<!-- ambient teal glow from the orb spilling into the navy -->
<circle cx="256" cy="262" r="150" fill="#009B98" opacity="0.18" filter="url(#softer)"/>
</g>
<!-- Concentric ripple rings (echo / memory) -->
<g fill="none" stroke="#3FE0DB" stroke-linecap="round">
<circle cx="256" cy="262" r="96" stroke-width="3" opacity="0.55"/>
<circle cx="256" cy="262" r="120" stroke-width="2.5" opacity="0.32"/>
<circle cx="256" cy="262" r="142" stroke-width="2" opacity="0.18"/>
</g>
<!-- Vault / open container: ring open at the top, cradling the orb -->
<path d="M 370.9 165.6 A 150 150 0 1 1 141.1 165.6"
fill="none" stroke="url(#vault)" stroke-width="26" stroke-linecap="round"/>
<!-- inner highlight on the vault -->
<path d="M 370.9 165.6 A 150 150 0 1 1 141.1 165.6"
fill="none" stroke="#5BC7E6" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
<!-- Orb glow halo -->
<circle cx="256" cy="262" r="74" fill="#00C2BE" opacity="0.55" filter="url(#soft)"/>
<!-- Orb -->
<circle cx="256" cy="262" r="62" fill="url(#orb)"/>
<!-- Orb specular highlight -->
<ellipse cx="236" cy="240" rx="22" ry="16" fill="#EAFFFE" opacity="0.45"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
{ {
"name": "echo-memory", "name": "echo-memory",
"version": "1.3.1", "version": "1.4.0",
"description": "Persistent memory via the ECHO Obsidian vault over the Obsidian Local REST API. A cross-platform, connection-pooled Python client: one-call capture/resolve/recall/link over an entity index, hybrid BM25 + graph recall, auto-linking, an offline write-ahead queue, and lock-guarded concurrency. Linter-enforced routing manifest plus /echo-load|save|recall|triage|health|sweep|reflect|doctor commands. Crosslinks notes across Claude and CoWork sessions.", "description": "Persistent memory via the ECHO Obsidian vault over the Obsidian Local REST API. A cross-platform, connection-pooled Python client: one-call capture/resolve/recall/link over an entity index, hybrid BM25 + graph recall, auto-linking, an offline write-ahead queue, and lock-guarded concurrency. Linter-enforced routing manifest plus /echo-load|save|recall|triage|health|sweep|reflect|doctor commands. Crosslinks notes across Claude and CoWork sessions.",
"author": { "author": {
"name": "Jason Stedwell" "name": "Jason Stedwell"
Binary file not shown.
@@ -17,12 +17,21 @@ committed, and is never written into a vault note. To create it, run
it, or `python3 echo.py config set --owner ... --endpoint ... --key ...`. it, or `python3 echo.py config set --owner ... --endpoint ... --key ...`.
Per-field resolution (first hit wins): Per-field resolution (first hit wins):
owner env ECHO_OWNER -> config "owner" owner env ECHO_OWNER -> config "owner" -> baked DEFAULT_OWNER
endpoint env ECHO_BASE -> config "endpoint" endpoint env ECHO_BASE -> config "endpoint" -> baked DEFAULT_BASE
key env ECHO_KEY -> config "key" key env ECHO_KEY -> config "key" -> baked DEFAULT_KEY
The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config
file path itself can be overridden with $ECHO_CONFIG. Pure stdlib only. 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 from __future__ import annotations
@@ -30,6 +39,12 @@ import json
import os import os
from pathlib import Path 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 = { TEMPLATE = {
"owner": "Full Name — how the agent should refer to the vault owner (third person)", "owner": "Full Name — how the agent should refer to the vault owner (third person)",
"endpoint": "https://your-obsidian-rest-endpoint.example.com", "endpoint": "https://your-obsidian-rest-endpoint.example.com",
@@ -68,11 +83,11 @@ def load() -> dict:
which raise a helpful error. This keeps `--help`, `config`, and `doctor` usable which raise a helpful error. This keeps `--help`, `config`, and `doctor` usable
even when nothing is configured yet.""" even when nothing is configured yet."""
f = _from_file() f = _from_file()
endpoint = (os.environ.get("ECHO_BASE") or f.get("endpoint") or "").rstrip("/") endpoint = (os.environ.get("ECHO_BASE") or f.get("endpoint") or DEFAULT_BASE or "").rstrip("/")
return { return {
"owner": os.environ.get("ECHO_OWNER") or f.get("owner") or "", "owner": os.environ.get("ECHO_OWNER") or f.get("owner") or DEFAULT_OWNER or "",
"endpoint": endpoint, "endpoint": endpoint,
"key": os.environ.get("ECHO_KEY") or f.get("key") or "", "key": os.environ.get("ECHO_KEY") or f.get("key") or DEFAULT_KEY or "",
} }
@@ -123,6 +138,9 @@ def source(field: str) -> str:
return f"{env} env" return f"{env} env"
if config_path().exists() and _from_file().get(field): if config_path().exists() and _from_file().get(field):
return "config file" return "config file"
baked = {"owner": DEFAULT_OWNER, "endpoint": DEFAULT_BASE, "key": DEFAULT_KEY}[field]
if baked:
return "baked-in default"
return "unset" return "unset"