@topy-ai/maggie 0.7.48 → 0.7.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +31 -4
  3. package/bin/maggie.js +17 -4
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
  5. package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
  6. package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
  7. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
  8. package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
  9. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  10. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  11. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  12. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  13. package/bundled-references/google-integrations-runbook.md +54 -0
  14. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  15. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  16. package/bundled-skills/maggie-deployment/SKILL.md +89 -7
  17. package/bundled-skills/maggie-deployment/references/vps.md +38 -6
  18. package/bundled-skills/maggie-design/SKILL.md +55 -0
  19. package/bundled-skills/maggie-feedback/SKILL.md +29 -0
  20. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  21. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  22. package/bundled-skills/maggie-service-booking/SKILL.md +59 -0
  23. package/bundled-skills/maggie-social-share/SKILL.md +19 -2
  24. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  25. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
  26. package/bundled-tools/clis/maggie_deployment.py +58 -17
  27. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  28. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  29. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  30. package/bundled-tools/clis/maggie_design.py +8 -0
  31. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  32. package/bundled-tools/clis/maggie_docs.py +82 -4
  33. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  34. package/bundled-tools/clis/maggie_feedback.py +9 -2
  35. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  36. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  37. package/bundled-tools/clis/maggie_localization.py +5 -0
  38. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  39. package/bundled-tools/clis/maggie_notifications.py +528 -0
  40. package/bundled-tools/clis/maggie_ops.py +87 -0
  41. package/bundled-tools/clis/maggie_schedule.py +12 -2
  42. package/bundled-tools/clis/maggie_service_import.py +520 -0
  43. package/bundled-tools/clis/maggie_social_queue.py +104 -0
  44. package/bundled-tools/clis/maggie_social_share.py +6 -2
  45. package/bundled-tools/runtime/google_ads.py +306 -0
  46. package/bundled-tools/runtime/google_capabilities.py +18 -2
  47. package/bundled-tools/runtime/google_remediation.py +152 -0
  48. package/package.json +1 -1
  49. package/references/google-integrations-runbook.md +54 -0
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env python3
2
+ """Check localized email templates and consent-aware lifecycle email plans.
3
+
4
+ Both commands are read-only validators. They never render with real customer
5
+ data, call a provider, or send email. ``email-matrix`` can write preview files
6
+ filled from declared fixtures for human review.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import html
13
+ import json
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ EMAIL_SCHEMA = "maggie-email-localization.v1"
19
+ LIFECYCLE_SCHEMA = "maggie-lifecycle-email.v1"
20
+
21
+ LOCALE_SOURCES = ("user", "market", "account", "fallback")
22
+ UNSAFE_LOCALE_SOURCES = ("accept-language", "hard-coded")
23
+ FIELDS = ("subject", "html", "text")
24
+ CATEGORIES = ("transactional", "support", "marketing")
25
+ PLACEHOLDER = re.compile(r"\{\{\s*([A-Za-z_][\w.]*)\s*\}\}|\{([A-Za-z_][\w.]*)\}")
26
+ TAG = re.compile(r"<[^>]+>")
27
+
28
+ LIFECYCLE_STAGES = ("plan", "segment", "consent-check", "preview", "approve", "schedule", "send", "report")
29
+ LIBRARY_KINDS = (
30
+ "opt-in-confirmation", "welcome", "digest", "recommendation", "gift-follow-up",
31
+ "voucher-follow-up", "waitlist-follow-up", "merchant-lead-digest",
32
+ "campaign-announcement", "re-engagement",
33
+ )
34
+ CONSENT_BASES = ("opt-in", "double-opt-in", "soft-opt-in", "legitimate-interest-b2b")
35
+ # The confirmation request is sent before marketing consent exists, so it is
36
+ # transactional and needs no unsubscribe link, but it still has a frequency cap.
37
+ PRE_CONSENT_KINDS = ("opt-in-confirmation",)
38
+
39
+
40
+ def placeholders(value: str) -> set[str]:
41
+ return {a or b for a, b in PLACEHOLDER.findall(value or "")}
42
+
43
+
44
+ def visible_text(value: str) -> str:
45
+ stripped = PLACEHOLDER.sub("", TAG.sub(" ", value or ""))
46
+ return re.sub(r"\s+", " ", html.unescape(stripped)).strip()
47
+
48
+
49
+ def language(locale: str) -> str:
50
+ return (locale or "").split("-")[0].lower()
51
+
52
+
53
+ def route_ok(route: str) -> bool:
54
+ return isinstance(route, str) and (route.startswith("/") or route.startswith("https://"))
55
+
56
+
57
+ def check_locale_resolution(template: dict, errors: list[str]) -> None:
58
+ tid = template.get("id")
59
+ order = template.get("localeResolution")
60
+ if not isinstance(order, list) or not order:
61
+ errors.append(f"{tid}: localeResolution must list the locale sources explicitly")
62
+ return
63
+ unsafe = [item for item in order if item in UNSAFE_LOCALE_SOURCES]
64
+ if unsafe:
65
+ errors.append(f"{tid}: localeResolution must not use {', '.join(unsafe)}; resolve from user, market, account, then fallback")
66
+ known = [item for item in order if item in LOCALE_SOURCES]
67
+ if known != [item for item in LOCALE_SOURCES if item in known]:
68
+ errors.append(f"{tid}: localeResolution precedence must be user > market > account > fallback")
69
+ if order[-1] != "fallback":
70
+ errors.append(f"{tid}: localeResolution must end with fallback")
71
+
72
+
73
+ def check_call_sites(manifest: dict, errors: list[str], warnings: list[str]) -> None:
74
+ templates = {item.get("id"): item for item in manifest.get("templates", []) if isinstance(item, dict)}
75
+ for site in manifest.get("callSites", []) or []:
76
+ source = site.get("localeSource")
77
+ tid = site.get("template")
78
+ label = f"call site {site.get('id') or tid}"
79
+ if tid not in templates:
80
+ errors.append(f"{label}: unknown template {tid}")
81
+ if source == "hard-coded":
82
+ errors.append(f"{label}: sends with a hard-coded locale; pass the resolved recipient locale")
83
+ elif source == "accept-language":
84
+ category = templates.get(tid, {}).get("category")
85
+ if site.get("path") == "fulfilment" or category == "transactional":
86
+ errors.append(f"{label}: fulfilment/transactional email must not use Accept-Language alone; resolve user > market > account > fallback")
87
+ else:
88
+ warnings.append(f"{label}: Accept-Language is only a hint; prefer a stored user or market locale")
89
+ elif source not in ("resolved",) + LOCALE_SOURCES:
90
+ errors.append(f"{label}: localeSource must be resolved, user, market, account or fallback")
91
+
92
+
93
+ def check_template(manifest: dict, template: dict, preview_dir: Path | None, errors: list[str], warnings: list[str]) -> list[dict]:
94
+ tid = template.get("id") or "<unnamed>"
95
+ source_locale = manifest.get("sourceLocale")
96
+ required = template.get("requiredLocales") or manifest.get("locales") or []
97
+ declared = set(template.get("variables") or [])
98
+ markets = manifest.get("markets") or {}
99
+ glossary = manifest.get("glossary") or []
100
+ if template.get("category") not in CATEGORIES:
101
+ errors.append(f"{tid}: category must be one of {', '.join(CATEGORIES)}")
102
+ check_locale_resolution(template, errors)
103
+ locales = template.get("locales") or {}
104
+ source = locales.get(source_locale) or {}
105
+ if not source:
106
+ errors.append(f"{tid}: missing source locale {source_locale}")
107
+ rows = []
108
+ for locale in required:
109
+ entry = locales.get(locale)
110
+ if not isinstance(entry, dict):
111
+ errors.append(f"{tid}: missing locale {locale}")
112
+ continue
113
+ for field in FIELDS:
114
+ if not isinstance(entry.get(field), str) or not entry[field].strip():
115
+ errors.append(f"{tid}/{locale}: missing {field}")
116
+ used = set()
117
+ for field in FIELDS:
118
+ value = entry.get(field) or ""
119
+ found = placeholders(value)
120
+ used |= found
121
+ undeclared = found - declared
122
+ if undeclared:
123
+ errors.append(f"{tid}/{locale}: {field} uses undeclared variables {', '.join(sorted(undeclared))}")
124
+ if locale != source_locale and source.get(field):
125
+ expected = placeholders(source[field])
126
+ if found != expected:
127
+ missing, extra = sorted(expected - found), sorted(found - expected)
128
+ errors.append(f"{tid}/{locale}: {field} variables changed from source (missing {missing or '[]'}, extra {extra or '[]'})")
129
+ if language(locale) != language(source_locale) and visible_text(value) and visible_text(value) == visible_text(source[field]):
130
+ errors.append(f"{tid}/{locale}: {field} is identical to the source text (untranslated)")
131
+ if placeholders(entry.get("html", "")) != placeholders(entry.get("text", "")):
132
+ errors.append(f"{tid}/{locale}: HTML and plain-text bodies use different variables")
133
+ route = entry.get("ctaRoute", template.get("cta", {}).get("route"))
134
+ if template.get("cta") or entry.get("ctaRoute"):
135
+ if not route_ok(route):
136
+ errors.append(f"{tid}/{locale}: CTA route must be a site path or https URL")
137
+ else:
138
+ market = entry.get("market")
139
+ prefix = (markets.get(market) or {}).get("routePrefix") if market else None
140
+ if market and market not in markets:
141
+ errors.append(f"{tid}/{locale}: unknown market {market}")
142
+ elif prefix and route.startswith("/") and not (route == prefix or route.startswith(prefix.rstrip("/") + "/")):
143
+ errors.append(f"{tid}/{locale}: CTA route {route} does not use market prefix {prefix}")
144
+ if market and locale not in ((markets.get(market) or {}).get("locales") or [locale]):
145
+ errors.append(f"{tid}/{locale}: market {market} does not serve locale {locale}")
146
+ base = template.get("cta", {}).get("route") or ""
147
+ if placeholders(route) != placeholders(base) and base:
148
+ errors.append(f"{tid}/{locale}: CTA route parameters differ from the template route")
149
+ for field in ("html", "text"):
150
+ body = entry.get(field) or ""
151
+ if route.split("{")[0] and route.split("{")[0] not in body and "{{ctaUrl}}" not in body and "{ctaUrl}" not in body:
152
+ warnings.append(f"{tid}/{locale}: {field} body does not include the CTA route or {{{{ctaUrl}}}}")
153
+ if locale != source_locale:
154
+ for term in glossary:
155
+ if term.get("locale") != locale:
156
+ continue
157
+ src_term, target = str(term.get("term", "")), str(term.get("translation", ""))
158
+ if src_term and src_term.lower() in visible_text(source.get("text", "")).lower() and target and target not in (entry.get("text") or ""):
159
+ warnings.append(f"{tid}/{locale}: glossary term '{src_term}' is not rendered as '{target}'")
160
+ approval = entry.get("approval") or {}
161
+ approved = approval.get("status") == "approved" and bool(approval.get("reviewer"))
162
+ if not approved:
163
+ warnings.append(f"{tid}/{locale}: not approved; not publishable")
164
+ rows.append({"template": tid, "locale": locale, "market": entry.get("market"), "approved": approved})
165
+ if preview_dir is not None:
166
+ write_preview(preview_dir, tid, locale, entry, template.get("fixtures") or {}, route)
167
+ unused = declared - set().union(*(placeholders((locales.get(l) or {}).get(f, "")) for l in required for f in FIELDS)) if required else set()
168
+ if unused:
169
+ warnings.append(f"{tid}: declared variables never used: {', '.join(sorted(unused))}")
170
+ return rows
171
+
172
+
173
+ def fill(value: str, fixtures: dict, escape: bool) -> str:
174
+ def replace(match: re.Match) -> str:
175
+ key = match.group(1) or match.group(2)
176
+ raw = str(fixtures.get(key, f"[{key}]"))
177
+ return html.escape(raw) if escape else raw
178
+ return PLACEHOLDER.sub(replace, value or "")
179
+
180
+
181
+ def safe_name(value: str) -> str:
182
+ return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-") or "template"
183
+
184
+
185
+ def write_preview(directory: Path, tid: str, locale: str, entry: dict, fixtures: dict, route: str | None) -> None:
186
+ directory.mkdir(parents=True, exist_ok=True)
187
+ stem = f"{safe_name(tid)}.{safe_name(locale)}"
188
+ subject = fill(entry.get("subject", ""), fixtures, escape=False)
189
+ banner = f"<p data-maggie-preview>Preview only — fixture data, not sent. Subject: {html.escape(subject)}. CTA: {html.escape(str(route or ''))}</p>"
190
+ (directory / f"{stem}.html").write_text(banner + fill(entry.get("html", ""), fixtures, escape=True), encoding="utf-8")
191
+ (directory / f"{stem}.txt").write_text(f"Subject: {subject}\n\n" + fill(entry.get("text", ""), fixtures, escape=False), encoding="utf-8")
192
+
193
+
194
+ def email_matrix(manifest: dict, preview_dir: Path | None = None, require_approved: bool = False) -> dict:
195
+ errors: list[str] = []
196
+ warnings: list[str] = []
197
+ if manifest.get("schemaVersion") != EMAIL_SCHEMA:
198
+ errors.append(f"schemaVersion must be {EMAIL_SCHEMA}")
199
+ if not manifest.get("sourceLocale"):
200
+ errors.append("sourceLocale is required")
201
+ templates = manifest.get("templates")
202
+ if not isinstance(templates, list) or not templates:
203
+ errors.append("templates must be a non-empty list")
204
+ templates = []
205
+ seen: set[str] = set()
206
+ rows: list[dict] = []
207
+ for template in templates:
208
+ if not isinstance(template, dict):
209
+ errors.append("template entries must be objects")
210
+ continue
211
+ if template.get("id") in seen:
212
+ errors.append(f"duplicate template id {template.get('id')}")
213
+ seen.add(template.get("id"))
214
+ rows.extend(check_template(manifest, template, preview_dir, errors, warnings))
215
+ check_call_sites(manifest, errors, warnings)
216
+ if require_approved:
217
+ errors.extend(f"{row['template']}/{row['locale']}: approval required" for row in rows if not row["approved"])
218
+ return {
219
+ "schemaVersion": "maggie-email-localization-report.v1",
220
+ "status": "failed" if errors else "passed",
221
+ "templates": len(seen),
222
+ "matrix": rows,
223
+ "publishable": not errors and all(row["approved"] for row in rows),
224
+ "errors": errors,
225
+ "warnings": warnings,
226
+ "previewDir": str(preview_dir) if preview_dir else None,
227
+ "sends": False,
228
+ }
229
+
230
+
231
+ def approved(record: dict | None) -> bool:
232
+ return isinstance(record, dict) and record.get("status") == "approved" and bool(record.get("reviewer")) and bool(record.get("approvedAt"))
233
+
234
+
235
+ def lifecycle_validate(plan: dict) -> dict:
236
+ errors: list[str] = []
237
+ warnings: list[str] = []
238
+ if plan.get("schemaVersion") != LIFECYCLE_SCHEMA:
239
+ errors.append(f"schemaVersion must be {LIFECYCLE_SCHEMA}")
240
+ stages = plan.get("stages")
241
+ if stages != list(LIFECYCLE_STAGES):
242
+ errors.append(f"stages must be exactly {', '.join(LIFECYCLE_STAGES)} in order")
243
+ if (plan.get("defaults") or {}).get("send") != "disabled":
244
+ errors.append("defaults.send must be 'disabled' (no send until approved)")
245
+ templates = plan.get("templates") or []
246
+ ids: dict[str, dict] = {}
247
+ for template in templates:
248
+ tid = template.get("id") or "<unnamed>"
249
+ if tid in ids:
250
+ errors.append(f"duplicate template id {tid}")
251
+ ids[tid] = template
252
+ kind = template.get("kind")
253
+ category = template.get("category")
254
+ if kind not in LIBRARY_KINDS:
255
+ errors.append(f"{tid}: kind must be one of {', '.join(LIBRARY_KINDS)}")
256
+ if category not in CATEGORIES:
257
+ errors.append(f"{tid}: category must be one of {', '.join(CATEGORIES)}")
258
+ if kind in PRE_CONSENT_KINDS:
259
+ if category != "transactional":
260
+ errors.append(f"{tid}: opt-in confirmation is sent before consent and must be transactional, with no promotional content")
261
+ elif category == "marketing":
262
+ consent = template.get("consent") or {}
263
+ if consent.get("basis") not in CONSENT_BASES:
264
+ errors.append(f"{tid}: marketing template needs consent.basis ({', '.join(CONSENT_BASES)})")
265
+ if not consent.get("category"):
266
+ errors.append(f"{tid}: marketing template needs consent.category (preference-centre category)")
267
+ if not route_ok((template.get("unsubscribe") or {}).get("path")):
268
+ errors.append(f"{tid}: marketing template needs an unsubscribe path")
269
+ if not route_ok(template.get("preferenceCentre")):
270
+ errors.append(f"{tid}: marketing template needs a preference-centre path")
271
+ attribution = (template.get("attribution") or {}).get("fields") or []
272
+ if not attribution:
273
+ errors.append(f"{tid}: marketing template needs attribution fields (e.g. source, utm_campaign)")
274
+ elif category == "transactional" and kind not in PRE_CONSENT_KINDS:
275
+ warnings.append(f"{tid}: {kind} is normally marketing; confirm it carries no promotional content")
276
+ frequency = template.get("frequency") or {}
277
+ if not (isinstance(frequency.get("maxPerWindow"), int) and frequency["maxPerWindow"] >= 1 and frequency.get("window")):
278
+ errors.append(f"{tid}: frequency needs maxPerWindow >= 1 and a window (e.g. P7D)")
279
+ if template.get("sendable") and not approved(template.get("approval")):
280
+ errors.append(f"{tid}: sendable without an approved approval record (status, reviewer, approvedAt)")
281
+ for campaign in plan.get("campaigns") or []:
282
+ cid = campaign.get("id") or "<unnamed>"
283
+ for tid in campaign.get("templates") or []:
284
+ if tid not in ids:
285
+ errors.append(f"campaign {cid}: unknown template {tid}")
286
+ elif campaign.get("sendable") and not ids[tid].get("sendable"):
287
+ errors.append(f"campaign {cid}: template {tid} is not approved for sending")
288
+ audience = campaign.get("audience") or {}
289
+ uses_marketing = any(ids.get(tid, {}).get("category") == "marketing" for tid in campaign.get("templates") or [])
290
+ if uses_marketing and not audience.get("requiresConsent"):
291
+ errors.append(f"campaign {cid}: marketing audience must require consent")
292
+ if not audience.get("suppression"):
293
+ errors.append(f"campaign {cid}: audience must honour suppression")
294
+ if not audience.get("dedupe"):
295
+ errors.append(f"campaign {cid}: audience must deduplicate contacts")
296
+ if campaign.get("sendable") and not approved(campaign.get("approval")):
297
+ errors.append(f"campaign {cid}: sendable without an approved approval record")
298
+ if campaign.get("sendable") and not campaign.get("schedule"):
299
+ errors.append(f"campaign {cid}: sendable campaign needs a schedule")
300
+ if not (campaign.get("report") or {}).get("metrics"):
301
+ warnings.append(f"campaign {cid}: no aggregate report metrics declared")
302
+ missing = [kind for kind in LIBRARY_KINDS if kind not in {t.get("kind") for t in templates}]
303
+ return {
304
+ "schemaVersion": "maggie-lifecycle-email-report.v1",
305
+ "status": "failed" if errors else "passed",
306
+ "templates": len(ids),
307
+ "sendable": sorted(tid for tid, item in ids.items() if item.get("sendable")),
308
+ "libraryCoverage": {"missing": missing},
309
+ "errors": errors,
310
+ "warnings": warnings,
311
+ "sends": False,
312
+ }
313
+
314
+
315
+ def library() -> dict:
316
+ """A starter plan: every template drafted, nothing sendable."""
317
+ templates = []
318
+ for kind in LIBRARY_KINDS:
319
+ pre = kind in PRE_CONSENT_KINDS
320
+ entry = {
321
+ "id": kind,
322
+ "kind": kind,
323
+ "category": "transactional" if pre else "marketing",
324
+ "frequency": {"maxPerWindow": 1, "window": "P1D" if pre else "P7D"},
325
+ "approval": {"status": "pending"},
326
+ "sendable": False,
327
+ }
328
+ if not pre:
329
+ entry.update({
330
+ "consent": {"basis": "opt-in", "category": "marketing.updates"},
331
+ "unsubscribe": {"path": "/email/unsubscribe/{token}"},
332
+ "preferenceCentre": "/account/notifications",
333
+ "attribution": {"fields": ["source", "utm_source", "utm_medium", "utm_campaign"]},
334
+ })
335
+ templates.append(entry)
336
+ return {"schemaVersion": LIFECYCLE_SCHEMA, "stages": list(LIFECYCLE_STAGES), "defaults": {"send": "disabled"}, "templates": templates, "campaigns": []}
337
+
338
+
339
+ def load(path: Path) -> dict:
340
+ data = json.loads(path.read_text(encoding="utf-8"))
341
+ if not isinstance(data, dict):
342
+ raise ValueError(f"{path} must contain a JSON object")
343
+ return data
344
+
345
+
346
+ def add_parsers(sub: argparse._SubParsersAction) -> None:
347
+ matrix = sub.add_parser("email-matrix", help="Check localized email templates (locales, variables, routes, parity, approval)")
348
+ matrix.add_argument("manifest", type=Path)
349
+ matrix.add_argument("--preview-dir", type=Path, help="write fixture-filled HTML/text previews for review")
350
+ matrix.add_argument("--require-approved", action="store_true", help="fail when any template locale is not approved")
351
+ lifecycle = sub.add_parser("lifecycle", help="Validate a consent-aware lifecycle/marketing email plan; never sends")
352
+ lifecycle.add_argument("plan", type=Path, nargs="?")
353
+ lifecycle.add_argument("--library", action="store_true", help="print the starter template library (nothing sendable)")
354
+
355
+
356
+ def run(args: argparse.Namespace) -> int:
357
+ if args.command == "email-matrix":
358
+ result = email_matrix(load(args.manifest), args.preview_dir, args.require_approved)
359
+ else:
360
+ if args.library:
361
+ print(json.dumps(library(), indent=2))
362
+ return 0
363
+ if not args.plan:
364
+ raise ValueError("lifecycle needs a plan file or --library")
365
+ result = lifecycle_validate(load(args.plan))
366
+ print(json.dumps(result, indent=2, ensure_ascii=False))
367
+ return 0 if result["status"] == "passed" else 1
368
+
369
+
370
+ def main() -> int:
371
+ parser = argparse.ArgumentParser(description=__doc__)
372
+ add_parsers(parser.add_subparsers(dest="command", required=True))
373
+ args = parser.parse_args()
374
+ try:
375
+ return run(args)
376
+ except (OSError, ValueError, json.JSONDecodeError) as error:
377
+ print(f"ERROR: {error}", file=sys.stderr)
378
+ return 1
379
+
380
+
381
+ if __name__ == "__main__":
382
+ raise SystemExit(main())
@@ -382,7 +382,14 @@ def list_feedback(args: argparse.Namespace) -> int:
382
382
 
383
383
  def collect_batch(args: argparse.Namespace) -> int:
384
384
  """Create one privacy-safe draft per item while retaining shared batch context."""
385
- manifest = load_json(Path(args.batch_file).expanduser().resolve())
385
+ if args.batch_file == "-":
386
+ # Reading JSON from stdin lets a quoted heredoc carry multiline text and
387
+ # backticks without shell interpolation.
388
+ manifest = json.loads(sys.stdin.read())
389
+ if not isinstance(manifest, dict):
390
+ raise ValueError("batch input must be a JSON object")
391
+ else:
392
+ manifest = load_json(Path(args.batch_file).expanduser().resolve())
386
393
  batch_id = safe_text(manifest.get("batchId"))
387
394
  if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
388
395
  raise ValueError("batch file requires a safe batchId")
@@ -655,7 +662,7 @@ def main() -> int:
655
662
  collect_parser.add_argument("--relationship", dest="relationships", action="append", default=[], help="safe relation=target pointer")
656
663
  batch_parser = sub.add_parser("batch")
657
664
  batch_parser.add_argument("--project", default=argparse.SUPPRESS)
658
- batch_parser.add_argument("--batch-file", required=True)
665
+ batch_parser.add_argument("--batch-file", required=True, help="batch JSON file, or - to read JSON from stdin")
659
666
  summary_parser = sub.add_parser("session-summary", help="draft batch items from git commit subjects for review")
660
667
  summary_parser.add_argument("--project", default=argparse.SUPPRESS)
661
668
  summary_parser.add_argument("--since", required=True, help="git revision; commits after it are summarized")