#!/usr/bin/env python3 """build.py — package the chorus-memory plugin source into a .plugin artifact. Zips the CONTENTS of chorus-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 chorus-memory-.plugin + refresh chorus-memory.plugin # (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 CHORUS_* env) into the # chorus_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 chorus-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 os 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 / "chorus-memory.plugin.src" MANIFEST = "/".join([".claude-plugin", "plugin.json"]) # The baked DEFAULT_* constants live in chorus_config.py (resolution lowest tier). CONFIG_PY = "/".join(["skills", "chorus-memory", "scripts", "chorus_config.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 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) # field name -> the constant assigned in chorus_config.py _CONSTS = {"owner": "DEFAULT_OWNER", "endpoint": "DEFAULT_BASE", "key": "DEFAULT_KEY"} def _const_re(const: str) -> re.Pattern: """Match a whole `CONST = ` assignment line.""" return re.compile(r'^(' + re.escape(const) + r'\s*=\s*).*$', re.M) 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, bake: dict | None, strip_key: bool) -> bytes: """Return the bytes to archive. For chorus_config.py, optionally rewrite the DEFAULT_* constants — inject `bake` values, or blank them with --strip-key.""" data = path.read_bytes() 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: """True if any DEFAULT_* constant in chorus_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], bake: dict | None, 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, 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 , else CHORUS_OWNER/CHORUS_BASE/CHORUS_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("CHORUS_OWNER") or "", "endpoint": (args.endpoint or src.get("endpoint") or os.environ.get("CHORUS_BASE") or "").rstrip("/"), "key": args.key or src.get("key") or os.environ.get("CHORUS_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 , the --owner/" "--endpoint/--key flags, or set CHORUS_OWNER/CHORUS_BASE/CHORUS_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 chorus-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="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 chorus-memory.plugin 'current' pointer") 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"] 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 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 # Baked artifacts are secret-bearing: default them into dist/ (gitignored) and # never touch the shared, committable chorus-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) label = f"-{args.label}" if args.label else "" targets = [outdir / f"chorus-memory-{version}{label}.plugin"] if not args.no_pointer and not bake: targets.append(outdir / "chorus-memory.plugin") for out in targets: size = build(out, files, bake, args.strip_key) print(f"built {out.name} ({len(files)} entries, {size:,} bytes)") 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" / "chorus-memory" / "scripts" / "chorus_config.py"): print("WARNING: a DEFAULT_* constant in chorus_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 if __name__ == "__main__": raise SystemExit(main())