@topy-ai/maggie 0.7.47 → 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.
- package/README-zh-TW.md +2 -2
- package/README.md +76 -15
- package/bin/maggie.js +23 -6
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
- package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
- package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
- package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
- package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
- package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
- package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
- package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
- package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
- package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
- package/bundled-marketplace/README.md +17 -0
- package/bundled-marketplace/catalog.json +56 -7
- package/bundled-references/browser-inspection.md +25 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
- package/bundled-references/maggiedash-booking/PRD.md +9 -1
- package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
- package/bundled-references/maggiedash-booking/README.md +23 -6
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
- package/bundled-references/maggiedash-booking/TASKS.md +44 -3
- package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
- package/bundled-references/provider-adapter-contract.md +33 -0
- package/bundled-skills/maggie-booking/SKILL.md +8 -0
- package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
- package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
- package/bundled-skills/maggie-deployment/SKILL.md +141 -7
- package/bundled-skills/maggie-deployment/references/vps.md +74 -1
- package/bundled-skills/maggie-design/SKILL.md +154 -0
- package/bundled-skills/maggie-feedback/SKILL.md +67 -1
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-ops/SKILL.md +37 -0
- package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
- package/bundled-skills/maggie-social-share/SKILL.md +56 -7
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
- package/bundled-skills/maggie-template/SKILL.md +57 -3
- package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
- package/bundled-tools/clis/maggie_booking.py +199 -26
- package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
- package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
- package/bundled-tools/clis/maggie_deployment.py +307 -18
- package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
- package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
- package/bundled-tools/clis/maggie_design.py +17 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_design_verify.py +359 -0
- package/bundled-tools/clis/maggie_docs.py +82 -4
- package/bundled-tools/clis/maggie_email_localization.py +382 -0
- package/bundled-tools/clis/maggie_feedback.py +115 -5
- package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
- package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
- package/bundled-tools/clis/maggie_localization.py +5 -0
- package/bundled-tools/clis/maggie_marketing_run.py +272 -0
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_schedule.py +12 -2
- package/bundled-tools/clis/maggie_service_import.py +520 -0
- package/bundled-tools/clis/maggie_social_queue.py +442 -0
- package/bundled-tools/clis/maggie_social_share.py +46 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/bundled-tools/runtime/google_ads.py +306 -0
- package/bundled-tools/runtime/google_capabilities.py +18 -2
- package/bundled-tools/runtime/google_remediation.py +152 -0
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- package/references/google-integrations-runbook.md +54 -0
- package/references/provider-adapter-contract.md +33 -0
|
@@ -15,6 +15,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
|
15
15
|
from content_localization import LANGUAGES, MARKETS, OPERATIONS, parse_locale, protected_field_changes, stale, valid_locale, validate_operation_quality, validate_translation
|
|
16
16
|
from localization_runner import generate, process_adapter
|
|
17
17
|
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
|
+
import maggie_email_localization as email_localization
|
|
20
|
+
|
|
18
21
|
|
|
19
22
|
def generate_job(args: argparse.Namespace) -> int:
|
|
20
23
|
if not args.confirm:
|
|
@@ -395,6 +398,7 @@ def main() -> int:
|
|
|
395
398
|
publish_parser.add_argument("job", type=Path); publish_parser.add_argument("--confirm", action="store_true")
|
|
396
399
|
for name in ("stale", "glossary"):
|
|
397
400
|
command = sub.add_parser(name); command.add_argument("--project", type=Path, default=Path("."))
|
|
401
|
+
email_localization.add_parsers(sub)
|
|
398
402
|
args = parser.parse_args()
|
|
399
403
|
try:
|
|
400
404
|
if args.command == "generate": return generate_job(args)
|
|
@@ -406,6 +410,7 @@ def main() -> int:
|
|
|
406
410
|
if args.command == "review": return review(args.job, args.reviewer, args.decision)
|
|
407
411
|
if args.command == "publish": return publish(args.job, args.confirm)
|
|
408
412
|
if args.command == "stale": return stale_jobs(args.project)
|
|
413
|
+
if args.command in ("email-matrix", "lifecycle"): return email_localization.run(args)
|
|
409
414
|
return glossary(args.project)
|
|
410
415
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
411
416
|
print(f"ERROR: {error}", file=sys.stderr); return 1
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate provider-neutral Maggie marketing runs and their named schedule layers.
|
|
3
|
+
|
|
4
|
+
A marketing run (`maggie-marketing-run-v1`) is one project-scoped record that
|
|
5
|
+
joins content, Social Share, Ads, and measurement work. It references source
|
|
6
|
+
revisions, capability evidence, approvals, mutations, and readbacks by ID; it
|
|
7
|
+
never copies provider secrets or large payloads.
|
|
8
|
+
|
|
9
|
+
Schedule layers keep four different meanings of "when" apart:
|
|
10
|
+
|
|
11
|
+
- `infrastructure`: when a worker wakes up (cron, systemd timer, queue);
|
|
12
|
+
validated by `maggie_schedule.py`.
|
|
13
|
+
- `content_calendar`: when reviewed content or a social post may publish.
|
|
14
|
+
- `campaign_flight`: when paid campaigns may run and spend.
|
|
15
|
+
- `measurement_window`: the data collection and attribution period, including
|
|
16
|
+
conversion lag.
|
|
17
|
+
|
|
18
|
+
No function in this module calls a provider, a model, or the network.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
from datetime import date, datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
SCHEMA_VERSION = "maggie-marketing-run-v1"
|
|
32
|
+
SCHEDULE_LAYERS = {
|
|
33
|
+
"infrastructure": "Operations: when a worker wakes up (cron/systemd/queue); never a publish time.",
|
|
34
|
+
"content_calendar": "Editor/marketer: when a reviewed content or social item may publish.",
|
|
35
|
+
"campaign_flight": "Ads owner: when a paid campaign may run and spend.",
|
|
36
|
+
"measurement_window": "Analytics: data collection and attribution period, including conversion lag.",
|
|
37
|
+
}
|
|
38
|
+
LAYER_STATUSES = {"planned", "active", "paused", "completed", "cancelled"}
|
|
39
|
+
RUN_MODES = {"draft", "scheduled", "executing", "completed", "cancelled"}
|
|
40
|
+
APPROVAL_STATES = {"pending", "approved", "rejected", "not_required"}
|
|
41
|
+
MUTATION_STATUSES = {"planned", "pending", "succeeded", "failed", "cancelled"}
|
|
42
|
+
MUTATION_KINDS = {"external_publication", "ads_change", "content_delivery", "draft_creation", "configuration"}
|
|
43
|
+
# Workflow stages a regulated-content policy may annotate. Only external
|
|
44
|
+
# publication can be gated by the policy review; everything else continues.
|
|
45
|
+
STAGES = ("delivery", "draft", "seo_geo", "measurement", "external_publication")
|
|
46
|
+
POLICY_GATED_STAGES = {"external_publication"}
|
|
47
|
+
REVIEW_STATES = {"not_required", "pending", "approved", "rejected"}
|
|
48
|
+
SECRET_KEY = re.compile(r"(?:^|_)(?:secret|token|password|api_?key|authorization|cookie)s?$", re.I)
|
|
49
|
+
SECRET_VALUE = re.compile(r"^(?:Bearer\s|sk_(?:live|test)_|ya29\.|AIza)", re.I)
|
|
50
|
+
RUN_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{2,127}$")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _day(value: object) -> date | None:
|
|
54
|
+
if not isinstance(value, str):
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).date() if "T" in value else date.fromisoformat(value)
|
|
58
|
+
except ValueError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _zone(value: object) -> bool:
|
|
63
|
+
if not isinstance(value, str) or not value:
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
ZoneInfo(value)
|
|
67
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
68
|
+
return False
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _secrets(value: object, path: str, found: list[str]) -> None:
|
|
73
|
+
if isinstance(value, dict):
|
|
74
|
+
for key, item in value.items():
|
|
75
|
+
if SECRET_KEY.search(str(key)) and not str(key).endswith(("_ref", "Ref")):
|
|
76
|
+
found.append(f"{path}.{key}")
|
|
77
|
+
_secrets(item, f"{path}.{key}", found)
|
|
78
|
+
elif isinstance(value, list):
|
|
79
|
+
for index, item in enumerate(value):
|
|
80
|
+
_secrets(item, f"{path}[{index}]", found)
|
|
81
|
+
elif isinstance(value, str) and SECRET_VALUE.search(value):
|
|
82
|
+
found.append(path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_layers(layers: object, *, label: str = "scheduleLayers") -> tuple[list[str], list[str]]:
|
|
86
|
+
"""Validate named schedule layers shared by maggie-schedule, Social Share, Ads, and analytics."""
|
|
87
|
+
errors: list[str] = []
|
|
88
|
+
warnings: list[str] = []
|
|
89
|
+
if not isinstance(layers, list) or not layers:
|
|
90
|
+
return [f"{label} must be a non-empty list"], warnings
|
|
91
|
+
seen: set[tuple[str, str]] = set()
|
|
92
|
+
for index, layer in enumerate(layers):
|
|
93
|
+
prefix = f"{label}[{index}]"
|
|
94
|
+
if not isinstance(layer, dict):
|
|
95
|
+
errors.append(f"{prefix} must be an object")
|
|
96
|
+
continue
|
|
97
|
+
name = layer.get("layer")
|
|
98
|
+
if name not in SCHEDULE_LAYERS:
|
|
99
|
+
errors.append(f"{prefix}.layer must be one of {', '.join(SCHEDULE_LAYERS)}")
|
|
100
|
+
continue
|
|
101
|
+
key = (name, str(layer.get("id") or ""))
|
|
102
|
+
if key in seen:
|
|
103
|
+
errors.append(f"{prefix} duplicates layer {name} id {key[1] or '(none)'}")
|
|
104
|
+
seen.add(key)
|
|
105
|
+
if not _zone(layer.get("timezone")):
|
|
106
|
+
errors.append(f"{prefix}.timezone must be an IANA time zone")
|
|
107
|
+
for field in ("owner", "reconciliation"):
|
|
108
|
+
if not isinstance(layer.get(field), str) or not layer[field].strip():
|
|
109
|
+
errors.append(f"{prefix}.{field} is required")
|
|
110
|
+
if layer.get("status") not in LAYER_STATUSES:
|
|
111
|
+
errors.append(f"{prefix}.status must be one of {', '.join(sorted(LAYER_STATUSES))}")
|
|
112
|
+
if name == "infrastructure":
|
|
113
|
+
if not layer.get("scheduleRef"):
|
|
114
|
+
errors.append(f"{prefix}.scheduleRef must point at the maggie-schedule job (infrastructure has no publish time)")
|
|
115
|
+
if "start" in layer or "end" in layer:
|
|
116
|
+
warnings.append(f"{prefix} infrastructure layers describe wake-up intervals; start/end are ignored")
|
|
117
|
+
continue
|
|
118
|
+
start, end = _day(layer.get("start")), _day(layer.get("end"))
|
|
119
|
+
if not start:
|
|
120
|
+
errors.append(f"{prefix}.start must be an ISO date or date-time")
|
|
121
|
+
if name != "content_calendar" and not end:
|
|
122
|
+
errors.append(f"{prefix}.end is required for {name}")
|
|
123
|
+
if start and end and end < start:
|
|
124
|
+
errors.append(f"{prefix}.end is before start")
|
|
125
|
+
if name == "measurement_window" and not isinstance(layer.get("lagDays"), int):
|
|
126
|
+
warnings.append(f"{prefix}.lagDays is not set; attribution lag will be ignored")
|
|
127
|
+
return errors, warnings
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def validate_policy(policy: object, stages: object, mutations: list) -> tuple[list[str], list[str]]:
|
|
131
|
+
"""Regulated-content review gates only external publication (default off, backward compatible)."""
|
|
132
|
+
errors: list[str] = []
|
|
133
|
+
warnings: list[str] = []
|
|
134
|
+
if policy is None:
|
|
135
|
+
policy = {}
|
|
136
|
+
if not isinstance(policy, dict):
|
|
137
|
+
return ["policy must be an object"], warnings
|
|
138
|
+
required = bool(policy.get("regulatedContent") and policy.get("reviewRequired", True))
|
|
139
|
+
review = policy.get("reviewState", "pending" if required else "not_required")
|
|
140
|
+
if review not in REVIEW_STATES:
|
|
141
|
+
errors.append(f"policy.reviewState must be one of {', '.join(sorted(REVIEW_STATES))}")
|
|
142
|
+
if isinstance(stages, dict):
|
|
143
|
+
for stage, state in stages.items():
|
|
144
|
+
if stage not in STAGES:
|
|
145
|
+
errors.append(f"stages.{stage} is not a known stage ({', '.join(STAGES)})")
|
|
146
|
+
continue
|
|
147
|
+
blocked = isinstance(state, dict) and state.get("status") == "blocked" and state.get("reason") == "policy_review"
|
|
148
|
+
if blocked and stage not in POLICY_GATED_STAGES:
|
|
149
|
+
errors.append(f"stages.{stage} is blocked by policy review; regulated-content review may only gate "
|
|
150
|
+
"external publication — delivery, drafts, SEO/GEO and measurement must continue")
|
|
151
|
+
elif stages is not None:
|
|
152
|
+
errors.append("stages must be an object keyed by stage")
|
|
153
|
+
if required and review != "approved":
|
|
154
|
+
for mutation in mutations:
|
|
155
|
+
if isinstance(mutation, dict) and mutation.get("kind") == "external_publication" and mutation.get("status") in {"pending", "succeeded"}:
|
|
156
|
+
errors.append(f"mutation {mutation.get('mutationId')} publishes regulated content before policy review is approved")
|
|
157
|
+
if review == "pending":
|
|
158
|
+
warnings.append("regulated-content review is pending; external publication stays held, other stages continue")
|
|
159
|
+
return errors, warnings
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def validate_run(run: object) -> dict:
|
|
163
|
+
errors: list[str] = []
|
|
164
|
+
warnings: list[str] = []
|
|
165
|
+
if not isinstance(run, dict):
|
|
166
|
+
return {"valid": False, "errors": ["marketing run must be a JSON object"], "warnings": []}
|
|
167
|
+
if run.get("schemaVersion") != SCHEMA_VERSION:
|
|
168
|
+
errors.append(f"schemaVersion must be {SCHEMA_VERSION}")
|
|
169
|
+
if not isinstance(run.get("runId"), str) or not RUN_ID.match(run["runId"]):
|
|
170
|
+
errors.append("runId must be a lowercase slug (3-128 chars)")
|
|
171
|
+
for field in ("projectRef", "objective"):
|
|
172
|
+
if not isinstance(run.get(field), str) or not run[field].strip():
|
|
173
|
+
errors.append(f"{field} is required")
|
|
174
|
+
conversion = run.get("conversion")
|
|
175
|
+
if not isinstance(conversion, dict) or not conversion.get("event") or not conversion.get("source"):
|
|
176
|
+
errors.append("conversion needs event and source")
|
|
177
|
+
channels = run.get("channels")
|
|
178
|
+
if not isinstance(channels, list) or not channels or not all(isinstance(item, str) and item for item in channels):
|
|
179
|
+
errors.append("channels must be a non-empty list of channel names")
|
|
180
|
+
if not _zone(run.get("timezone")):
|
|
181
|
+
errors.append("timezone must be an IANA time zone")
|
|
182
|
+
if run.get("mode") not in RUN_MODES:
|
|
183
|
+
errors.append(f"mode must be one of {', '.join(sorted(RUN_MODES))}")
|
|
184
|
+
if "scheduled_at" in run or "scheduledAt" in run:
|
|
185
|
+
errors.append("a run must not carry a single scheduled_at; use named scheduleLayers")
|
|
186
|
+
for index, revision in enumerate(run.get("sourceRevisions") or []):
|
|
187
|
+
if not isinstance(revision, dict) or not revision.get("sourceId") or not revision.get("revision"):
|
|
188
|
+
errors.append(f"sourceRevisions[{index}] needs sourceId and revision")
|
|
189
|
+
for index, ref in enumerate(run.get("capabilityEvidence") or []):
|
|
190
|
+
if not isinstance(ref, str) or not ref:
|
|
191
|
+
errors.append(f"capabilityEvidence[{index}] must be an evidence ID or path reference")
|
|
192
|
+
approval = run.get("approval")
|
|
193
|
+
if not isinstance(approval, dict) or approval.get("state") not in APPROVAL_STATES:
|
|
194
|
+
errors.append(f"approval.state must be one of {', '.join(sorted(APPROVAL_STATES))}")
|
|
195
|
+
approval = {}
|
|
196
|
+
if approval.get("state") == "approved" and not (approval.get("actor") and approval.get("approvalId")):
|
|
197
|
+
errors.append("an approved run needs approval.actor and approval.approvalId")
|
|
198
|
+
layer_errors, layer_warnings = validate_layers(run.get("scheduleLayers"))
|
|
199
|
+
errors += layer_errors
|
|
200
|
+
warnings += layer_warnings
|
|
201
|
+
mutations = run.get("mutations") or []
|
|
202
|
+
if not isinstance(mutations, list):
|
|
203
|
+
errors.append("mutations must be a list")
|
|
204
|
+
mutations = []
|
|
205
|
+
mutation_ids: set[str] = set()
|
|
206
|
+
for index, mutation in enumerate(mutations):
|
|
207
|
+
prefix = f"mutations[{index}]"
|
|
208
|
+
if not isinstance(mutation, dict) or not mutation.get("mutationId"):
|
|
209
|
+
errors.append(f"{prefix} needs mutationId")
|
|
210
|
+
continue
|
|
211
|
+
mutation_ids.add(mutation["mutationId"])
|
|
212
|
+
if mutation.get("kind") not in MUTATION_KINDS:
|
|
213
|
+
errors.append(f"{prefix}.kind must be one of {', '.join(sorted(MUTATION_KINDS))}")
|
|
214
|
+
if mutation.get("status") not in MUTATION_STATUSES:
|
|
215
|
+
errors.append(f"{prefix}.status must be one of {', '.join(sorted(MUTATION_STATUSES))}")
|
|
216
|
+
if mutation.get("kind") in {"external_publication", "ads_change"}:
|
|
217
|
+
if not mutation.get("idempotencyKey"):
|
|
218
|
+
errors.append(f"{prefix} external mutation needs an idempotencyKey")
|
|
219
|
+
if mutation.get("status") in {"pending", "succeeded"} and approval.get("state") != "approved":
|
|
220
|
+
errors.append(f"{prefix} executed before the run was approved")
|
|
221
|
+
readback_refs = set()
|
|
222
|
+
for index, readback in enumerate(run.get("readbacks") or []):
|
|
223
|
+
if not isinstance(readback, dict) or not readback.get("readbackId"):
|
|
224
|
+
errors.append(f"readbacks[{index}] needs readbackId")
|
|
225
|
+
continue
|
|
226
|
+
ref = readback.get("mutationRef")
|
|
227
|
+
if ref and ref not in mutation_ids:
|
|
228
|
+
errors.append(f"readbacks[{index}].mutationRef {ref} does not match a mutation")
|
|
229
|
+
readback_refs.add(ref)
|
|
230
|
+
for mutation in mutations:
|
|
231
|
+
if isinstance(mutation, dict) and mutation.get("status") == "succeeded" and mutation.get("mutationId") not in readback_refs:
|
|
232
|
+
message = f"mutation {mutation.get('mutationId')} succeeded without a readback reference"
|
|
233
|
+
(errors if run.get("mode") == "completed" else warnings).append(message)
|
|
234
|
+
policy_errors, policy_warnings = validate_policy(run.get("policy"), run.get("stages"), mutations)
|
|
235
|
+
errors += policy_errors
|
|
236
|
+
warnings += policy_warnings
|
|
237
|
+
leaked: list[str] = []
|
|
238
|
+
_secrets(run, "run", leaked)
|
|
239
|
+
if leaked:
|
|
240
|
+
errors.append("run must reference secrets, not contain them: " + ", ".join(leaked))
|
|
241
|
+
return {"valid": not errors, "errors": errors, "warnings": warnings,
|
|
242
|
+
"runId": run.get("runId"), "layers": sorted({layer.get("layer") for layer in run.get("scheduleLayers") or [] if isinstance(layer, dict)} - {None})}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def main() -> int:
|
|
246
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
247
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
248
|
+
check = sub.add_parser("validate", help="validate a maggie-marketing-run-v1 document")
|
|
249
|
+
check.add_argument("input")
|
|
250
|
+
layers = sub.add_parser("layers", help="validate a scheduleLayers list, or print the layer model with no input")
|
|
251
|
+
layers.add_argument("input", nargs="?")
|
|
252
|
+
args = parser.parse_args()
|
|
253
|
+
try:
|
|
254
|
+
if args.command == "layers" and not args.input:
|
|
255
|
+
print(json.dumps({"layers": SCHEDULE_LAYERS}, indent=2))
|
|
256
|
+
return 0
|
|
257
|
+
value = json.loads(Path(args.input).read_text(encoding="utf-8"))
|
|
258
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
259
|
+
parser.error(str(error))
|
|
260
|
+
return 2
|
|
261
|
+
if args.command == "validate":
|
|
262
|
+
result = validate_run(value)
|
|
263
|
+
else:
|
|
264
|
+
items = value.get("scheduleLayers") if isinstance(value, dict) else value
|
|
265
|
+
errors, warnings = validate_layers(items)
|
|
266
|
+
result = {"valid": not errors, "errors": errors, "warnings": warnings}
|
|
267
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
268
|
+
return 0 if result["valid"] else 1
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
raise SystemExit(main())
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import hashlib
|
|
7
7
|
import json
|
|
8
|
+
import os
|
|
8
9
|
import re
|
|
9
10
|
import shutil
|
|
10
11
|
import subprocess
|
|
@@ -17,6 +18,9 @@ from html.parser import HTMLParser
|
|
|
17
18
|
from html import escape, unescape
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
22
|
+
import maggie_template_package as package # noqa: E402
|
|
23
|
+
|
|
20
24
|
|
|
21
25
|
ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
22
26
|
REQUIRED_METADATA = ("id", "name", "status", "framework", "industry", "source")
|
|
@@ -382,7 +386,7 @@ def catalog_entries(root: Path) -> list[dict]:
|
|
|
382
386
|
|
|
383
387
|
def update_catalog(root: Path, metadata: dict) -> None:
|
|
384
388
|
entries = [item for item in catalog_entries(root) if item.get("id") != metadata.get("id")]
|
|
385
|
-
entries.append({key: metadata[key] for key in ("id", "name", "status", "framework", "industry", "pageTypes", "styleTags", "source", "supports") if key in metadata})
|
|
389
|
+
entries.append({key: metadata[key] for key in ("id", "name", "displayName", "displayDescription", "status", "framework", "industry", "pageTypes", "styleTags", "source", "supports") if key in metadata})
|
|
386
390
|
entries.sort(key=lambda item: item.get("id", ""))
|
|
387
391
|
save_catalog(root, entries)
|
|
388
392
|
|
|
@@ -1185,6 +1189,8 @@ header:first-of-type a[class*="border-white"], #home a[class*="border-white"] {
|
|
|
1185
1189
|
metadata["generatedAssets"] = {"logo": generated[0]["path"], "hero": generated[1]["path"], "careTeam": generated[2]["path"], "provider": "maggie-marketplace-pipeline", "generatedAt": now()}
|
|
1186
1190
|
metadata["sourceMetadata"]["description"] = description
|
|
1187
1191
|
write_json(destination / "template.json", metadata)
|
|
1192
|
+
finalise_package_identity(root, destination, getattr(args, "display_name", None), getattr(args, "display_description", None))
|
|
1193
|
+
metadata = read_json(destination / "template.json")
|
|
1188
1194
|
(destination / "README.md").write_text(f"# {metadata['name']}\n\nA UK-localised Astro homepage template derived from {display_name}. Includes clone evidence, responsive screenshots, locally mapped assets, SEO metadata, Phosphor icons, and a DESIGN.md contract.\n\nThis entry remains a draft until source reuse and asset licensing are reviewed.\n", encoding="utf-8")
|
|
1189
1195
|
cleanup_template_assets(destination)
|
|
1190
1196
|
scrub_local_export_references(destination, args.preview_url)
|
|
@@ -1192,6 +1198,29 @@ header:first-of-type a[class*="border-white"], #home a[class*="border-white"] {
|
|
|
1192
1198
|
update_catalog(root, read_json(destination / "template.json"))
|
|
1193
1199
|
|
|
1194
1200
|
|
|
1201
|
+
def finalise_package_identity(root: Path, destination: Path, display_name: str | None = None, display_description: str | None = None, rewrite_design: bool = True) -> dict:
|
|
1202
|
+
"""Measure design tokens, record a neutral display identity, and review screenshots."""
|
|
1203
|
+
metadata = read_json(destination / "template.json")
|
|
1204
|
+
measured = package.measure_design_tokens((destination / "homepage.html").read_text(encoding="utf-8", errors="replace"))
|
|
1205
|
+
if rewrite_design and (destination / "DESIGN.md").exists():
|
|
1206
|
+
package.write_measured_design(destination, measured)
|
|
1207
|
+
metadata["designTokens"] = {"palette": measured["roles"], "textSecondary": measured.get("textSecondary"), "border": measured["border"],
|
|
1208
|
+
"fonts": {key: measured["fonts"][key] for key in ("display", "body", "missingFontLinks")}, "measuredFrom": "homepage.html", "measuredAt": now()}
|
|
1209
|
+
if not metadata.get("styleTags"):
|
|
1210
|
+
metadata["styleTags"] = package.derived_style_tags(measured)
|
|
1211
|
+
taken = {str(entry.get("displayName", "")).lower() for entry in catalog_entries(root) if entry.get("id") != metadata.get("id")}
|
|
1212
|
+
if display_name or not metadata.get("displayName"):
|
|
1213
|
+
identity = package.generate_display_identity(metadata, measured, taken)
|
|
1214
|
+
metadata["displayName"] = display_name or identity["displayName"]
|
|
1215
|
+
metadata["displayDescription"] = display_description or (metadata.get("displayDescription") if display_name else None) or identity["displayDescription"]
|
|
1216
|
+
elif display_description:
|
|
1217
|
+
metadata["displayDescription"] = display_description
|
|
1218
|
+
metadata["screenshotReview"] = package.screenshot_report(destination)
|
|
1219
|
+
write_json(destination / "template.json", metadata)
|
|
1220
|
+
update_catalog(root, metadata)
|
|
1221
|
+
return metadata
|
|
1222
|
+
|
|
1223
|
+
|
|
1195
1224
|
def standardize_phosphor_icons(value: str) -> tuple[str, list[dict]]:
|
|
1196
1225
|
"""Inventory source icon semantics, then replace each with a Phosphor class."""
|
|
1197
1226
|
mapping = {
|
|
@@ -1676,7 +1705,12 @@ def audit_image_replacements(path: Path) -> list[str]:
|
|
|
1676
1705
|
Path(src.split("?", 1)[0]).name
|
|
1677
1706
|
for src in re.findall(r"assets/[^\s,\"'?#]+", value, re.I)
|
|
1678
1707
|
}
|
|
1679
|
-
|
|
1708
|
+
# repair-assets may add a sniffed suffix to an extensionless capture, so a
|
|
1709
|
+
# suffix-less source name also matches its renamed `<name>.<ext>` file.
|
|
1710
|
+
renamed = {Path(name).stem: name for name in homepage_paths if Path(name).suffix}
|
|
1711
|
+
leaked = sorted({path_name if path_name in homepage_paths else renamed[path_name]
|
|
1712
|
+
for path_name in source_paths
|
|
1713
|
+
if path_name in homepage_paths or (not Path(path_name).suffix and path_name in renamed)})
|
|
1680
1714
|
errors = [f"homepage still references captured source image: assets/{name}" for name in leaked]
|
|
1681
1715
|
generated_assets = [
|
|
1682
1716
|
item for item in generated_manifest.get("assets", [])
|
|
@@ -1834,6 +1868,7 @@ def marketplace_artifact_gate(destination: Path, require_generated_assets: bool
|
|
|
1834
1868
|
errors.extend(audit_visual_contrast(destination))
|
|
1835
1869
|
errors.extend(audit_mobile_navigation(destination))
|
|
1836
1870
|
errors.extend(audit_image_replacements(destination))
|
|
1871
|
+
errors.extend(package_quality_errors(destination))
|
|
1837
1872
|
update_template_tasks(destination, "COMPLETED")
|
|
1838
1873
|
try:
|
|
1839
1874
|
task_state = read_json(destination / "tasks.json")
|
|
@@ -1974,6 +2009,16 @@ def resume_marketplace_job(args: argparse.Namespace) -> int:
|
|
|
1974
2009
|
return run_marketplace_job(resumed)
|
|
1975
2010
|
|
|
1976
2011
|
|
|
2012
|
+
def package_quality_errors(path: Path) -> list[str]:
|
|
2013
|
+
"""Screenshot, measured-palette, provenance, identity, and image-review gates."""
|
|
2014
|
+
try:
|
|
2015
|
+
catalog = catalog_entries(path.parent.parent)
|
|
2016
|
+
except ValueError:
|
|
2017
|
+
catalog = []
|
|
2018
|
+
report = package.package_audit(path, catalog)
|
|
2019
|
+
return [error for group in report["checks"].values() for error in group]
|
|
2020
|
+
|
|
2021
|
+
|
|
1977
2022
|
def validate_template(path: Path, sample: Path | None = None) -> int:
|
|
1978
2023
|
normalise_phosphor_template(path)
|
|
1979
2024
|
cleanup_template_assets(path)
|
|
@@ -1991,6 +2036,7 @@ def validate_template(path: Path, sample: Path | None = None) -> int:
|
|
|
1991
2036
|
errors.extend(audit_visual_contrast(path))
|
|
1992
2037
|
errors.extend(audit_mobile_navigation(path))
|
|
1993
2038
|
errors.extend(audit_image_replacements(path))
|
|
2039
|
+
errors.extend(package_quality_errors(path))
|
|
1994
2040
|
if not metadata_path.exists():
|
|
1995
2041
|
errors.append("missing template.json")
|
|
1996
2042
|
metadata = {}
|
|
@@ -2136,7 +2182,7 @@ def list_templates(args: argparse.Namespace) -> int:
|
|
|
2136
2182
|
if args.published:
|
|
2137
2183
|
entries = [entry for entry in entries if entry.get("status") in PUBLISHED_STATUSES]
|
|
2138
2184
|
for entry in entries:
|
|
2139
|
-
print(f"{entry.get('id')}\t{entry.get('status', 'unknown')}\t{', '.join(entry.get('industry', []))}\t{', '.join(entry.get('styleTags', []))}")
|
|
2185
|
+
print(f"{entry.get('id')}\t{entry.get('displayName') or '(display name missing)'}\t{entry.get('status', 'unknown')}\t{', '.join(entry.get('industry', []))}\t{', '.join(entry.get('styleTags', []))}")
|
|
2140
2186
|
return 0
|
|
2141
2187
|
|
|
2142
2188
|
|
|
@@ -2157,7 +2203,7 @@ def suggest_templates(args: argparse.Namespace) -> int:
|
|
|
2157
2203
|
ranked = []
|
|
2158
2204
|
for entry in catalog_entries(marketplace_root(args.marketplace)):
|
|
2159
2205
|
haystack = set(re.findall(r"[a-z0-9]+", " ".join([
|
|
2160
|
-
str(entry.get("
|
|
2206
|
+
str(entry.get("displayName", "")), str(entry.get("displayDescription", "")), " ".join(entry.get("industry", [])),
|
|
2161
2207
|
" ".join(entry.get("styleTags", [])), " ".join(entry.get("pageTypes", [])),
|
|
2162
2208
|
]).lower()))
|
|
2163
2209
|
score = len(query & haystack)
|
|
@@ -2166,7 +2212,7 @@ def suggest_templates(args: argparse.Namespace) -> int:
|
|
|
2166
2212
|
ranked.append((score, entry))
|
|
2167
2213
|
ranked.sort(key=lambda item: (-item[0], item[1].get("id", "")))
|
|
2168
2214
|
for score, entry in ranked[:args.limit]:
|
|
2169
|
-
print(f"{score}\t{entry.get('id')}\t{entry.get('status', 'unknown')}\t{', '.join(entry.get('industry', []))}\t{', '.join(entry.get('styleTags', []))}")
|
|
2215
|
+
print(f"{score}\t{entry.get('id')}\t{entry.get('displayName') or '(display name missing)'}\t{entry.get('status', 'unknown')}\t{', '.join(entry.get('industry', []))}\t{', '.join(entry.get('styleTags', []))}")
|
|
2170
2216
|
return 0
|
|
2171
2217
|
|
|
2172
2218
|
|
|
@@ -2235,6 +2281,107 @@ def enrichment_report(args: argparse.Namespace) -> int:
|
|
|
2235
2281
|
print(json.dumps({"status": report["status"], "candidateId": candidate_id, "report": str(output), "retry": report["retry"]}, ensure_ascii=False)); return 0 if not errors else 1
|
|
2236
2282
|
|
|
2237
2283
|
|
|
2284
|
+
def _template_dir(args: argparse.Namespace) -> tuple[Path, Path]:
|
|
2285
|
+
root = marketplace_root(args.marketplace)
|
|
2286
|
+
source = template_path(root, args.id)
|
|
2287
|
+
if not (source / "template.json").is_file():
|
|
2288
|
+
raise ValueError(f"template not found: {args.id}")
|
|
2289
|
+
return root, source
|
|
2290
|
+
|
|
2291
|
+
|
|
2292
|
+
def audit_package(args: argparse.Namespace) -> int:
|
|
2293
|
+
"""Read-only package-quality audit; --record stores the screenshot review."""
|
|
2294
|
+
root, source = _template_dir(args)
|
|
2295
|
+
report = package.package_audit(source, catalog_entries(root))
|
|
2296
|
+
if args.record:
|
|
2297
|
+
metadata = read_json(source / "template.json")
|
|
2298
|
+
metadata["screenshotReview"] = package.screenshot_report(source)
|
|
2299
|
+
if args.recapture_note:
|
|
2300
|
+
metadata["screenshotReview"]["note"] = args.recapture_note
|
|
2301
|
+
write_json(source / "template.json", metadata)
|
|
2302
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
2303
|
+
return 0 if report["passed"] else 1
|
|
2304
|
+
|
|
2305
|
+
|
|
2306
|
+
def measure_design(args: argparse.Namespace) -> int:
|
|
2307
|
+
root, source = _template_dir(args)
|
|
2308
|
+
measured = package.measure_design_tokens((source / "homepage.html").read_text(encoding="utf-8", errors="replace"))
|
|
2309
|
+
if args.palette_roles:
|
|
2310
|
+
palettes = package.load_palettes(None, measured, read_json(source / "template.json").get("id"))
|
|
2311
|
+
args.palette_roles.parent.mkdir(parents=True, exist_ok=True)
|
|
2312
|
+
args.palette_roles.write_text(json.dumps(palettes, indent=2) + "\n", encoding="utf-8")
|
|
2313
|
+
if args.write:
|
|
2314
|
+
finalise_package_identity(root, source, rewrite_design=True)
|
|
2315
|
+
print(json.dumps({key: measured[key] for key in ("roles", "textSecondary", "border", "fonts")}, indent=2, ensure_ascii=False))
|
|
2316
|
+
return 0
|
|
2317
|
+
|
|
2318
|
+
|
|
2319
|
+
def repair_assets(args: argparse.Namespace) -> int:
|
|
2320
|
+
_, source = _template_dir(args)
|
|
2321
|
+
result = package.repair_asset_provenance(source, write=args.write)
|
|
2322
|
+
print(json.dumps({"write": args.write, **result}, indent=2, ensure_ascii=False))
|
|
2323
|
+
return 0
|
|
2324
|
+
|
|
2325
|
+
|
|
2326
|
+
def set_identity(args: argparse.Namespace) -> int:
|
|
2327
|
+
root, source = _template_dir(args)
|
|
2328
|
+
metadata = finalise_package_identity(root, source, args.display_name, args.display_description, rewrite_design=False)
|
|
2329
|
+
errors = package.audit_display_identity(source, catalog_entries(root))
|
|
2330
|
+
print(json.dumps({"displayName": metadata.get("displayName"), "displayDescription": metadata.get("displayDescription"), "errors": errors}, indent=2, ensure_ascii=False))
|
|
2331
|
+
return 0 if not errors else 1
|
|
2332
|
+
|
|
2333
|
+
|
|
2334
|
+
def template_studio(args: argparse.Namespace) -> int:
|
|
2335
|
+
root, source = _template_dir(args)
|
|
2336
|
+
measured = package.measure_design_tokens((source / "homepage.html").read_text(encoding="utf-8", errors="replace"))
|
|
2337
|
+
palettes = package.load_palettes(args.palettes.resolve() if args.palettes else None, measured, args.id)
|
|
2338
|
+
output = (args.output or root / ".maggie" / "template-studio" / args.id).resolve()
|
|
2339
|
+
result = package.build_studio(source, output, palettes)
|
|
2340
|
+
if args.check_overflow:
|
|
2341
|
+
serve_root = Path(os.path.commonpath([str(output), str(source.resolve())]))
|
|
2342
|
+
report = package.check_studio_overflow(serve_root, output / "index.html", palette_id=args.palette)
|
|
2343
|
+
result["overflow"] = report
|
|
2344
|
+
_write = output / "overflow-report.json"
|
|
2345
|
+
_write.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
2346
|
+
failing = {width: item for width, item in (report.get("viewports") or {}).items() if item.get("overflowPx")}
|
|
2347
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
2348
|
+
return 1 if failing or report.get("error") or not report.get("viewports") else 0
|
|
2349
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
2350
|
+
print("Serve the common parent over HTTP (python3 -m http.server) to preview; --check-overflow renders it headlessly.")
|
|
2351
|
+
return 0
|
|
2352
|
+
|
|
2353
|
+
|
|
2354
|
+
def content_validate(args: argparse.Namespace) -> int:
|
|
2355
|
+
content = read_json(args.input.resolve())
|
|
2356
|
+
errors = package.validate_content(content)
|
|
2357
|
+
print(json.dumps({"status": "passed" if not errors else "failed", "errors": errors}, indent=2))
|
|
2358
|
+
return 0 if not errors else 1
|
|
2359
|
+
|
|
2360
|
+
|
|
2361
|
+
def content_css(args: argparse.Namespace) -> int:
|
|
2362
|
+
content = read_json(args.input.resolve())
|
|
2363
|
+
roles = (content.get("palette") or {}).get("roles") or {}
|
|
2364
|
+
if not roles:
|
|
2365
|
+
raise ValueError("content has no palette.roles to export")
|
|
2366
|
+
print(package.palette_css_variables(roles), end="")
|
|
2367
|
+
return 0
|
|
2368
|
+
|
|
2369
|
+
|
|
2370
|
+
def image_plan(args: argparse.Namespace) -> int:
|
|
2371
|
+
_, source = _template_dir(args)
|
|
2372
|
+
report = package.plan_image_generation(source, args.unit_cost, args.currency, args.provider)
|
|
2373
|
+
print(json.dumps(report["before"], indent=2))
|
|
2374
|
+
return 0
|
|
2375
|
+
|
|
2376
|
+
|
|
2377
|
+
def image_review(args: argparse.Namespace) -> int:
|
|
2378
|
+
_, source = _template_dir(args)
|
|
2379
|
+
report = package.review_generated_images(source, approve=args.approve, reviewer=args.reviewer or "", flags=args.flag, note=args.note or "")
|
|
2380
|
+
summary = {"after": report["after"], "textReview": {key: report["textReview"][key] for key in ("status", "method", "contactSheet")}}
|
|
2381
|
+
print(json.dumps(summary, indent=2))
|
|
2382
|
+
return 0 if report["textReview"]["status"] == "passed" else 1
|
|
2383
|
+
|
|
2384
|
+
|
|
2238
2385
|
def main() -> int:
|
|
2239
2386
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
2240
2387
|
parser.add_argument("--marketplace", type=Path, default=Path("marketplace"))
|
|
@@ -2269,6 +2416,8 @@ def main() -> int:
|
|
|
2269
2416
|
add.add_argument("--languages", action="append", default=["en"])
|
|
2270
2417
|
add.add_argument("--translation-policy", choices=("source-only", "review-required", "fully-localized"), default="review-required")
|
|
2271
2418
|
add.add_argument("--brand", default="Maggie Studio", help="neutral demo brand used by the completion pipeline")
|
|
2419
|
+
add.add_argument("--display-name", help="neutral customer-facing style name (default: generated from measured design)")
|
|
2420
|
+
add.add_argument("--display-description", help="short neutral description shown in template pickers")
|
|
2272
2421
|
add.add_argument("--site-url", help="final canonical site URL; defaults to an example.co.uk URL")
|
|
2273
2422
|
add.add_argument("--sample", type=Path)
|
|
2274
2423
|
add.add_argument("--force", action="store_true")
|
|
@@ -2319,6 +2468,51 @@ def main() -> int:
|
|
|
2319
2468
|
enrichment.add_argument("--input", type=Path, required=True)
|
|
2320
2469
|
enrichment.add_argument("--output", type=Path)
|
|
2321
2470
|
enrichment.set_defaults(func=enrichment_report)
|
|
2471
|
+
audit = sub.add_parser("audit-package", help="read-only screenshot, palette, provenance, identity, and image-review audit")
|
|
2472
|
+
audit.add_argument("id")
|
|
2473
|
+
audit.add_argument("--record", action="store_true", help="store the screenshot review in template.json")
|
|
2474
|
+
audit.add_argument("--recapture-note")
|
|
2475
|
+
audit.set_defaults(func=audit_package)
|
|
2476
|
+
measure = sub.add_parser("measure-design", help="measure palette roles and fonts from homepage CSS")
|
|
2477
|
+
measure.add_argument("id")
|
|
2478
|
+
measure.add_argument("--write", action="store_true", help="rewrite DESIGN.md palette/typography and template.json designTokens")
|
|
2479
|
+
measure.add_argument("--palette-roles", type=Path, help="write the measured signature as maggie-palette-roles-v1 JSON")
|
|
2480
|
+
measure.set_defaults(func=measure_design)
|
|
2481
|
+
repair = sub.add_parser("repair-assets", help="list unlisted assets with honest provenance; add missing file extensions")
|
|
2482
|
+
repair.add_argument("id")
|
|
2483
|
+
repair.add_argument("--write", action="store_true")
|
|
2484
|
+
repair.set_defaults(func=repair_assets)
|
|
2485
|
+
identity = sub.add_parser("identity", help="record a neutral, unique displayName and description")
|
|
2486
|
+
identity.add_argument("id")
|
|
2487
|
+
identity.add_argument("--display-name")
|
|
2488
|
+
identity.add_argument("--display-description")
|
|
2489
|
+
identity.set_defaults(func=set_identity)
|
|
2490
|
+
studio = sub.add_parser("studio", help="build a template x palette x viewport preview studio")
|
|
2491
|
+
studio.add_argument("id")
|
|
2492
|
+
studio.add_argument("--palettes", type=Path, help="maggie-palette-roles-v1 JSON (default: measured signature only)")
|
|
2493
|
+
studio.add_argument("--output", type=Path)
|
|
2494
|
+
studio.add_argument("--check-overflow", action="store_true", help="render headlessly and fail on horizontal overflow")
|
|
2495
|
+
studio.add_argument("--palette", help="palette id to render for --check-overflow (default: signature)")
|
|
2496
|
+
studio.set_defaults(func=template_studio)
|
|
2497
|
+
cvalidate = sub.add_parser("content-validate", help="validate maggie-template-content-v1 JSON")
|
|
2498
|
+
cvalidate.add_argument("--input", type=Path, required=True)
|
|
2499
|
+
cvalidate.set_defaults(func=content_validate)
|
|
2500
|
+
ccss = sub.add_parser("content-css", help="print palette CSS variables for template content")
|
|
2501
|
+
ccss.add_argument("--input", type=Path, required=True)
|
|
2502
|
+
ccss.set_defaults(func=content_css)
|
|
2503
|
+
iplan = sub.add_parser("image-plan", help="record planned image count and estimated cost before generation")
|
|
2504
|
+
iplan.add_argument("id")
|
|
2505
|
+
iplan.add_argument("--unit-cost", type=float)
|
|
2506
|
+
iplan.add_argument("--currency", default="USD")
|
|
2507
|
+
iplan.add_argument("--provider", default="built-in-image-generation")
|
|
2508
|
+
iplan.set_defaults(func=image_plan)
|
|
2509
|
+
ireview = sub.add_parser("image-review", help="contact sheet, OCR/human no-text review, and post-run cost")
|
|
2510
|
+
ireview.add_argument("id")
|
|
2511
|
+
ireview.add_argument("--approve", action="store_true", help="record a human review that found no text, labels, or watermarks")
|
|
2512
|
+
ireview.add_argument("--reviewer")
|
|
2513
|
+
ireview.add_argument("--flag", action="append", default=[], help="asset path with readable text; repeatable")
|
|
2514
|
+
ireview.add_argument("--note")
|
|
2515
|
+
ireview.set_defaults(func=image_review)
|
|
2322
2516
|
args = parser.parse_args()
|
|
2323
2517
|
try:
|
|
2324
2518
|
return int(args.func(args) or 0)
|