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
+110 -29
View File
@@ -7,8 +7,13 @@ dev cruft. The version is read from the manifest, so the output is named automat
Usage:
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
# (requires ECHO_KEY or ~/.echo-memory/credentials at runtime)
# (token-free: ships no credentials; user configures 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 --outdir dist # write artifacts somewhere other than the repo root
@@ -20,6 +25,7 @@ from __future__ import annotations
import argparse
import json
import os
import re
import sys
import zipfile
@@ -35,7 +41,8 @@ for _stream in (sys.stdout, sys.stderr):
REPO = Path(__file__).resolve().parent
SRC = REPO / "echo-memory.plugin.src"
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_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
# (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]:
@@ -60,24 +73,33 @@ def included_files() -> list[Path]:
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()
if strip_key and arcname == ECHO_PY:
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
if arcname != CONFIG_PY or (bake is None and not strip_key):
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:
m = _KEY_RE.search(path.read_text(encoding="utf-8"))
return bool(m and m.group(2))
"""True if any DEFAULT_* constant in echo_config.py holds a non-empty value."""
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():
out.unlink()
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.compress_type = zipfile.ZIP_DEFLATED
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
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:
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",
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",
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)
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():
print(f"build: source tree not found at {SRC}", file=sys.stderr)
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 = json.loads(manifest_path.read_text(encoding="utf-8"))
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)
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)
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")
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)")
echo_src = SRC / "skills" / "echo-memory" / "scripts" / "echo.py"
if args.strip_key:
print("note: --strip-key set — artifact is token-free; runtime needs ECHO_KEY or a credentials file.")
elif token_present(echo_src):
print("WARNING: the baked-in DEFAULT_KEY is present in this artifact — do not share it. "
"Set ECHO_KEY everywhere (API-KEY-SETUP.md), then build with --strip-key.")
if bake:
who = bake.get("owner") or "(no owner)"
print(f"note: --bake-key baked credentials for {who} into {CONFIG_PY}.")
print("WARNING: this artifact carries a vault key — deliver it directly to that one "
"user and NEVER commit, push, or publish it (dist/ is gitignored).")
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