@topy-ai/maggie 0.7.53 → 0.7.55

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 (65) hide show
  1. package/README-zh-TW.md +28 -50
  2. package/README.md +80 -64
  3. package/bin/maggie.js +61 -9
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
  5. package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
  6. package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
  7. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
  8. package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
  9. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
  10. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  11. package/bundled-skills/catalog.json +1 -1
  12. package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
  13. package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
  14. package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
  15. package/bundled-skills/maggie-blog/SKILL.md +15 -0
  16. package/bundled-skills/maggie-booking/SKILL.md +20 -12
  17. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  18. package/bundled-skills/maggie-dash/SKILL.md +37 -2
  19. package/bundled-skills/maggie-deployment/SKILL.md +104 -1
  20. package/bundled-skills/maggie-deployment/references/vps.md +5 -0
  21. package/bundled-skills/maggie-design/SKILL.md +81 -0
  22. package/bundled-skills/maggie-feedback/SKILL.md +10 -2
  23. package/bundled-skills/maggie-marketplace/SKILL.md +22 -2
  24. package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
  25. package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
  26. package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
  27. package/bundled-skills/maggie-ops/SKILL.md +28 -0
  28. package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
  29. package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
  30. package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
  31. package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
  32. package/bundled-skills/maggie-social-share/SKILL.md +50 -0
  33. package/bundled-tools/clis/maggie.py +63 -3
  34. package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
  35. package/bundled-tools/clis/maggie_booking.py +9 -8
  36. package/bundled-tools/clis/maggie_dash.py +173 -3
  37. package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
  38. package/bundled-tools/clis/maggie_design.py +7 -1
  39. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  40. package/bundled-tools/clis/maggie_design_lint.py +131 -1
  41. package/bundled-tools/clis/maggie_feedback.py +41 -13
  42. package/bundled-tools/clis/maggie_host_runtime.py +458 -0
  43. package/bundled-tools/clis/maggie_live_urls.py +147 -0
  44. package/bundled-tools/clis/maggie_localization.py +3 -0
  45. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  46. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  47. package/bundled-tools/clis/maggie_mobile.py +84 -1
  48. package/bundled-tools/clis/maggie_ops.py +2 -0
  49. package/bundled-tools/clis/maggie_ops_routines.py +352 -0
  50. package/bundled-tools/clis/maggie_product_release.py +49 -1
  51. package/bundled-tools/clis/maggie_query_ideas.py +154 -0
  52. package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
  53. package/bundled-tools/clis/maggie_social_share.py +223 -2
  54. package/bundled-tools/clis/maggie_version_sources.py +93 -0
  55. package/bundled-tools/integrations/maggie-api-pull.md +49 -0
  56. package/package.json +1 -1
  57. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  58. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  59. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  60. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  61. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  62. package/bundled-references/maggiedash-booking/README.md +0 -288
  63. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  64. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  65. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -6,7 +6,8 @@ server and ChatGPT/Claude apps. The product exports its registry as JSON
6
6
  (``maggie-assistant-tool-registry.v1``); these commands never import product
7
7
  code or call a model.
8
8
 
9
- - ``registry-validate``: names, risk levels, gates, scopes, channels, and the
9
+ - ``registry-validate``: names, risk levels, gates, scopes, channels, clipped
10
+ lists, the report inbox, unattended-caller caps and sign-in defaults, and the
10
11
  user-facing copy (label, detail, confirm sentence) kept separate from the
11
12
  model-facing description. User copy may not contain snake_case tool names,
12
13
  "the user", or "(from some_tool)" references.
@@ -17,6 +18,10 @@ code or call a model.
17
18
  - ``audit-check``: every write in a recorded session has one audit entry, with
18
19
  a readable summary (never a bare UUID) and the right scope (account-level
19
20
  writes are not filed under the open site).
21
+ - ``list-check``: a recorded list result marks every item whose declared
22
+ field was shortened (``clipped``) and carries the id its get tool needs.
23
+ - ``report-check``: a report an agent is about to save to the report inbox
24
+ (title, one-line summary, Markdown body) fits the limits and holds no secret.
20
25
  - ``approval-selftest``: exercises the reference signed one-time approval
21
26
  token and standing workflow approvals (``tools/runtime/assistant_approval.py``).
22
27
  """
@@ -187,10 +192,222 @@ def validate_registry(registry: dict) -> dict:
187
192
  errors.append("visitorPolicy.factsOnly must be true: answers come from the business's own facts, never general knowledge about prices or health")
188
193
  if policy.get("cardDetails") != "never":
189
194
  errors.append("visitorPolicy.cardDetails must be never: the chat never asks for or accepts card numbers")
195
+ by_name = {str(t.get("name")): t for t in tools if isinstance(t, dict)}
196
+ check_clips(by_name, errors, warnings)
197
+ check_unattended(registry, by_name, servers, errors, warnings)
198
+ check_report_inbox(registry, by_name, errors)
190
199
  public_tools = [t.get("name") for t in tools if isinstance(t, dict) and any(servers.get(s, {}).get("auth") == "none" for s in t.get("mcpServers", []) or [])]
191
200
  return {"schemaVersion": "maggie-assistant-registry-validation.v1", "passed": not errors, "errors": errors, "warnings": warnings, "counts": {"tools": len(tools), **counts, "cards": sum(1 for t in tools if isinstance(t, dict) and t.get("card")), "publicMcpTools": len(public_tools)}}
192
201
 
193
202
 
203
+ CLIP_WORDS_RE = re.compile(r"\b(?:clipped|shortened|truncated|cut to|first \d+ characters|~?\d+-character)\b", re.I)
204
+ WRITE_RISKS = ("write-reversible", "write-spends", "write-public")
205
+
206
+
207
+ def check_clips(by_name: dict[str, dict], errors: list[str], warnings: list[str]) -> None:
208
+ """A list tool that shortens long fields says so per item and names a get tool with the full text.
209
+
210
+ An agent checking what will be published must read the full text; a list that
211
+ silently clips makes it report "cut off mid-sentence" for text that is whole.
212
+ """
213
+ for name, tool in by_name.items():
214
+ clips = tool.get("clips")
215
+ if clips is None:
216
+ if risk_of(tool) == "read" and CLIP_WORDS_RE.search(str(tool.get("description", ""))):
217
+ warnings.append(f"{name}: the description says results are shortened; declare clips (fields, maxChars, getTool) so each item is marked and the full text has a get tool")
218
+ continue
219
+ if not isinstance(clips, dict):
220
+ errors.append(f"{name}: clips must be an object with fields, maxChars and getTool"); continue
221
+ if risk_of(tool) != "read":
222
+ errors.append(f"{name}: clips belongs to a read (list) tool")
223
+ fields = clips.get("fields")
224
+ if not isinstance(fields, list) or not fields or not all(isinstance(f, str) and f for f in fields):
225
+ errors.append(f"{name}: clips.fields must name the shortened fields")
226
+ if not isinstance(clips.get("maxChars"), int) or clips["maxChars"] < 1:
227
+ errors.append(f"{name}: clips.maxChars must be a positive integer")
228
+ get_name = str(clips.get("getTool", ""))
229
+ get_tool = by_name.get(get_name)
230
+ if get_tool is None:
231
+ errors.append(f"{name}: clips.getTool {get_name or '<missing>'!r} is not in the registry; a clipped list needs a get tool that returns the full text"); continue
232
+ if risk_of(get_tool) != "read":
233
+ errors.append(f"{name}: clips.getTool {get_name} must be a read tool")
234
+ if get_tool.get("scope") != tool.get("scope"):
235
+ errors.append(f"{name}: clips.getTool {get_name} must have the same scope as the list")
236
+ missing = [c for c in tool.get("channels") or [] if c not in (get_tool.get("channels") or [])]
237
+ if missing:
238
+ errors.append(f"{name}: clips.getTool {get_name} is not on {', '.join(missing)}; every caller of the list must be able to read the full text")
239
+
240
+
241
+ def oauth_full_set(by_name: dict[str, dict], server: str | None) -> set[str]:
242
+ """The tools a signed-in caller's role can reach on MCP (the server filters by role at call time)."""
243
+ names = set()
244
+ for name, tool in by_name.items():
245
+ if risk_of(tool) == "never" or "mcp" not in (tool.get("channels") or []):
246
+ continue
247
+ if server and tool.get("mcpServers") and server not in tool["mcpServers"]:
248
+ continue
249
+ names.add(name)
250
+ return names
251
+
252
+
253
+ def check_unattended(registry: dict, by_name: dict[str, dict], servers: dict, errors: list[str], warnings: list[str]) -> None:
254
+ """Limits for callers no person watches: agent keys and sign-in (OAuth) grants used by routines.
255
+
256
+ A prompt is not a limit. Every write such a caller can reach needs a daily cap
257
+ that the server counts; the OAuth default tool set is the role's whole set (a
258
+ narrower default needs a reason); an agent key cannot confirm previews.
259
+ """
260
+ policy = registry.get("unattended")
261
+ oauth_servers = [name for name, server in servers.items() if server.get("auth") == "oauth"]
262
+ if policy is None:
263
+ if oauth_servers and any(risk_of(by_name[n]) in WRITE_RISKS for n in oauth_full_set(by_name, oauth_servers[0])):
264
+ warnings.append("a signed-in MCP server exposes writes but registry.unattended is missing; declare server-enforced daily caps for agent keys and OAuth grants")
265
+ return
266
+ if not isinstance(policy, dict):
267
+ errors.append("unattended must be an object"); return
268
+ if policy.get("enforcedBy") != "server":
269
+ errors.append("unattended.enforcedBy must be server: caps kept only in a routine's prompt are not limits")
270
+ keys = policy.get("agentKeys")
271
+ if keys is not None:
272
+ if not isinstance(keys, dict):
273
+ errors.append("unattended.agentKeys must be an object")
274
+ else:
275
+ if keys.get("canConfirmPreviews") is not False:
276
+ errors.append("unattended.agentKeys.canConfirmPreviews must be false: an agent key runs only the writes its standing permission covers")
277
+ if keys.get("refusalReturnsReason") is not True:
278
+ errors.append("unattended.agentKeys.refusalReturnsReason must be true: a refused write says why (e.g. today's limit is used) instead of returning a preview nobody can confirm")
279
+ standing = keys.get("standingTools") if isinstance(keys.get("standingTools"), dict) else {}
280
+ for name, cap in standing.items():
281
+ tool = by_name.get(name)
282
+ if tool is None:
283
+ errors.append(f"unattended.agentKeys.standingTools: {name} is not in the registry"); continue
284
+ if risk_of(tool) not in ("write-reversible", "write-spends"):
285
+ errors.append(f"unattended.agentKeys.standingTools: {name} is {risk_of(tool)}; standing permissions cover reversible or spending writes only, never public or irreversible ones")
286
+ if not isinstance(cap, dict) or not isinstance(cap.get("perDay"), int) or cap["perDay"] < 1:
287
+ errors.append(f"unattended.agentKeys.standingTools: {name} needs perDay, the daily cap the server counts")
288
+ oauth = policy.get("oauth")
289
+ if oauth is None:
290
+ if oauth_servers:
291
+ errors.append("unattended.oauth is required when a signed-in (oauth) MCP server is declared: a sign-in grant acts as the full person")
292
+ return
293
+ if not isinstance(oauth, dict):
294
+ errors.append("unattended.oauth must be an object"); return
295
+ server = oauth.get("server") or (oauth_servers[0] if oauth_servers else None)
296
+ if server and server not in servers:
297
+ errors.append(f"unattended.oauth.server {server!r} is not declared in registry.mcpServers")
298
+ full = oauth_full_set(by_name, server)
299
+ default = oauth.get("defaultTools", "role")
300
+ if default == "role":
301
+ reachable = full
302
+ elif isinstance(default, list):
303
+ unknown = sorted(set(map(str, default)) - full)
304
+ if unknown:
305
+ errors.append(f"unattended.oauth.defaultTools names tools the role can't reach on MCP: {', '.join(unknown)}")
306
+ reachable = set(map(str, default)) & full
307
+ left_out = sorted(full - reachable)
308
+ if left_out and not str(oauth.get("narrowedReason", "")).strip():
309
+ errors.append(f"unattended.oauth.defaultTools exposes {len(reachable)} of the role's {len(full)} tools (missing e.g. {', '.join(left_out[:5])}); a routine connected by sign-in can't reach the rest. Use \"role\" or give narrowedReason")
310
+ else:
311
+ errors.append("unattended.oauth.defaultTools must be \"role\" (the caller's role's full set) or a list of tools"); reachable = full
312
+ per_tool = oauth.get("perToolPerDay") if isinstance(oauth.get("perToolPerDay"), dict) else {}
313
+ total = oauth.get("dailyWriteLimit")
314
+ has_total = isinstance(total, int) and total >= 1
315
+ for name in sorted(reachable):
316
+ if risk_of(by_name[name]) not in WRITE_RISKS:
317
+ continue
318
+ cap = per_tool.get(name)
319
+ if not (isinstance(cap, int) and cap >= 1) and not has_total:
320
+ errors.append(f"unattended.oauth: {name} is a write reachable by a sign-in grant with no server cap; set perToolPerDay.{name} or dailyWriteLimit")
321
+
322
+
323
+ REPORT_LIMITS = {"titleMaxChars": 120, "summaryMaxChars": 500, "bodyMaxChars": 50000}
324
+
325
+
326
+ def check_report_inbox(registry: dict, by_name: dict[str, dict], errors: list[str]) -> None:
327
+ """One place where scheduled agents leave their results: save, list, get; capped; bodies untrusted."""
328
+ inbox = registry.get("reportInbox")
329
+ if inbox is None:
330
+ return
331
+ if not isinstance(inbox, dict):
332
+ errors.append("reportInbox must be an object"); return
333
+ names = inbox.get("tools") if isinstance(inbox.get("tools"), dict) else {}
334
+ expected = {"save": ("write-reversible",), "list": ("read",), "get": ("read",)}
335
+ for role, risks in expected.items():
336
+ name = str(names.get(role, ""))
337
+ tool = by_name.get(name)
338
+ if tool is None:
339
+ errors.append(f"reportInbox.tools.{role} {name or '<missing>'!r} is not in the registry"); continue
340
+ if risk_of(tool) not in risks:
341
+ errors.append(f"reportInbox.tools.{role} {name} must be {risks[0]} (saving a report changes nothing else)")
342
+ if tool.get("scope") != "workspace":
343
+ errors.append(f"reportInbox.tools.{role} {name}: reports belong to one website (scope workspace)")
344
+ if not isinstance(inbox.get("perSitePerDay"), int) or inbox["perSitePerDay"] < 1:
345
+ errors.append("reportInbox.perSitePerDay is required: the server caps agent reports per website per day")
346
+ for key, ceiling in REPORT_LIMITS.items():
347
+ value = inbox.get(key, ceiling)
348
+ if not isinstance(value, int) or value < 1:
349
+ errors.append(f"reportInbox.{key} must be a positive integer")
350
+ if not isinstance(inbox.get("retentionDays"), int) or inbox["retentionDays"] < 1:
351
+ errors.append("reportInbox.retentionDays is required")
352
+ untrusted = set(inbox.get("untrusted") or [])
353
+ missing = sorted({"title", "summary", "body"} - untrusted)
354
+ if missing:
355
+ errors.append(f"reportInbox.untrusted must include {', '.join(missing)}: an agent wrote them, so readers treat them as data, never instructions")
356
+
357
+
358
+ def list_check(registry: dict, tool_name: str, result: dict) -> dict:
359
+ """Check a recorded list result: every item whose declared field was shortened says so."""
360
+ tool = next((t for t in registry.get("tools", []) if isinstance(t, dict) and t.get("name") == tool_name), None)
361
+ errors: list[str] = []
362
+ if tool is None or not isinstance(tool.get("clips"), dict):
363
+ return {"schemaVersion": "maggie-assistant-list-check.v1", "passed": False, "errors": [f"{tool_name}: not in the registry or declares no clips"], "items": 0, "clipped": 0}
364
+ clips = tool["clips"]
365
+ id_field = str(clips.get("idField", "id"))
366
+ max_chars = int(clips.get("maxChars", 0) or 0)
367
+ items = result.get("items") if isinstance(result.get("items"), list) else result.get("result", {}).get("items", []) if isinstance(result.get("result"), dict) else []
368
+ clipped = 0
369
+ for index, item in enumerate(items):
370
+ if not isinstance(item, dict):
371
+ continue
372
+ where = f"item {index} ({item.get(id_field, 'no ' + id_field)})"
373
+ if item.get(id_field) in (None, ""):
374
+ errors.append(f"{where}: has no {id_field}, so the full text can't be fetched with {clips.get('getTool')}")
375
+ marker = item.get("clipped")
376
+ for field in clips.get("fields", []):
377
+ text = item.get(field)
378
+ if not isinstance(text, str):
379
+ continue
380
+ shortened = (max_chars and len(text) >= max_chars) or text.rstrip().endswith(("…", "..."))
381
+ flagged = marker is True or (isinstance(marker, list) and field in marker)
382
+ if shortened and not flagged:
383
+ errors.append(f"{where}: {field} looks shortened ({len(text)} characters) but the item does not say clipped")
384
+ if flagged:
385
+ clipped += 1
386
+ return {"schemaVersion": "maggie-assistant-list-check.v1", "passed": not errors, "errors": errors, "items": len(items), "clipped": clipped, "getTool": clips.get("getTool")}
387
+
388
+
389
+ def report_check(report: dict, inbox: dict | None = None) -> dict:
390
+ """Check a report an agent is about to save (maggie-assistant-report.v1)."""
391
+ limits = {**REPORT_LIMITS, **{k: v for k, v in (inbox or {}).items() if k in REPORT_LIMITS and isinstance(v, int)}}
392
+ errors: list[str] = []
393
+ title = str(report.get("title", "")).strip()
394
+ summary = str(report.get("summary", "")).strip()
395
+ body = str(report.get("body_md", report.get("body", "")))
396
+ if not title:
397
+ errors.append("title is required")
398
+ elif len(title) > limits["titleMaxChars"]:
399
+ errors.append(f"title is over {limits['titleMaxChars']} characters")
400
+ if not summary:
401
+ errors.append("summary is required: one or two sentences shown in the inbox")
402
+ elif len(summary) > limits["summaryMaxChars"]:
403
+ errors.append(f"summary is over {limits['summaryMaxChars']} characters")
404
+ if len(body) > limits["bodyMaxChars"]:
405
+ errors.append(f"body is over {limits['bodyMaxChars']} characters")
406
+ if re.search(r"(?i)\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{8,}|\bBearer\s+[A-Za-z0-9._-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY", title + summary + body):
407
+ errors.append("the report contains something that looks like a secret; remove it before saving")
408
+ return {"schemaVersion": "maggie-assistant-report-check.v1", "passed": not errors, "errors": errors, "chars": {"title": len(title), "summary": len(summary), "body": len(body)}}
409
+
410
+
194
411
  def annotations_for(tool: dict) -> dict:
195
412
  risk = risk_of(tool)
196
413
  title = str(tool.get("user", {}).get("label", "")).strip() or None
@@ -348,14 +565,21 @@ def main(argv: list[str] | None = None) -> int:
348
565
  page = sub.add_parser("capability-page"); page.add_argument("--registry", type=Path, required=True); page.add_argument("--title", default="What the assistant can and can't do"); page.add_argument("--output", type=Path, help="Markdown file; JSON is written next to it")
349
566
  audit = sub.add_parser("audit-check"); audit.add_argument("--registry", type=Path, required=True); audit.add_argument("--session", type=Path, required=True, help="recorded calls + audit entries (maggie-assistant-session.v1)")
350
567
  sub.add_parser("approval-selftest")
568
+ lc = sub.add_parser("list-check"); lc.add_argument("--registry", type=Path, required=True); lc.add_argument("--tool", required=True); lc.add_argument("--result", type=Path, required=True, help="a recorded result of the list tool")
569
+ rc = sub.add_parser("report-check"); rc.add_argument("--report", type=Path, required=True, help="the report about to be saved (title, summary, body_md)"); rc.add_argument("--registry", type=Path, help="use this registry's reportInbox limits")
351
570
  args = parser.parse_args(argv)
352
571
  try:
353
572
  if args.command == "approval-selftest":
354
573
  report = approval_selftest()
574
+ elif args.command == "report-check":
575
+ inbox = _read(args.registry).get("reportInbox") if args.registry else None
576
+ report = report_check(_read(args.report), inbox if isinstance(inbox, dict) else None)
355
577
  else:
356
578
  registry = _read(args.registry)
357
579
  if args.command == "registry-validate":
358
580
  report = validate_registry(registry)
581
+ elif args.command == "list-check":
582
+ report = list_check(registry, args.tool, _read(args.result))
359
583
  elif args.command == "annotations":
360
584
  report = derive_annotations(registry, _read(args.mcp_tools) if args.mcp_tools else None)
361
585
  elif args.command == "capability-page":
@@ -32,11 +32,9 @@ DEFAULT_OPS_EVIDENCE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-ops-evi
32
32
  DEFAULT_BROWSER_EVIDENCE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-browser-evidence-v2.json"
33
33
  DEFAULT_STRIPE_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "stripe-booking-capabilities-v1.json"
34
34
  DEFAULT_VOUCHER_CONTRACT = CONTRACTS_ROOT / "maggiedash" / "booking-voucher-lifecycle-v1.json"
35
- EXECUTION_BOARD = (
36
- CONTRACTS_ROOT / "maggiedash" / "execution-board.json"
37
- if PACKAGED_CONTRACTS_ROOT.is_dir()
38
- else REPO_ROOT / "docs" / "maggiedash-booking" / "EXECUTION-BOARD.json"
39
- )
35
+ # The Booking delivery board is a maintainer document of the Maggie source
36
+ # repository; the npm package does not ship it (config/release-privacy.json).
37
+ EXECUTION_BOARD = REPO_ROOT / "docs" / "maggiedash-booking" / "EXECUTION-BOARD.json"
40
38
 
41
39
 
42
40
  def load_json(path: Path) -> dict:
@@ -57,6 +55,8 @@ def load_gate_json(path: Path, label: str) -> tuple[dict | None, str | None]:
57
55
 
58
56
 
59
57
  def load_execution_board(path: Path = EXECUTION_BOARD) -> dict:
58
+ if not path.is_file():
59
+ raise RuntimeError("the Booking delivery board lives in the Maggie source repository (docs/maggiedash-booking/EXECUTION-BOARD.json); pass --board PATH")
60
60
  board = load_json(path)
61
61
  if board.get("schemaVersion") != "maggiedash-booking-execution-board.v1":
62
62
  raise RuntimeError("unsupported Booking execution board schema")
@@ -661,9 +661,9 @@ def booking_setup_result(root: Path, environment: dict[str, str] | None = None)
661
661
  stripe_webhook = has("STRIPE_WEBHOOK_SECRET")
662
662
  stripe_complete = stripe_secret and stripe_webhook
663
663
  stripe_partial = stripe_secret != stripe_webhook
664
- stripe_key_help = "STRIPE_SECRET_KEY (or existing ORA alias ST_SECRECT_KEY)"
664
+ stripe_key_help = "STRIPE_SECRET_KEY (or the legacy alias ST_SECRECT_KEY)"
665
665
  stripe_detail = (
666
- "Recognizes ORA's existing ST_SECRECT_KEY alias; prefer STRIPE_SECRET_KEY for new deployments. Verify the connection in Booking Overview."
666
+ "Recognizes the legacy ST_SECRECT_KEY alias; prefer STRIPE_SECRET_KEY for new deployments. Verify the connection in Booking Overview."
667
667
  if stripe_legacy_alias and stripe_complete
668
668
  else f"Both {stripe_key_help} and STRIPE_WEBHOOK_SECRET are required for online payments."
669
669
  if stripe_partial
@@ -872,7 +872,8 @@ def command_install(args: argparse.Namespace) -> int:
872
872
  return 2
873
873
  root = project_path(args)
874
874
  dash = Path(__file__).with_name("maggie_dash.py")
875
- command = [sys.executable, str(dash), "install", "--project", str(root), "--host", args.host]
875
+ # Booking is an optional MaggieDash module: install the core and Booking.
876
+ command = [sys.executable, str(dash), "install", "--project", str(root), "--host", args.host, "--with", "booking"]
876
877
  if args.source:
877
878
  command.extend(["--source", args.source])
878
879
  if args.ref:
@@ -252,7 +252,7 @@ def merge_astro_middleware(path: Path) -> dict[str, object]:
252
252
  return {"status": "updated", "reason": "inserted idempotent Maggie rewrite block"}
253
253
 
254
254
 
255
- def manifest_host_file_pairs(source: Path, manifest: dict[str, object], root: Path, framework: str | None) -> list[tuple[Path, Path, Path]]:
255
+ def manifest_host_file_pairs(source: Path, manifest: dict[str, object], root: Path, framework: str | None, required: bool = True) -> list[tuple[Path, Path, Path]]:
256
256
  """Return non-destructive host files for a declared framework adapter."""
257
257
  if not framework:
258
258
  return []
@@ -260,6 +260,8 @@ def manifest_host_file_pairs(source: Path, manifest: dict[str, object], root: Pa
260
260
  if not isinstance(bootstrap, dict):
261
261
  return []
262
262
  spec = bootstrap.get(framework)
263
+ if not isinstance(spec, dict) and not required:
264
+ return []
263
265
  if not isinstance(spec, dict):
264
266
  raise RuntimeError(
265
267
  f"MaggieDash distribution at {source} does not provide a {framework} host bootstrap; "
@@ -288,6 +290,128 @@ def manifest_host_file_pairs(source: Path, manifest: dict[str, object], root: Pa
288
290
  return pairs
289
291
 
290
292
 
293
+ CORE_MODULE = "core"
294
+
295
+
296
+ def module_names(manifest: dict[str, object]) -> list[str]:
297
+ modules = manifest.get("modules")
298
+ return sorted(modules) if isinstance(modules, dict) else []
299
+
300
+
301
+ def effective_manifest(manifest: dict[str, object], modules: list[str]) -> dict[str, object]:
302
+ """Core entries plus the root files and workspaces of the chosen modules."""
303
+ result = dict(manifest)
304
+ specs = manifest.get("modules") if isinstance(manifest.get("modules"), dict) else {}
305
+ root_files = list(manifest.get("rootFiles", []))
306
+ workspaces = list(manifest.get("workspaces", []))
307
+ for name in modules:
308
+ spec = specs.get(name) or {}
309
+ root_files.extend(spec.get("rootFiles", []))
310
+ workspaces.extend(spec.get("workspaces", []))
311
+ result["rootFiles"] = root_files
312
+ result["workspaces"] = workspaces
313
+ return result
314
+
315
+
316
+ def resolve_modules(manifest: dict[str, object], requested: list[str], previous: list[str]) -> list[str]:
317
+ available = module_names(manifest)
318
+ if requested and not available:
319
+ # A release without the module split installs everything already.
320
+ return []
321
+ unknown = sorted(set(requested) - set(available))
322
+ if unknown:
323
+ raise RuntimeError(f"unknown MaggieDash module(s): {', '.join(unknown)}; this release offers: {', '.join(available) or 'none'}")
324
+ # Re-running install keeps refreshing modules that were added before.
325
+ return sorted((set(requested) | set(previous)) & set(available))
326
+
327
+
328
+ def installed_state(root: Path) -> dict[str, object]:
329
+ try:
330
+ value = json.loads((root / ".maggie" / "dash-install.json").read_text(encoding="utf-8"))
331
+ except (OSError, json.JSONDecodeError):
332
+ return {}
333
+ return value if isinstance(value, dict) else {}
334
+
335
+
336
+ def package_dependencies(root: Path) -> set[str]:
337
+ try:
338
+ value = json.loads((root / "package.json").read_text(encoding="utf-8"))
339
+ except (OSError, json.JSONDecodeError):
340
+ return set()
341
+ return set(value.get("dependencies") or {}) | set(value.get("devDependencies") or {})
342
+
343
+
344
+ SERVICE_WORDS = re.compile(r"\b(book (?:now|an? (?:appointment|session|treatment|table))|appointments?|treatments?|reservations?|book online|opening hours)\b", re.I)
345
+ LOCALE_DIR = re.compile(r"^(?:[a-z]{2}(?:-[a-z]{2,4})?)$")
346
+
347
+
348
+ def analyse_host(root: Path, framework: str | None) -> dict[str, object]:
349
+ """What the host already has, read from files only (nothing is executed)."""
350
+ deps = package_dependencies(root)
351
+ auth = sorted(name for name in deps if name in {"firebase", "firebase-admin", "next-auth", "lucia", "better-auth", "@supabase/supabase-js", "@supabase/ssr", "auth-astro"} or name.startswith(("@clerk/", "@auth/")))
352
+ database = sorted(name for name in deps if name in {"pg", "postgres", "@neondatabase/serverless", "drizzle-orm", "prisma", "@prisma/client", "mysql2", "better-sqlite3", "@libsql/client", "kysely"})
353
+ for config in ("wrangler.json", "wrangler.jsonc", "wrangler.toml"):
354
+ path = root / config
355
+ if path.is_file() and "d1_databases" in path.read_text(encoding="utf-8", errors="replace"):
356
+ database.append("cloudflare-d1")
357
+ payments = sorted(name for name in deps if name in {"stripe", "@stripe/stripe-js", "@paypal/checkout-server-sdk", "@lemonsqueezy/lemonsqueezy.js"})
358
+ source = root / "src"
359
+ service_hits: list[str] = []
360
+ if source.is_dir():
361
+ for path in sorted(source.rglob("*")):
362
+ if len(service_hits) >= 5:
363
+ break
364
+ if path.is_file() and path.suffix in {".astro", ".md", ".mdx", ".tsx", ".jsx", ".ts", ".json", ".html", ".vue", ".svelte"} and "_maggie" not in path.parts and "maggie" not in path.parts:
365
+ try:
366
+ if SERVICE_WORDS.search(path.read_text(encoding="utf-8", errors="replace")[:200_000]):
367
+ service_hits.append(str(path.relative_to(root)))
368
+ except OSError:
369
+ continue
370
+ blog = any((root / candidate).exists() for candidate in ("src/content/blog", "src/content/posts", "src/pages/blog", "src/pages/blog.astro", "src/pages/posts", "content/blog", "app/blog"))
371
+ locales: set[str] = set()
372
+ for pages in (root / "src" / "pages", root / "app"):
373
+ if pages.is_dir():
374
+ locales.update(child.name for child in pages.iterdir() if child.is_dir() and LOCALE_DIR.match(child.name) and child.name not in {"api", "og"})
375
+ astro_config = next((path for path in root.glob("astro.config.*")), None)
376
+ if astro_config:
377
+ match = re.search(r"locales\s*:\s*\[([^\]]+)\]", astro_config.read_text(encoding="utf-8", errors="replace"))
378
+ if match:
379
+ locales.update(re.findall(r"[\"']([A-Za-z-]+)[\"']", match.group(1)))
380
+ return {
381
+ "framework": framework,
382
+ "auth": auth,
383
+ "database": sorted(set(database)),
384
+ "payments": payments,
385
+ "sellsServices": bool(service_hits),
386
+ "serviceEvidence": service_hits,
387
+ "blog": blog,
388
+ "locales": sorted(locales),
389
+ }
390
+
391
+
392
+ def recommendations(analysis: dict[str, object], available: list[str], installed: list[str]) -> list[dict[str, str]]:
393
+ result: list[dict[str, str]] = []
394
+ if "booking" in available and "booking" not in installed:
395
+ if analysis.get("sellsServices"):
396
+ notes = []
397
+ if not any(name in {"pg", "postgres", "@neondatabase/serverless"} for name in analysis.get("database", [])):
398
+ notes.append("it needs PostgreSQL, which this project does not use yet")
399
+ if analysis.get("auth"):
400
+ notes.append(f"the site already signs people in with {', '.join(analysis['auth'])}; MaggieDash keeps its own staff sign-in")
401
+ reason = "The site offers appointments or services (" + ", ".join(analysis.get("serviceEvidence", [])[:3]) + ")" + ("; " + "; ".join(notes) if notes else "")
402
+ result.append({"kind": "module", "module": "booking", "recommended": "yes", "reason": reason, "command": "maggie dash add booking --confirm"})
403
+ else:
404
+ result.append({"kind": "module", "module": "booking", "recommended": "no", "reason": "No appointments or bookable services found in src/; add it only if the business takes bookings.", "command": "maggie dash add booking --confirm"})
405
+ if analysis.get("blog"):
406
+ result.append({"kind": "skill", "skill": "maggie-blog", "recommended": "yes", "reason": "The site has a blog; manage posts from the dashboard Blog panel.", "command": "use the maggie-blog skill"})
407
+ result.append({"kind": "skill", "skill": "maggie-social-share", "recommended": "maybe", "reason": "Blog posts can be shared to social channels after publishing.", "command": "use the maggie-social-share skill"})
408
+ else:
409
+ result.append({"kind": "skill", "skill": "maggie-blog-bootstrap", "recommended": "maybe", "reason": "No blog found; add one only if the site will publish articles.", "command": "use the maggie-blog-bootstrap skill"})
410
+ if len(analysis.get("locales", [])) > 1:
411
+ result.append({"kind": "skill", "skill": "maggie-content-localization", "recommended": "yes", "reason": f"The site serves {', '.join(analysis['locales'])}; keep translations in the Languages panel.", "command": "use the maggie-content-localization skill"})
412
+ return result
413
+
414
+
291
415
  def command_install_diff(source: Path, manifest: dict[str, object], target: Path, compare_dir: Path | None, force: bool, host_pairs: list[tuple[Path, Path, Path]] | None = None, host_framework: str | None = None) -> dict[str, object]:
292
416
  pairs = manifest_file_pairs(source, manifest, target)
293
417
  root_pairs = manifest_root_file_pairs(source, manifest, target.parents[1])
@@ -386,7 +510,16 @@ def command_install(args: argparse.Namespace) -> int:
386
510
  raise RuntimeError("unsupported MaggieDash distribution schema")
387
511
  if manifest.get("target") != "_maggie/admin" and args.target == "_maggie/admin":
388
512
  raise RuntimeError("MaggieDash manifest target is not ./_maggie/admin")
513
+ previous = installed_state(root)
514
+ legacy = not module_names(manifest)
515
+ modules = resolve_modules(manifest, list(getattr(args, "with_modules", None) or []) + (module_names(manifest) if getattr(args, "all_modules", False) else []), [name for name in previous.get("modules", []) if name != CORE_MODULE])
516
+ module_specs = manifest.get("modules") if isinstance(manifest.get("modules"), dict) else {}
517
+ manifest = effective_manifest(manifest, modules)
389
518
  host_pairs = manifest_host_file_pairs(source, manifest, root, host_framework)
519
+ for name in modules:
520
+ host_pairs += manifest_host_file_pairs(source, module_specs[name], root, host_framework, required=False)
521
+ analysis = analyse_host(root, host_framework)
522
+ advice = recommendations(analysis, module_names({"modules": module_specs}), modules)
390
523
  compare_dir = None
391
524
  if args.existing_dir:
392
525
  compare_dir = Path(args.existing_dir).expanduser()
@@ -394,7 +527,9 @@ def command_install(args: argparse.Namespace) -> int:
394
527
  compare_dir = root / compare_dir
395
528
  compare_dir = compare_dir.resolve()
396
529
  if args.dry_run or args.diff:
397
- emit(command_install_diff(source, manifest, target, compare_dir, args.force, host_pairs, host_framework))
530
+ plan = command_install_diff(source, manifest, target, compare_dir, args.force, host_pairs, host_framework)
531
+ plan.update({"modules": [CORE_MODULE, *modules] if not legacy else ["all (release without modules)"], "availableModules": module_names({"modules": module_specs}), "host": analysis, "recommendations": advice})
532
+ emit(plan)
398
533
  return 0
399
534
  installed: list[str] = []
400
535
  preserved: list[str] = []
@@ -471,18 +606,39 @@ def command_install(args: argparse.Namespace) -> int:
471
606
  "hostPreserved": sorted(set(host_preserved)),
472
607
  "hostUpdated": sorted(set(host_updated)),
473
608
  "hostWarnings": sorted(set(host_warnings)),
609
+ "modules": [CORE_MODULE, *modules] if not legacy else ["legacy-all"],
474
610
  "updatedAt": utc_now(),
475
611
  }
476
612
  state_path = root / ".maggie" / "dash-install.json"
477
613
  state_path.parent.mkdir(parents=True, exist_ok=True)
478
614
  state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
479
- emit({"status": "needs-attention" if host_warnings else "installed", "version": manifest.get("version"), "target": str(target), "hostFramework": host_framework, "hostInstalled": len(set(host_installed)), "hostPreserved": len(set(host_preserved)), "hostUpdated": len(set(host_updated)), "hostWarnings": host_warnings, "installed": len(set(installed)), "preserved": len(set(preserved)), "state": str(state_path)})
615
+ notices = ["this MaggieDash release has no module split, so its Booking workspace was installed with the core; use MaggieDash 0.2.14 or later to install the core first"] if legacy and manifest.get("workspaces") else []
616
+ emit({"status": "needs-attention" if host_warnings else "installed", "version": manifest.get("version"), "modules": state["modules"], "notices": notices, "recommendations": advice, "next": "Add a recommended module with `maggie dash add <module> --confirm`; nothing optional is installed without that step." if advice and not legacy else None, "target": str(target), "hostFramework": host_framework, "hostInstalled": len(set(host_installed)), "hostPreserved": len(set(host_preserved)), "hostUpdated": len(set(host_updated)), "hostWarnings": host_warnings, "installed": len(set(installed)), "preserved": len(set(preserved)), "state": str(state_path)})
480
617
  return 1 if host_warnings else 0
481
618
  finally:
482
619
  if temporary is not None:
483
620
  temporary.cleanup()
484
621
 
485
622
 
623
+ def command_add(args: argparse.Namespace) -> int:
624
+ """Install one optional module next to an existing (or new) core install."""
625
+ args.with_modules = list(args.modules)
626
+ args.all_modules = False
627
+ return command_install(args)
628
+
629
+
630
+ def command_recommend(args: argparse.Namespace) -> int:
631
+ root = project_root(args)
632
+ framework = detect_host_framework(root, args.host)
633
+ state = installed_state(root)
634
+ installed = [name for name in state.get("modules", []) if name != CORE_MODULE]
635
+ analysis = analyse_host(root, framework)
636
+ # The module list comes from the installed release when known; Booking is
637
+ # the optional code module MaggieDash ships today.
638
+ emit({"status": "recommendations", "installed": state.get("modules", []), "host": analysis, "recommendations": recommendations(analysis, ["booking"], installed)})
639
+ return 0
640
+
641
+
486
642
  def command_init(args: argparse.Namespace) -> int:
487
643
  require_confirm(args)
488
644
  root = project_root(args)
@@ -832,8 +988,22 @@ def parser() -> argparse.ArgumentParser:
832
988
  install.add_argument("--dry-run", action="store_true", help="show the install plan without writing files")
833
989
  install.add_argument("--diff", action="store_true", help="show the install plan and compare with an existing workspace")
834
990
  install.add_argument("--existing-dir", help="existing dashboard source directory to compare during --diff")
991
+ install.add_argument("--with", dest="with_modules", action="append", default=[], metavar="MODULE", help="also install an optional module (repeatable), e.g. --with booking")
992
+ install.add_argument("--all-modules", action="store_true", help="install every optional module (the pre-0.2.14 behaviour)")
835
993
  install.add_argument("--confirm", action="store_true")
836
994
  install.set_defaults(func=command_install)
995
+ add = sub.add_parser("add", help="add an optional MaggieDash module (e.g. booking) to an installed core")
996
+ add.add_argument("modules", nargs="+", metavar="MODULE")
997
+ for flag, options in (("--project", {"default": "."}), ("--source", {"help": "local checkout or Git URL; defaults to MaggieDash repository"}), ("--ref", {"default": "main"}), ("--target", {"default": "_maggie/admin"}), ("--existing-dir", {})):
998
+ add.add_argument(flag, **options)
999
+ add.add_argument("--host", choices=["auto", "astro", "none"], default="auto")
1000
+ for flag in ("--force", "--dry-run", "--diff", "--confirm"):
1001
+ add.add_argument(flag, action="store_true")
1002
+ add.set_defaults(func=command_add)
1003
+ recommend = sub.add_parser("recommend", help="analyse the host project and recommend optional modules and skills")
1004
+ recommend.add_argument("--project", default=".")
1005
+ recommend.add_argument("--host", choices=["auto", "astro", "none"], default="auto")
1006
+ recommend.set_defaults(func=command_recommend)
837
1007
  status = sub.add_parser("status", help="inspect project and documents")
838
1008
  status.add_argument("--project", default=".")
839
1009
  status.add_argument("--project-id")