forked from jason/echo
6b538bbb87
The plugin upload validates each skills/**/SKILL.md `description` frontmatter against a 1024-char cap (distinct from plugin.json's <500 rule). The skill description was 1171 chars and got rejected on upload. - Trim the chorus-memory SKILL.md description to 1000 chars, preserving the save/recall/group triggers and the full "Do NOT" exclusion list. - Add MAX_SKILL_DESCRIPTION=1024 guard to build.py: scans every SKILL.md and fails the build with an over-by-N message so this can't silently regress. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
262 lines
12 KiB
Python
262 lines
12 KiB
Python
#!/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-<version>.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)
|
|
MAX_SKILL_DESCRIPTION = 1024 # marketplace cap: each SKILL.md frontmatter "description" must be
|
|
# AT MOST this many chars (upload rejects it otherwise — fail here first)
|
|
|
|
# field name -> the constant assigned in chorus_config.py
|
|
_CONSTS = {"group": "DEFAULT_GROUP", "member": "DEFAULT_MEMBER",
|
|
"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]:
|
|
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 skill_description_violations() -> list[str]:
|
|
"""Return a message for every skills/**/SKILL.md whose frontmatter `description`
|
|
exceeds MAX_SKILL_DESCRIPTION. The marketplace validates this at upload time and
|
|
rejects the whole plugin, so catch it at build time instead."""
|
|
problems = []
|
|
for skill_md in sorted((SRC / "skills").rglob("SKILL.md")):
|
|
text = skill_md.read_text(encoding="utf-8")
|
|
fm = re.match(r"^---\n(.*?)\n---", text, re.S)
|
|
if not fm:
|
|
continue
|
|
# description is a single-line scalar: capture from `description:` to the next
|
|
# top-level `key:` line (or end of frontmatter).
|
|
dm = re.search(r"^description:[ \t]*(.*?)(?=\n[A-Za-z_][\w-]*:[ \t]|\Z)",
|
|
fm.group(1), re.S | re.M)
|
|
if not dm:
|
|
continue
|
|
desc = dm.group(1).strip()
|
|
if len(desc) > 2 and desc[0] in "\"'" and desc[-1] == desc[0]:
|
|
desc = desc[1:-1]
|
|
if len(desc) > MAX_SKILL_DESCRIPTION:
|
|
rel = skill_md.relative_to(SRC).as_posix()
|
|
problems.append(f"{rel}: description is {len(desc)} chars — must be at most "
|
|
f"{MAX_SKILL_DESCRIPTION} (over by {len(desc) - MAX_SKILL_DESCRIPTION}).")
|
|
return problems
|
|
|
|
|
|
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 group/member/endpoint/key for --bake-key from the CLI flags,
|
|
else --from <config.json>, else CHORUS_GROUP/CHORUS_MEMBER/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 = {
|
|
"group": args.group or src.get("group") or os.environ.get("CHORUS_GROUP") or "",
|
|
"member": args.member or src.get("member") or os.environ.get("CHORUS_MEMBER") 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", "member") if not vals[f]]
|
|
if missing:
|
|
raise RuntimeError(
|
|
"build: --bake-key needs a real endpoint, key, and member — missing "
|
|
+ ", ".join(missing) + ". Provide --from <config.json>, the --group/--member/"
|
|
"--endpoint/--key flags, or set CHORUS_GROUP/CHORUS_MEMBER/CHORUS_BASE/CHORUS_KEY.")
|
|
if "your-obsidian-rest-endpoint" in vals["endpoint"] or vals["key"].startswith("<") \
|
|
or vals["member"].startswith("<"):
|
|
raise RuntimeError("build: --bake-key got the template placeholders, not real values.")
|
|
if not re.match(r"^[a-z0-9][a-z0-9-]{0,31}$", vals["member"]):
|
|
raise RuntimeError(f"build: baked member must be a kebab-case slug, got {vals['member']!r}")
|
|
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 member's group/member/endpoint/key into the artifact (secret-bearing — never commit)")
|
|
ap.add_argument("--from", dest="from_file", metavar="FILE",
|
|
help="config.json to read group/member/endpoint/key from when baking")
|
|
ap.add_argument("--group", help="baked group name (overrides --from / env)")
|
|
ap.add_argument("--member", help="baked member id (kebab-case; 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
|
|
skill_problems = skill_description_violations()
|
|
for problem in skill_problems:
|
|
print(f"build: {problem} Trim the SKILL.md \"description\" frontmatter.", file=sys.stderr)
|
|
if skill_problems:
|
|
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("member") or "(no member)"
|
|
print(f"note: --bake-key baked credentials for member '{who}' into {CONFIG_PY}.")
|
|
print("WARNING: this artifact carries a vault key — deliver it directly to that one "
|
|
"member and NEVER commit, push, or publish it (dist/ is gitignored).")
|
|
elif args.strip_key:
|
|
print("note: --strip-key set — artifact is token-free; member configures group/member/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())
|