2026-06-22 09:27:36 -05:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""build.py — package the echo-memory plugin source into a .plugin artifact.
|
|
|
|
|
|
|
|
|
|
Zips the CONTENTS of echo-memory.plugin.src/ at the archive root (the layout the plugin
|
|
|
|
|
loader expects: .claude-plugin/plugin.json, commands/, skills/ all at top level), excluding
|
|
|
|
|
dev cruft. The version is read from the manifest, so the output is named automatically.
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
Deterministic: entries are sorted and stamped with a fixed timestamp, so an unchanged
|
|
|
|
|
source tree always produces a byte-identical archive (clean diffs / reproducible builds).
|
|
|
|
|
Pure stdlib; runs the same on Windows/macOS/Linux.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import json
|
|
|
|
|
import re
|
|
|
|
|
import sys
|
|
|
|
|
import zipfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
# Keep non-ASCII (em-dashes in our messages) from raising/garbling on a legacy console.
|
|
|
|
|
for _stream in (sys.stdout, sys.stderr):
|
|
|
|
|
try:
|
|
|
|
|
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
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"])
|
|
|
|
|
|
|
|
|
|
EXCLUDE_DIRS = {"__pycache__", ".git"}
|
|
|
|
|
EXCLUDE_NAMES = {".DS_Store"}
|
|
|
|
|
EXCLUDE_SUFFIXES = {".pyc", ".pyo"}
|
|
|
|
|
FIXED_DATE = (2026, 1, 1, 0, 0, 0) # stable timestamp for reproducible archives
|
2026-06-23 07:18:23 -05:00
|
|
|
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)
|
2026-06-22 09:27:36 -05:00
|
|
|
|
|
|
|
|
_KEY_RE = re.compile(r'(DEFAULT_KEY\s*=\s*")([0-9a-fA-F]{16,})(")')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def included_files() -> list[Path]:
|
|
|
|
|
out = []
|
|
|
|
|
for p in sorted(SRC.rglob("*")):
|
|
|
|
|
if p.is_dir():
|
|
|
|
|
continue
|
|
|
|
|
if any(part in EXCLUDE_DIRS for part in p.relative_to(SRC).parts):
|
|
|
|
|
continue
|
|
|
|
|
if p.name in EXCLUDE_NAMES or p.suffix in EXCLUDE_SUFFIXES:
|
|
|
|
|
continue
|
|
|
|
|
out.append(p)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def file_bytes(path: Path, arcname: str, strip_key: bool) -> bytes:
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def token_present(path: Path) -> bool:
|
|
|
|
|
m = _KEY_RE.search(path.read_text(encoding="utf-8"))
|
|
|
|
|
return bool(m and m.group(2))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build(out: Path, files: list[Path], strip_key: bool) -> int:
|
|
|
|
|
if out.exists():
|
|
|
|
|
out.unlink()
|
|
|
|
|
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
|
|
|
|
|
for p in files:
|
|
|
|
|
arc = p.relative_to(SRC).as_posix()
|
|
|
|
|
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))
|
|
|
|
|
return out.stat().st_size
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
|
|
|
ap = argparse.ArgumentParser(description="Build the echo-memory .plugin artifact")
|
|
|
|
|
ap.add_argument("--strip-key", action="store_true",
|
|
|
|
|
help="blank echo.py DEFAULT_KEY 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)")
|
|
|
|
|
args = ap.parse_args(argv)
|
|
|
|
|
|
|
|
|
|
if not SRC.is_dir():
|
|
|
|
|
print(f"build: source tree not found at {SRC}", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
manifest_path = SRC / ".claude-plugin" / "plugin.json"
|
2026-06-23 07:18:23 -05:00
|
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
|
|
|
version = manifest["version"]
|
|
|
|
|
desc = manifest.get("description", "")
|
|
|
|
|
if len(desc) >= MAX_DESCRIPTION:
|
|
|
|
|
print(f"build: plugin description is {len(desc)} chars — must be under "
|
|
|
|
|
f"{MAX_DESCRIPTION} (marketplace cap). Trim \"description\" in {MANIFEST}.",
|
|
|
|
|
file=sys.stderr)
|
|
|
|
|
return 1
|
2026-06-22 09:27:36 -05:00
|
|
|
files = included_files()
|
|
|
|
|
arcnames = {p.relative_to(SRC).as_posix() for p in files}
|
|
|
|
|
if MANIFEST not in arcnames:
|
|
|
|
|
print(f"build: {MANIFEST} missing from the archive root — aborting", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
outdir = Path(args.outdir)
|
|
|
|
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
targets = [outdir / f"echo-memory-{version}.plugin"]
|
|
|
|
|
if not args.no_pointer:
|
|
|
|
|
targets.append(outdir / "echo-memory.plugin")
|
|
|
|
|
|
|
|
|
|
for out in targets:
|
|
|
|
|
size = build(out, files, 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.")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|