@topy-ai/maggie 0.7.53 → 0.7.54

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 (42) hide show
  1. package/README-zh-TW.md +28 -50
  2. package/README.md +85 -64
  3. package/bin/maggie.js +51 -7
  4. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
  5. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  6. package/bundled-skills/maggie-blog/SKILL.md +8 -0
  7. package/bundled-skills/maggie-booking/SKILL.md +20 -12
  8. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  9. package/bundled-skills/maggie-dash/SKILL.md +28 -2
  10. package/bundled-skills/maggie-deployment/SKILL.md +27 -0
  11. package/bundled-skills/maggie-design/SKILL.md +81 -0
  12. package/bundled-skills/maggie-feedback/SKILL.md +10 -2
  13. package/bundled-skills/maggie-marketplace/SKILL.md +1 -1
  14. package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
  15. package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
  16. package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
  17. package/bundled-skills/maggie-social-share/SKILL.md +30 -0
  18. package/bundled-tools/clis/maggie_booking.py +9 -8
  19. package/bundled-tools/clis/maggie_dash.py +173 -3
  20. package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
  21. package/bundled-tools/clis/maggie_design.py +7 -1
  22. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  23. package/bundled-tools/clis/maggie_design_lint.py +131 -1
  24. package/bundled-tools/clis/maggie_feedback.py +41 -13
  25. package/bundled-tools/clis/maggie_live_urls.py +138 -0
  26. package/bundled-tools/clis/maggie_localization.py +3 -0
  27. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  28. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  29. package/bundled-tools/clis/maggie_mobile.py +84 -1
  30. package/bundled-tools/clis/maggie_product_release.py +49 -1
  31. package/bundled-tools/clis/maggie_social_share.py +122 -1
  32. package/bundled-tools/integrations/maggie-api-pull.md +22 -0
  33. package/package.json +1 -1
  34. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  35. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  36. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  37. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  38. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  39. package/bundled-references/maggiedash-booking/README.md +0 -288
  40. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  41. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  42. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -70,6 +70,32 @@ Norwegian, Finnish, Greek, Hebrew, Czech, Romanian, Hungarian, Thai, Persian,
70
70
  and Swahili. Use BCP 47 regions such as `es-MX`, `pt-BR`, `ar-SA`, or `ja-JP`
71
71
  when market-specific copy is required.
72
72
 
73
+ ## Text keys are site-wide; generated pages need position keys
74
+
75
+ A translation stored under its source text (a text hash) applies to **every
76
+ page** that shows that text. That is right for a shared header and wrong for
77
+ generic phrases on generated pages: "your practice" is 诊所 on a dental page
78
+ and 事务所 on an accountants page, and only one translation can exist per
79
+ text. For generated or templated pages (one page per industry, city or
80
+ product), render the source page, then store each node's translation under a
81
+ **per-page position key** (page + node position, with the source text hash so
82
+ a changed source shows as stale). Keep text keys only as a fallback for text
83
+ that is genuinely the same everywhere.
84
+
85
+ ```bash
86
+ maggie localization key-check rows.json [--locale zh-hans]
87
+ maggie localization fragments --html dist --brand "Maggie"
88
+ ```
89
+
90
+ `key-check` reads per-page rows (`page`, `position`, `text`, `translation`)
91
+ and lists source texts translated differently on different pages — each one
92
+ needs position keys. `fragments` reads rendered HTML and lists text split
93
+ around an inline brand span into one- or two-word pieces ("Can", "No.",
94
+ "What") that cannot be translated alone; translate the whole sentence with
95
+ the span as a placeholder instead. Agents writing in parallel must use one
96
+ glossary for short shared fragments, or the same piece gets different
97
+ translations from different writers.
98
+
73
99
  ## Email and lifecycle email
74
100
 
75
101
  Localized email is a content surface with its own checks. Resolve the
@@ -31,9 +31,35 @@ must already be configured for the project owner.
31
31
  maggie dash install --project . --confirm
32
32
  ```
33
33
 
34
+ This installs **only the core**: the dashboard, the auth boundary
35
+ (email/password sign-in, first-owner registration, password reset), the
36
+ `/_maggie/admin` route, navigation and the core schema. It then reads the host
37
+ (framework, existing sign-in, database, payments, whether the site sells
38
+ services or publishes a blog, locales) and prints `recommendations`, one per
39
+ optional module or companion skill with a reason. Install a module only after
40
+ the user agrees:
41
+
42
+ ```bash
43
+ maggie dash recommend --project . # analysis only, writes nothing
44
+ maggie dash add booking --project . --confirm # same as: maggie booking install --confirm
45
+ ```
46
+
47
+ Booking is the optional code module MaggieDash ships (workspace, backend,
48
+ schema, Stripe adapter, worker, env example). Blog, localization, social
49
+ sharing and ops are Maggie skills that use the core panels; the
50
+ recommendations name them. Never pass `--with booking` or `--all-modules` for
51
+ a site that has not asked for bookings: a content-only site must not receive
52
+ payment and booking files. A release without the module split (MaggieDash
53
+ 0.2.13 or older) still installs everything and says so in `notices`.
54
+
55
+ Server settings in the Astro scaffold are read at run time
56
+ (`src/lib/maggie/env.ts`). Never read a secret through `import.meta.env.X` in
57
+ host code: Vite inlines it into the build output.
58
+
34
59
  The command is idempotent, preserves existing files unless `--force` is
35
- explicitly supplied, and records the resolved revision in
36
- `.maggie/dash-install.json`. It never accepts or stores a GitHub token as a
60
+ explicitly supplied, and records the resolved revision and installed
61
+ `modules` in `.maggie/dash-install.json` (a later `install` keeps refreshing
62
+ modules that were added). It never accepts or stores a GitHub token as a
37
63
  CLI argument. For local development or a pinned checkout, use
38
64
  `--source /path/to/MaggieDash` or `--source URL --ref REF`.
39
65
 
@@ -25,6 +25,33 @@ metadata:
25
25
  - Product CLI and MCP packages: `maggie product release-check|smoke|mcp-check`
26
26
  ([product release](../maggie-assistant-tools/references/product-release.md)).
27
27
 
28
+ ## Downstream surfaces after a major product release
29
+
30
+ A product change is not released while an agent-facing surface still
31
+ describes the old product. After any major change to the app, the dashboard or
32
+ the backend API (a new feature or channel, a renamed tab, a changed rule or
33
+ limit), walk every surface below and either update it or record "no change":
34
+
35
+ | Surface | Check | Evidence |
36
+ |---|---|---|
37
+ | Remote MCP tools | New or changed API params and response fields have tools; descriptions use the product's words | `tools/list` on the live server: count and the new names |
38
+ | Agent system prompts | In-app assistant and any hosted bridge/agent prompt name tabs, channels and rules as the app does | One question about the change answered correctly |
39
+ | Skills catalog and marketplace | Grep skills for the old wording; `mcp_tools` lists only real tool names; any tool-name allowlist is in sync with the tool source | Validator passes; marketplace repo pushed; agent hosts re-synced and recycled |
40
+ | CLI and MCP npm packages | Commands/columns for the new data; both package versions bumped together | `npm view <pkg> version` |
41
+ | Third-party plugins (ChatGPT, IDE) | Usually nothing if they use the remote MCP; re-review when tool names or auth change | Plugin lists the new tools |
42
+ | SDK / API client types | New response fields typed | Workspace typecheck |
43
+ | Mobile app | Web deploy plus OTA when the native fingerprint matches; a new build when it does not | Fingerprint compare; update id |
44
+ | Assistant memory | A new channel or surface gets its own memory channel | Memory read for that channel |
45
+ | Docs | PRD task list, progress log and the deployment runbook | — |
46
+ | Preview host protection | Before (or after) adding basic auth to a preview host, every workflow, script or webhook that sends to it is repointed to production or sends the preview's basic-auth credentials from a secret — a bearer token does not pass basic auth | `maggie deployment preview-auth-check --host preview.example.com --repo . --live` passes; the next run of each listed workflow is green and the public surfaces it feeds show the new version |
47
+ | npm staged publishes | A just-published version can stay invisible as `latest` for minutes; do not report it until it is. `Cannot publish over previously staged version` means the earlier attempt was already accepted, not a failure | `maggie product published --package-name NAME --version X --publish-log publish.log` reports `live` |
48
+
49
+ Run the walk in the release order (schema → backend → dashboard → app →
50
+ MCP/agent → bridge → npm → skills) and record which surfaces changed in the
51
+ release record. Keep publish tokens in an env file and pass them through a
52
+ temporary npmrc (`NPM_CONFIG_USERCONFIG`) that is deleted afterwards; never
53
+ print them.
54
+
28
55
  ## Edge and rendered canary
29
56
 
30
57
  After staging or production deployment, verify that the edge serves the
@@ -262,6 +262,59 @@ maggie design step author-<id> --project . \
262
262
  An author plan is not complete until every `requiredSteps` entry has matching
263
263
  evidence and the explicit approval step is recorded.
264
264
 
265
+ ### Bulk author mode (many similar pages)
266
+
267
+ For a family of similar pages (one use-case page per industry, one page per
268
+ city), use one bulk job instead of hand-built scripts:
269
+
270
+ ```bash
271
+ maggie design bulk plan --project . --targets targets.json --brief BRIEF.md --facts FACTS.md \
272
+ --validator '["python3", "scripts/validate-page.py"]' --check links --check prices --confirm
273
+ maggie design bulk next <job> --stage write # next batch (default 8) with each page's currentHash
274
+ maggie design bulk record <job> --target ID --stage written --expect-hash HASH
275
+ maggie design bulk validate <job> # project validator + built-in checks
276
+ maggie design bulk next <job> --stage picture # only pages validated and unchanged since
277
+ maggie design bulk record <job> --target ID --stage pictured
278
+ maggie design bulk status <job>
279
+ maggie design bulk seed <job> --confirm # only when every page passed
280
+ ```
281
+
282
+ The job enforces what goes wrong in a parallel run: a picture is drawn only
283
+ for a page that validated and has not changed since (a picture worker must
284
+ never rewrite a page a writer is still editing); a writer records with the
285
+ hash it read and is refused if the page changed meanwhile (re-read, re-apply,
286
+ record again); changing the brief, facts or validator mid-run sends every
287
+ earlier pass back to `revalidate`; nothing is seeded until all pages are
288
+ validated and pictured. Built-in checks: every internal link points at a page
289
+ of the job or a `--known-routes` entry, and every price appears in the facts
290
+ file. The project validator checks structure, counts, facts and translation
291
+ coverage. Share one glossary between writers for short repeated fragments
292
+ (see maggie-content-localization, position keys).
293
+
294
+ ### Page pictures: choose the engine before a large run
295
+
296
+ `maggie media generate` draws one picture with either engine; `maggie media
297
+ engine-plan` states time and cost first. Show the plan and wait for a yes
298
+ before a run of more than a handful of pictures.
299
+
300
+ ```bash
301
+ maggie media engine-plan --count 510 --size 2400x1000 --gemini-unit-cost <price>
302
+ maggie media generate --engine gemini --size 2400x1000 --prompt "..." --output hero.png --confirm
303
+ maggie media generate --engine codex --image screen.png --prompt "Redraw as a clean step picture" --output step.png --confirm
304
+ ```
305
+
306
+ | Engine | Billing | Speed (observed) | Reference pictures |
307
+ |---|---|---|---|
308
+ | `codex` | the ChatGPT plan's quota via the local `codex` CLI, no per-picture charge | about 75 s | yes (`--image`, before `--`) |
309
+ | `gemini` | the project's own `GEMINI_API_KEY` (its Google account) | about 19 s at 2K | no |
310
+
311
+ For 510 pictures that is hours versus minutes. Gemini takes the nearest
312
+ aspect ratio and a size from the target (2400x1000 → 21:9 at 2K; 1K up to
313
+ 1300 px, 2K up to 2600 px, else 4K); crop to the exact size and convert to
314
+ WebP afterwards. `engine-plan` leaves the Gemini cost empty until you pass
315
+ `--gemini-unit-cost` from the current price list. Only transient Gemini
316
+ errors (429, 5xx, network) are retried.
317
+
265
318
  ## Content UI initialization
266
319
 
267
320
  Use content UI initialization when `maggie-blog` or
@@ -398,6 +451,34 @@ repeated sheet and modal changes. Add `maggie design textinput-check`: a
398
451
  after collapsing; see the `chat-composer` pattern). `// maggie-allow-textinput`
399
452
  marks a reviewed exception.
400
453
 
454
+ Closing a modal sheet is the third iOS-only trap. Closing a sheet and, in the
455
+ same tick, showing a toast, refreshing or writing query data, navigating,
456
+ opening another sheet, or changing what the closing sheet shows can leave the
457
+ sheet's transparent window on top of the app: it looks fine but takes no
458
+ touches until the app is killed. Close first, then do the rest once it has
459
+ slid away:
460
+
461
+ export const SHEET_CLOSE_MS = 450; // the slide-out duration
462
+ export function afterSheetCloses(fn: () => void) {
463
+ if (Platform.OS === "web") fn(); else setTimeout(fn, SHEET_CLOSE_MS);
464
+ }
465
+ // setSheet(null); afterSheetCloses(() => { toast(...); refresh(); });
466
+
467
+ maggie design sheet-check --project apps/app
468
+
469
+ `sheet-check` finds closes (`setSheet(null)`, `setXOpen(false)`, `onClose()`,
470
+ and the setter of any state used in a sheet's `open`/`visible` prop) followed
471
+ in the same block by a toast, a refetch/invalidate/`setQueryData`,
472
+ `router.*`/`*.open(…)` or another sheet opening; work inside
473
+ `afterSheetCloses`, `setTimeout`, `requestAnimationFrame`, a local helper that
474
+ wraps them, or after `await new Promise(r => setTimeout(r, …))` is deferred and
475
+ passes. Errors fail the check. Warnings: a parent callback (`onDone()`) right
476
+ after a close, and a sheet whose content reads the same state as its `open`
477
+ prop (`open={Boolean(item)} title={item?.label}`), which empties the sheet
478
+ while it slides away — keep a separate open flag. `// maggie-allow-sheet-close`
479
+ marks a reviewed exception. Keep it with pressable-check, safe-area-check and
480
+ textinput-check in the app's lint loop.
481
+
401
482
  ### Onboarding wizards
402
483
 
403
484
  Use one pattern for multi-step setup (brand, logo, voice, posting hours, and
@@ -223,10 +223,18 @@ leaving contradictory claims current.
223
223
  The CLI never submits automatically. After reviewing the draft:
224
224
 
225
225
  ```bash
226
- maggie feedback submit .maggie/feedback/<feedback-id>.json \
227
- --endpoint https://feedback.noblox.app/api/feedback --confirm
226
+ maggie feedback submit .maggie/feedback/<feedback-id>.json --confirm
228
227
  ```
229
228
 
229
+ Without `--endpoint` the draft goes to `https://feedback.noblox.app/api/feedback`
230
+ and, only when that host fails before storing it (no connection, timeout, or a
231
+ 5xx such as Cloudflare's 526), to `https://feedback.cmo.so/api/feedback`. Both
232
+ accept the same contract and de-duplicate on `feedbackId`, so a retry never
233
+ creates a second record. A 4xx is the host rejecting the draft and is not
234
+ retried elsewhere. The saved `submission` records the `endpoint` that accepted
235
+ the draft and any `failedEndpoints`. Repeat `--endpoint URL` to give your own
236
+ ordered list. Do not hand-edit drafts to point at a different host.
237
+
230
238
  Only HTTPS endpoints are accepted. Project paths, file contents, secrets, and
231
239
  logs are excluded by default. `--allow-project-context` is an explicit opt-in;
232
240
  even then, send only a short public-safe context note. After submission, the
@@ -231,7 +231,7 @@ imported by the server and every node script (town pages, sitemaps, maps) —
231
231
  never a copy. It parses UK post towns and US "City, ST", falls back to the
232
232
  town field plus the state from the ZIP prefix when a US profile saved only
233
233
  the street, keeps non-US places in a US market ("Kingston, Jamaica"), and
234
- gives a normalised search key so "moonlake" finds "Moon Lake Spa", ranked
234
+ gives a normalised search key so "lakeview" finds "Lake View Spa", ranked
235
235
  exact → prefix → word prefix → contains. See [directory towns and
236
236
  search](references/directory-search.md) for the generated-column SQL.
237
237
 
@@ -27,7 +27,7 @@ counts per town.
27
27
 
28
28
  ## Name search
29
29
 
30
- People type "moonlake" for "Moon Lake Spa". Compare a normalised key —
30
+ People type "lakeview" for "Lake View Spa". Compare a normalised key —
31
31
  lower case, accents removed, letters and digits only (`searchKey`) — as well
32
32
  as the plain `ILIKE` match, and rank exact and prefix matches first
33
33
  (`nameMatchRank`). In Postgres keep the key in a generated column with an
@@ -85,8 +85,8 @@ export function parseTown(address, market, locality, postcode) {
85
85
  }
86
86
 
87
87
  /**
88
- * Name search key: lower case, accents removed, letters and digits only, so "moonlake", "Moon-Lake" and
89
- * "Moon Lake Spa" meet. Store it in a generated column (see directory-search.md) and compare both forms.
88
+ * Name search key: lower case, accents removed, letters and digits only, so "lakeview", "Lake-View" and
89
+ * "Lake View Spa" meet. Store it in a generated column (see directory-search.md) and compare both forms.
90
90
  * @param {string | null | undefined} value
91
91
  */
92
92
  export const searchKey = (value) => (value || "").normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "");
@@ -129,6 +129,35 @@ always restores the working copies — checked by hash — even when a step
129
129
  fails. A mismatch with the build's own files means the change needs a new
130
130
  native build. `MAGGIE_MOBILE_EAS` overrides the `npx eas-cli` invocation.
131
131
 
132
+ ### Monorepos: publish only to a runtime an installed build has
133
+
134
+ In a pnpm monorepo the fingerprint also hashes pnpm's virtual-store paths
135
+ (`node_modules/.pnpm/<pkg>@<version>_<peer hash>`), so a dependency change in
136
+ **any** workspace package (React added to the website) can move the app's
137
+ runtime. `eas update` then publishes to a runtime no build has — silently.
138
+ `ignorePaths` and `fileHookTransform` cannot fix it: `@expo/fingerprint` adds
139
+ each native module folder by its real path. Keep the installed builds'
140
+ runtimes in a file and publish through the guard:
141
+
142
+ ```json
143
+ {"ios": [{"build": 7, "eas_build_id": "…", "runtime": "19d7a260e8e2…"}], "android": []}
144
+ ```
145
+
146
+ ```bash
147
+ maggie mobile ota-guard --project . --app-dir apps/app --platform ios --runtimes scripts/ota-runtimes.json
148
+ maggie mobile ota-guard … --publish --message "Fix copy" --confirm
149
+ ```
150
+
151
+ It computes the fingerprint (`expo-updates fingerprint:generate`), refuses
152
+ when no installed build has it — naming the two ways out (a native build, or
153
+ publishing from a worktree with the build's lockfile and workspace file after a
154
+ frozen install) — and with `--publish --confirm` runs `eas update` and checks
155
+ that the published runtime is the one it checked. Add a build's runtime to the
156
+ file when it ships; drop it when nobody runs it. At the next native build set
157
+ an explicit `runtimeVersion` with a native-change check, so unrelated
158
+ workspace changes cannot move it. `preflight` warns about a fingerprint
159
+ runtime in a pnpm workspace.
160
+
132
161
  Show the plan's `costNotice` and wait for confirmation before step 1.
133
162
 
134
163
  ## 5. Store-review rules
@@ -36,6 +36,36 @@ URL, channel limits, UTM links, approval state, and provider post state.
36
36
  draft without `workspace_domain`. Apply the same rule in source selection:
37
37
  an account that manages several sites must not pick another site's
38
38
  articles for this workspace's plan.
39
+ - **Live links only.** Before planning posts from articles, drop the ones
40
+ whose link is gone. A site that moved platforms leaves old URLs that answer
41
+ 404; posts scheduled with them reach readers as dead links.
42
+
43
+ ```bash
44
+ maggie tool maggie_social_share.py link-check .maggie/candidate-articles.json
45
+ maggie tool maggie_social_share.py plan content.json --workspace-domain example.com --check-link
46
+ ```
47
+
48
+ Only a definite 404 or 410 (also after redirects) drops an article. Blocked
49
+ (403/429), failing (5xx) or unreachable pages are kept, so a firewall or an
50
+ outage cannot empty the list; verdicts are cached for 6 hours
51
+ (`.maggie/social-link-cache.json`, `--ttl-hours`). Prefer the URL the site
52
+ reports for a delivered post over an older stored one (maggie-blog, "Where a
53
+ delivered post went live").
54
+ - **One picture per idea.** Posts written together for several channels from
55
+ one idea share one picture on every channel, and a later picture change
56
+ (redraw, replace, rewrite of an idea that had one) is applied to every
57
+ channel of that idea, not only some. Write all channels of an idea through
58
+ the same path — an article-sourced channel (often LinkedIn) must join the
59
+ other channels' idea instead of being written separately. Check with:
60
+
61
+ ```bash
62
+ maggie tool maggie_social_share.py pictures .maggie/social-drafts.json
63
+ ```
64
+
65
+ It groups drafts by `idea_id` and fails a group whose channels carry
66
+ different pictures or one without; a channel whose picture a person chose
67
+ records `image_source: "user"`. `validate` applies the same rule to the
68
+ channels of one draft.
39
69
  - **Image posts by default.** When a post has its own image (`image_url`),
40
70
  each channel gets `publish_mode: "image"` with the link in the text;
41
71
  `link_preview` is the default only without an image. A person may switch a
@@ -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")