@bettercms-ai/mcp 0.61.0 → 0.62.0

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/dist/index.js CHANGED
@@ -1992,16 +1992,285 @@ var LAYOUT_SECTION_ICONS = Object.freeze([
1992
1992
  ]);
1993
1993
  var LAYOUT_SECTION_ICON_SET = new Set(LAYOUT_SECTION_ICONS);
1994
1994
 
1995
+ // ../types/src/dock-icon-names.ts
1996
+ var DOCK_ICON_NAMES = [
1997
+ "access-denied",
1998
+ "activity",
1999
+ "add",
2000
+ "add-circle",
2001
+ "add-comment",
2002
+ "add-document",
2003
+ "add-user",
2004
+ "api",
2005
+ "archive",
2006
+ "arrow-down",
2007
+ "arrow-left",
2008
+ "arrow-right",
2009
+ "arrow-top-right",
2010
+ "arrow-up",
2011
+ "asterisk",
2012
+ "bar-chart",
2013
+ "basket",
2014
+ "bell",
2015
+ "bill",
2016
+ "binary-document",
2017
+ "block-content",
2018
+ "block-element",
2019
+ "blockquote",
2020
+ "bold",
2021
+ "bolt",
2022
+ "book",
2023
+ "bookmark",
2024
+ "bookmark-filled",
2025
+ "bottle",
2026
+ "bug",
2027
+ "bulb-filled",
2028
+ "bulb-outline",
2029
+ "calendar",
2030
+ "case",
2031
+ "chart-upward",
2032
+ "checkmark",
2033
+ "checkmark-circle",
2034
+ "chevron-down",
2035
+ "chevron-left",
2036
+ "chevron-right",
2037
+ "chevron-up",
2038
+ "circle",
2039
+ "clipboard",
2040
+ "clipboard-image",
2041
+ "clock",
2042
+ "close",
2043
+ "close-circle",
2044
+ "code",
2045
+ "code-block",
2046
+ "cog",
2047
+ "collapse",
2048
+ "color-wheel",
2049
+ "comment",
2050
+ "component",
2051
+ "compose",
2052
+ "compose-sparkles",
2053
+ "confetti",
2054
+ "controls",
2055
+ "copy",
2056
+ "credit-card",
2057
+ "crop",
2058
+ "cube",
2059
+ "dashboard",
2060
+ "database",
2061
+ "desktop",
2062
+ "diamond",
2063
+ "document",
2064
+ "document-pdf",
2065
+ "document-remove",
2066
+ "document-sheet",
2067
+ "document-text",
2068
+ "document-video",
2069
+ "document-word",
2070
+ "document-zip",
2071
+ "documents",
2072
+ "dot",
2073
+ "double-chevron-down",
2074
+ "double-chevron-left",
2075
+ "double-chevron-right",
2076
+ "double-chevron-up",
2077
+ "double-quote",
2078
+ "download",
2079
+ "drag-handle",
2080
+ "drop",
2081
+ "earth-americas",
2082
+ "earth-globe",
2083
+ "edit",
2084
+ "ellipsis-horizontal",
2085
+ "ellipsis-vertical",
2086
+ "empty",
2087
+ "enter",
2088
+ "enter-right",
2089
+ "envelope",
2090
+ "equal",
2091
+ "error-filled",
2092
+ "error-outline",
2093
+ "error-screen",
2094
+ "expand",
2095
+ "eye-closed",
2096
+ "eye-open",
2097
+ "face-happy",
2098
+ "face-indifferent",
2099
+ "face-sad",
2100
+ "feedback",
2101
+ "filter",
2102
+ "folder",
2103
+ "generate",
2104
+ "github",
2105
+ "groq",
2106
+ "hash",
2107
+ "heart",
2108
+ "heart-filled",
2109
+ "help-circle",
2110
+ "highlight",
2111
+ "home",
2112
+ "ice-cream",
2113
+ "image",
2114
+ "image-remove",
2115
+ "images",
2116
+ "inbox",
2117
+ "info-filled",
2118
+ "info-outline",
2119
+ "inline",
2120
+ "inline-element",
2121
+ "insert-above",
2122
+ "insert-below",
2123
+ "italic",
2124
+ "joystick",
2125
+ "json",
2126
+ "launch",
2127
+ "leave",
2128
+ "lemon",
2129
+ "link",
2130
+ "link-removed",
2131
+ "linkedin",
2132
+ "list",
2133
+ "lock",
2134
+ "logo-js",
2135
+ "logo-ts",
2136
+ "marker",
2137
+ "marker-removed",
2138
+ "master-detail",
2139
+ "menu",
2140
+ "microphone",
2141
+ "microphone-slash",
2142
+ "mobile-device",
2143
+ "moon",
2144
+ "number",
2145
+ "ok-hand",
2146
+ "olist",
2147
+ "overage",
2148
+ "package",
2149
+ "panel-left",
2150
+ "panel-right",
2151
+ "pause",
2152
+ "pin",
2153
+ "pin-filled",
2154
+ "pin-removed",
2155
+ "play",
2156
+ "plug",
2157
+ "presentation",
2158
+ "progress-50",
2159
+ "progress-75",
2160
+ "projects",
2161
+ "publish",
2162
+ "read-only",
2163
+ "redo",
2164
+ "refresh",
2165
+ "remove",
2166
+ "remove-circle",
2167
+ "reset",
2168
+ "restore",
2169
+ "retrieve",
2170
+ "retry",
2171
+ "revert",
2172
+ "robot",
2173
+ "rocket",
2174
+ "schema",
2175
+ "search",
2176
+ "select",
2177
+ "share",
2178
+ "sort",
2179
+ "sparkle",
2180
+ "sparkles",
2181
+ "spinner",
2182
+ "split-horizontal",
2183
+ "split-vertical",
2184
+ "square",
2185
+ "stack",
2186
+ "stack-compact",
2187
+ "star",
2188
+ "star-filled",
2189
+ "stop",
2190
+ "strikethrough",
2191
+ "string",
2192
+ "sun",
2193
+ "sync",
2194
+ "tablet-device",
2195
+ "tag",
2196
+ "tags",
2197
+ "target",
2198
+ "task",
2199
+ "terminal",
2200
+ "text",
2201
+ "th-large",
2202
+ "th-list",
2203
+ "thumbs-down",
2204
+ "thumbs-up",
2205
+ "tiers",
2206
+ "timeline",
2207
+ "toggle-arrow-right",
2208
+ "token",
2209
+ "transfer",
2210
+ "translate",
2211
+ "trash",
2212
+ "trend-upward",
2213
+ "triangle-outline",
2214
+ "trolley",
2215
+ "truncate",
2216
+ "twitter",
2217
+ "ulist",
2218
+ "unarchive",
2219
+ "underline",
2220
+ "undo",
2221
+ "unknown",
2222
+ "unlink",
2223
+ "unlock",
2224
+ "unpublish",
2225
+ "upload",
2226
+ "user",
2227
+ "users",
2228
+ "versions",
2229
+ "video",
2230
+ "warning-filled",
2231
+ "warning-outline",
2232
+ "wrench"
2233
+ ];
2234
+ var DOCK_ICON_NAME_SET = new Set(DOCK_ICON_NAMES);
2235
+
1995
2236
  // src/tools.ts
1996
2237
  import { BetterCMSError } from "@bettercms-ai/sdk";
2238
+
2239
+ // src/dock-icons.ts
2240
+ var DOCK_ICONS_URI = "bettercms://icons/dock";
2241
+ var DOCK_ICON_SET = DOCK_ICON_NAME_SET;
2242
+ var DOCK_ICONS_RESOURCE = {
2243
+ uri: DOCK_ICONS_URI,
2244
+ name: "dock-icons",
2245
+ title: "BetterCMS dock icon names",
2246
+ description: "Every name `ui.icon`, a fieldset's `icon` and a prop's `ui.group.icon` accept. Layout section icons are a different list (Lucide names).",
2247
+ mimeType: "text/markdown"
2248
+ };
2249
+ var DOCK_ICONS_TEXT = `# Dock icon names
2250
+
2251
+ \`ui.icon\` on a nesting field, \`icon\` on a fieldset and \`ui.group.icon\` on a component prop take ONE
2252
+ name from this list. Any other string is refused with a 400 that names this resource. A leaf field
2253
+ never carries an icon.
2254
+
2255
+ Two vocabularies, on purpose: Layout section icons (update_layout \`add-section\`) stay Lucide names.
2256
+ A name from one list is not valid in the other.
2257
+
2258
+ ${DOCK_ICON_NAMES.length} names:
2259
+
2260
+ ${DOCK_ICON_NAMES.join(", ")}
2261
+ `;
2262
+
2263
+ // src/tools.ts
1997
2264
  import { DeviceAuthPendingError } from "@bettercms-ai/device-auth";
1998
2265
 
1999
2266
  // src/structure-playbook.ts
2000
2267
  var STRUCTURE_PLAYBOOK_URI = "bettercms://playbook/structure";
2001
2268
  var STRUCTURE_DEFAULT_INSTRUCTION = `After you create collections or pages, organise them per the structure playbook: read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure (read-only), show the user its outline, then apply it with set_content_structure and the version it returned (the If-Match). A project that already has a structure keeps it: file only what you created, with move_to_folder, unless the user asks for a full re-organisation.`;
2002
2269
  var SKILLS_ROUTING_INSTRUCTION = "Before writing code in a repo that uses BetterCMS, explore the repo, then read the installed `bettercms` skill (`npx @bettercms-ai/install --project -y` adds it if missing): its table turns what you found into the one reference the task needs \u2014 read only that.";
2270
+ var DOCK_UI_ROUTING_INSTRUCTION = "How fields and props look in the editor (groups, tabs, rows, icons, item names) is playbook \xA714 or the `field-ui` prompt: write it with update_content_model field* or update_component prop* plus ifMatch, read it back, and finish when get_next_steps reports no dock-* step.";
2003
2271
  var RECEIPT_FIRST_INSTRUCTION = "Start every project with get_next_steps: its `receipt` lists what is already done (`done`), what is still missing (`missing`, each item citing the count it reacted to) and the one `nextAction`. Work from that receipt instead of re-reading the site, call get_next_steps again after each batch of edits, and before you tell the user you are finished. Fix what it lists, or say why you are leaving it.";
2004
- var NEXT_STEPS_DESCRIPTION = `Call this FIRST on a project. Returns \`receipt\` \u2014 \`done\` (what the platform already sees as finished: live release, bound elements, conversion coverage, pages composed from components, published components and Layout), \`missing\` and \`nextAction\` \u2014 plus \`data\`, the unfinished items: pages without a meta description, drafts never published, collections with no entries, forms nobody is notified about, writes waiting for human approval, bindings or sections the build has not stamped. Each item cites the count it reacted to. Call it again AFTER a batch of edits to catch what you left behind, and before telling the user you are done. When it lists \`organise-content\`, the project's pages and collections are in no folder: read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure and apply it with set_content_structure once the user agrees.`;
2272
+ var NEXT_STEPS_DESCRIPTION = `Call this FIRST on a project. Returns \`receipt\` \u2014 \`done\` (live release, bound elements, conversion coverage, composed pages, published components and Layout), \`missing\`, \`nextAction\` and \`notes\` (unjudged: unknown, not done) \u2014 plus \`data\`, the unfinished items: missing meta descriptions, drafts, empty collections, unnotified forms, writes awaiting approval, unstamped bindings or sections, \`authoring-undecided\` and the Layout lane (\`layout-write-blocked\`, \`layout-field-unread\`, \`layout-locator-stale\`, \`layout-rebuild-required\`, \`chrome-duplicated\`). Each item cites its count and may carry \`payload\` {rows, total}, at most 20 rows. Call it again after a batch of edits and before telling the user you are done. \`organise-content\`: pages and collections are in no folder \u2014 read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure and, once the user agrees, set_content_structure.`;
2273
+ var LAYOUT_CHROME_INSTRUCTION = 'When the user asks to make the navigation, footer or other site chrome editable, or get_next_steps lists a `layout-*` or `chrome-duplicated` item: this is playbook \xA711 item 3. Chrome is the project Layout, never page fields: in the source, wrap each Section in `data-bcms-layout-section` (the slot, its base slug) and mark each value `data-bcms-layout-field="layout:<sections[slug].id>:<fieldId>"`, never `data-bcms-field` inside it, then push; the strip removes the page copies, never delete them by hand. On a site BetterCMS serves, publish_layout writes Layout VALUES into the live pages; a structure change waits for a Rebuild.';
2005
2274
  var STRUCTURE_EXAMPLE_PAYLOAD = {
2006
2275
  version: 0,
2007
2276
  doc: {
@@ -2264,24 +2533,37 @@ var fieldType = z.enum([
2264
2533
  ]);
2265
2534
  var slug = z.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only");
2266
2535
  var fieldKey = z.string().regex(/^[a-zA-Z0-9_]+$/, "letters, numbers, and underscores only");
2267
- var uiObject = z.object({
2536
+ var uiObject = z.strictObject({
2268
2537
  collapsed: z.boolean().optional().describe("start this panel collapsed. Use it for long optional sections."),
2269
- preview: z.object({
2538
+ preview: z.strictObject({
2270
2539
  title: z.string().min(1).optional().describe("CHILD KEY whose value titles a collapsed row"),
2271
2540
  subtitle: z.string().min(1).optional().describe("CHILD KEY whose value is the row's second line (e.g. 'level' \u2192 'Heading \xB7 h2')"),
2272
2541
  media: z.string().min(1).optional().describe("CHILD KEY whose value is the row's thumbnail (image/file/url all work)")
2273
2542
  }).optional().describe(
2274
2543
  "how ONE ITEM summarises itself when collapsed. Every slot names a CHILD KEY of THIS field/prop \u2014 not a value, not a dotted path. Without it a repeater of testimonials reads 'Item 1, Item 2, Item 3'; with {title:'author', media:'avatar'} it reads the names with their faces."
2275
2544
  ),
2276
- layout: z.enum(["list", "grid"]).optional().describe(
2277
- "how the ITEMS are arranged. Omit (\u2261 'list') for rows of text; 'grid' for items whose thumbnail is the thing you scan \u2014 a gallery, a logo wall, a team. Same placement rule as `preview`."
2545
+ layout: z.enum(["list", "grid", "table"]).optional().describe(
2546
+ "how the ITEMS are arranged. Omit (\u2261 'list') for rows of text; 'grid' for items whose thumbnail is the thing you scan \u2014 a gallery, a logo wall, a team; 'table' for up to 3 scalar columns. Same placement rule as `preview`."
2278
2547
  ),
2279
2548
  reorderable: z.boolean().optional().describe(
2280
2549
  "omit (\u2261 true) unless the order is PART OF THE MEANING. `false` LOCKS the list \u2014 a 'three steps' band that must stay three steps in that order, a nav whose order is semantic, a timeline. Valid only where there is a list to lock."
2281
2550
  ),
2282
2551
  control: z.enum(["segmented", "slider"]).optional().describe(
2283
2552
  "editor control for a LEAF. Omit for the default. 'segmented' on a select with a few short options (alignment, theme, size); 'slider' on a number that has config.min < config.max (opacity, columns). Anything else is refused with the rule named."
2284
- )
2553
+ ),
2554
+ // A refine, not an enum: 236 names inlined into every schema that carries `ui` would be paid on
2555
+ // every tools/list. The layoutIcon precedent; the server's z.enum is the guard.
2556
+ icon: z.string().min(1).max(40).refine((v) => DOCK_ICON_SET.has(v), `not a dock icon, see ${DOCK_ICONS_URI}`).optional().describe(`the branch row's icon, a name from ${DOCK_ICONS_URI}. Nesting fields only.`),
2557
+ itemLabel: z.string().min(1).max(24).optional().describe("what ONE item is called ('Slide'): the add button, crumbs and empty state. Nesting fields only.")
2558
+ });
2559
+ var propUiObject = uiObject.extend({
2560
+ group: z.strictObject({
2561
+ id: z.string().min(1).max(40),
2562
+ label: z.string().min(1).max(32).optional(),
2563
+ kind: z.enum(["group", "row"]).optional(),
2564
+ icon: uiObject.shape.icon,
2565
+ collapsed: z.boolean().optional()
2566
+ }).optional().describe("the group this prop sits in. The FIRST prop of a group defines {id, label, kind?, icon?, collapsed?}; later props send only {id}.")
2285
2567
  });
2286
2568
  var fieldShape = {
2287
2569
  key: fieldKey.describe(
@@ -2345,14 +2627,23 @@ var urlPatternArg = z.string().nullable().optional().describe(
2345
2627
  "the public URL of one entry, e.g. '/blog/:slug'. Must start with '/' and contain ':slug' exactly once ('{slug}' is refused). Drives RSS item links and the automatic 301 when an entry's slug changes. null clears it."
2346
2628
  );
2347
2629
  var fieldsetsArg = z.array(
2348
- z.object({
2630
+ z.strictObject({
2349
2631
  id: z.string().min(1),
2350
2632
  name: z.string().min(1).max(60),
2351
- description: z.string().max(500).optional()
2633
+ description: z.string().max(500).optional(),
2634
+ kind: z.enum(["group", "row", "tab"]).optional(),
2635
+ tab: z.string().min(1).optional(),
2636
+ icon: uiObject.shape.icon,
2637
+ collapsed: z.boolean().optional(),
2638
+ // Server-owned provenance, as get_content_model returns it: an echo parses and is dropped
2639
+ // before the write (dropProvenance), never refused.
2640
+ uiOrigin: z.unknown().optional(),
2641
+ uiBefore: z.unknown().optional()
2352
2642
  })
2353
2643
  ).max(30).optional().describe(
2354
- "the model's editor cards: [{id, name, description?}], at most 30, ids unique, order = array index. `description` is one line under the card title. A field joins one with `fieldsetId`. Editor-only: never changes what is stored or delivered."
2644
+ "the model's editor cards: [{id, name, description?, kind?, tab?, icon?, collapsed?}], at most 30, ids unique, order = array index. kind 'group' is a disclosure, 'row' puts up to 3 short scalars on one line, 'tab' is a tab (at most 3, top-level fields only; untabbed fields stay pinned above the tabs; a group's `tab` names its tab). A field joins one with `fieldsetId`. Editor-only: never changes what is stored or delivered."
2355
2645
  );
2646
+ var dropProvenance = (sets) => sets?.map(({ uiOrigin: _origin, uiBefore: _before, ...set }) => set) ?? sets;
2356
2647
  var RICH_TEXT_NOTE = "\u{1F534} A field of type 'text' is created as RICH TEXT unless you send `richText: false` on it. Send `richText: false` for every value that is not prose: a name used as a title, a URL, slug, id, SKU, email, phone, CSS class or icon name.";
2357
2648
  var PAGES_NOT_COLLECTIONS_NOTE = "A collection is only for items one template route repeats (/blog/:slug: set urlPattern). A single page's copy (home, about, contact, pricing, legal) is page fields in sections (add_page_field / set_page_content), never a one-entry collection.";
2358
2649
  function flatFieldKeys(fields) {
@@ -2437,7 +2728,25 @@ function toField(f) {
2437
2728
  };
2438
2729
  }
2439
2730
  var BRAND_KIT_NOTE = "`brandKit` (colors, typography, radius and any typeScale/spacing/elevation/motion/graphics) is edited in the BetterCMS dashboard AND by set_brand_assets and set_brand_graphics; every write is a version a person can restore. ASSETS: a media asset OF THIS PROJECT by id; null clears one; a slot a person chose needs `replace: true`. GRAPHICS: `kind`, optional `angle`, 2-8 stops naming `colors.*` or `extras` keys \u2014 never a CSS string and never a hex. CONSUME, DO NOT COPY: a hosted page exposes `--brand-color-<key>`, `--brand-shadow-<key>` and `--brand-gradient-<key>`; style sections with their `style` tokens (`nav.backgroundGradient` and `footer.backgroundGradient` take a gradient key), never copied values. Never invent brand facts: read them from get_project.";
2440
- var MODEL_IF_MATCH_NOTE = "optimisticVersion from get_content_model. When sent, the update applies only if the model is still at that version; a 409 means someone changed it since \u2014 read it again and re-apply, never retry blindly. Omitted, the last write wins.";
2731
+ var UPDATE_MODEL_DESCRIPTION = "Edit a content model: rename it, change description/slug/urlPattern, set its `fieldsets`, or reshape how its EXISTING fields look in the editor. Never adds, removes or retypes a field (use add_field). Send modelId, or pageId for the model a page's schema lives in. `fieldsets` REPLACES the list (null drops all); a field joins one with fieldFieldset or add_field's `fieldsetId`. The field* args address fields by dotted key path ('hero.title'). A taken slug is 409 `slug_taken`. Section 14 of bettercms://playbook/schema.";
2732
+ var MODEL_IF_MATCH_NOTE = "optimisticVersion from get_content_model (with pageId: get_page backingModel). The write applies only at that version; a 409 version-conflict means it changed since: read it again and re-apply, never retry blindly. REQUIRED with field*, override or a fieldset kind/tab/icon/collapsed (428 without); otherwise, omitted, the last write wins.";
2733
+ var COMPONENT_IF_MATCH_NOTE = "optimisticVersion from get_component. The write applies only at that version; a 409 version-conflict means it changed since: read it again and re-apply. REQUIRED with prop*, override or a prop ui icon/itemLabel/group (428 without).";
2734
+ var PROP_UI_ARG_NOTE = "top-level prop key \u2192 ui patch, the same vocabulary as a prop's `ui`: each key merges, a null key clears it, null clears the whole ui";
2735
+ var OVERRIDE_NOTE = "replace chrome a person set (otherwise 409 ui_human_owned names those paths). Send it only on the user's word.";
2736
+ var FIELD_UI_ARG_NOTE = "path \u2192 ui patch, a field's `ui` vocabulary: each key merges, a null key clears it, null clears the whole ui";
2737
+ var MODEL_UPDATE_KEYS = [
2738
+ "name",
2739
+ "slug",
2740
+ "description",
2741
+ "urlPattern",
2742
+ "fieldsets",
2743
+ "fieldUi",
2744
+ "fieldFieldset",
2745
+ "fieldOrder",
2746
+ "fieldLabel",
2747
+ "fieldHelpText",
2748
+ "override"
2749
+ ];
2441
2750
  var ENTRY_META_NOTE = "SEO is native: set this entry's metaTitle, metaDescription, noindex, canonical, og, twitter, schemaType or schema in `meta` (never as model fields). `meta` MERGES into what is stored: a key you leave out is kept, null or an empty string clears it.";
2442
2751
  var PAGE_HEAD_NOTE = "noindex, canonical, og and twitter set the page's head extras; each MERGES into what is stored (a key you leave out is kept, null or an empty string clears it).";
2443
2752
  var STRUCTURE_NOTE = 'ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state; URL folders (page folders) are set in the dashboard. Folders nest at most 4 levels deep. Icons are lucide names (a-z, 0-9, dashes); null clears one. SYSTEM FOLDERS "home:pages" (Other pages), "home:collections" (Shared) and "home:globals" (Settings) always exist and can never be moved or deleted (400 NAV_SYSTEM_FOLDER); rename_folder and set_icon work on them. TYPE PURITY (400 NAV_TYPE_MISMATCH): Other pages holds only pages at any depth, Shared only collections, Settings neither \u2014 globals are not sidebar items. ACCESS: changing the structure needs Admin or Developer, reading only content access; a 403 SCHEMA_ACCESS_REQUIRED means neither: stop and tell the user, do not retry. PINS sit above the folders in their own order, pages or collections; in a document a pin is folderId "home:root", a virtual id with no folder record (a folder record with that id is 400 NAV_SYSTEM_FOLDER). set_content_structure never refuses a kind mismatch: it returns `warnings` and `kindMismatches` (the single writes do \u2014 see `kind`). PAGE-BOUND MODELS: a page with fields has a bound model that is NOT a collection: never listed or filed, and the writes refuse it with 400 NAV_PAGE_BOUND_MODEL naming its page. Organise the PAGE instead, by its page id. ';
@@ -2594,6 +2903,7 @@ var LISTING_TAIL = /* @__PURE__ */ new Set([
2594
2903
  "set_authoring_preference",
2595
2904
  "set_media_delivery",
2596
2905
  "delete_form_submission",
2906
+ "remove_page_fields",
2597
2907
  // The ABM campaign lane: a marketer's dashboard surface, plus the worker tools a task runner
2598
2908
  // claims with. Ten more tools than the tail was written for, and a connector that truncates
2599
2909
  // would otherwise lose `get_deploy_status` and the GitHub lane to make room for them.
@@ -2663,7 +2973,7 @@ function buildToolDefs(deps) {
2663
2973
  ]).describe("block type; section/slider/tabs/columns nest child blocks"),
2664
2974
  id: z.string().min(1).describe("stable unique block id"),
2665
2975
  props: z.record(z.string(), z.unknown()).describe(
2666
- "per-type props: heading {text, level}; text/richtext {html} (NOT {text}), and both also take level 1-6, rendering the html AS that <hN> with inline marks kept (one line of inline copy only) \u2014 use it for a title that carries a styled span; image {src, alt}; button {text, href}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may NOT hold columns/section/slider/tabs; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>"
2976
+ "per-type props: heading {text, level}; text/richtext {html} (NOT {text}), and both also take level 1-6, rendering the html AS that <hN> with inline marks kept (one line of inline copy only) \u2014 use it for a title that carries a styled span; image {src, alt}; button {text, href, variant? primary|secondary, size? sm|md|lg (default md), fullWidth? boolean}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may NOT hold columns/section/slider/tabs; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>"
2667
2977
  ),
2668
2978
  style: z.record(z.string(), z.unknown()).optional().describe(
2669
2979
  "design tokens: theme, bg (none|surface|muted|accent|dark|custom), bgCustom hex, paddingTop/paddingBottom/paddingSides px, contentWidth (narrow|default|wide|full), align, corner, shadow, borderTop/borderBottom, border (all four sides, in the text colour). A real marketing band is a `section` block carrying bg + padding + contentWidth."
@@ -2907,7 +3217,7 @@ function buildToolDefs(deps) {
2907
3217
  // ⚠ A STRIP SITE, exactly like OutField above: this is a `z.object`, so a key the model
2908
3218
  // sends and this shape does not declare is dropped BEFORE the request leaves — a 200 with
2909
3219
  // the declaration gone. `ui` rode through the backend's looseObject `config` and died here.
2910
- ui: uiObject.optional().describe(
3220
+ ui: propUiObject.optional().describe(
2911
3221
  "AUTHORING CHROME for this prop's control in the inspector \u2014 never a delivery effect, and the same object a FIELD's `ui` takes. `preview` and `layout` are valid on 'group', 'table' and 'slot' only; `reorderable` on 'table' ALONE, the one prop type whose value is a LIST (a 'group' is one object, a 'slot' one component instance). preview keys name a SUB-FIELD of this prop's config.fields and are checked against them (on a 'slot' the shape is checked but the keys cannot be \u2014 they belong to whichever component fills it). An unknown key inside `ui` is a 400, not a silent strip. Read it back with get_component: a prop whose `ui` is absent there was not stored."
2912
3222
  )
2913
3223
  });
@@ -3003,7 +3313,12 @@ function buildToolDefs(deps) {
3003
3313
  blockJson: z.array(blockObject).optional().describe("REPLACES the block tree"),
3004
3314
  props: z.array(componentPropObject).optional().describe(
3005
3315
  "REPLACES the prop array \u2014 include every prop you want to keep, WITH its stored `ui`, or that prop's authoring chrome is dropped along with the prop. Read get_component first."
3006
- )
3316
+ ),
3317
+ propUi: z.record(z.string(), z.record(z.string(), z.unknown()).nullable()).optional().describe(PROP_UI_ARG_NOTE),
3318
+ propGroup: z.record(z.string(), z.record(z.string(), z.unknown()).nullable()).optional().describe("prop key \u2192 its ui.group ({id, label?, kind?, icon?, collapsed?}), null takes it out"),
3319
+ propLabel: z.record(z.string(), z.string().min(1).max(255)).optional().describe("prop key \u2192 label"),
3320
+ override: z.boolean().optional().describe(OVERRIDE_NOTE),
3321
+ ifMatch: z.number().int().min(0).optional().describe(COMPONENT_IF_MATCH_NOTE)
3007
3322
  });
3008
3323
  const setComponentSourceInput = z.object({
3009
3324
  componentId: z.string().min(1).describe("component id (from list_components)"),
@@ -3530,7 +3845,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3530
3845
  def(
3531
3846
  "set_authoring_preference",
3532
3847
  "Set the site's authoring architecture",
3533
- "Record which authoring architecture this site uses \u2014 'components' (RECOMMENDED: reusable section components placed as blocks; editors add, reorder and swap sections without touching a schema \u2014 recommend it for every marketing, landing, agency or product site) or 'fields' (a typed field schema per page \u2014 only for a blog, catalogue or directory where many rows share one shape). ASK THE USER; do not pick for them. Called without `preference`, this tool asks them directly (or hands you the question to ask). deploy_project, deploy_from_upload and promote_project all refuse with 409 AUTHORING_DECISION_REQUIRED until it is set, and that refusal carries this project's real page counts to show the user. On a site whose pages were DERIVED at import, answering 'components' redoes nothing: after set_authoring_preference, get_componentize_plan proposes one component per section (it reads the PUBLISHED pages \u2014 no deploy needed to reach it), you confirm it with the user, then componentize_sections places them with `props.bind` (dry run first \u2014 they land as drafts and the page keeps its fields, values and bindings, so click-to-edit and the coverage meter are unchanged), then publish_component each one, publish the pages, run `npx @bettercms-ai/convert --componentize` in the repo, and deploy. Recommend components unless the user says the site is schema-first. Asked once per project; re-callable if the user changes their mind.",
3848
+ "Record which authoring architecture this site uses \u2014 'components' (RECOMMENDED: reusable section components placed as blocks; editors add, reorder and swap sections without touching a schema \u2014 recommend it for every marketing, landing, agency or product site) or 'fields' (a typed field schema per page \u2014 only for a blog, catalogue or directory where many rows share one shape). ASK THE USER; do not pick for them. Called without `preference`, this tool asks them directly (or hands you the question to ask). deploy_project, deploy_from_upload and promote_project all refuse with 409 AUTHORING_DECISION_REQUIRED until it is set, carrying this project's real page counts to show the user. Unset, the release lane already acts as `components`; only `fields` stops it. On a site whose pages were DERIVED at import, answering 'components' redoes nothing: after set_authoring_preference, get_componentize_plan proposes one component per section (it reads the PUBLISHED pages \u2014 no deploy needed to reach it), you confirm it with the user, then componentize_sections places them with `props.bind` (dry run first \u2014 they land as drafts and the page keeps its fields, values and bindings, so click-to-edit and the coverage meter are unchanged), then publish_component each one, publish the pages, run `npx @bettercms-ai/convert --componentize` in the repo, and deploy. Asked once per project; re-callable if the user changes their mind.",
3534
3849
  // Optional in the schema for exactly the reason `framework` is above: a required arg is
3535
3850
  // rejected by the SDK before the handler runs, which would kill the elicitation below
3536
3851
  // and leave the model guessing. Optional here, answered by a human there. The backend
@@ -3620,17 +3935,39 @@ ${d.outline}` : "Proposed Content structure.", d);
3620
3935
  def(
3621
3936
  "update_content_model",
3622
3937
  "Update a content model's metadata",
3623
- "Rename a content model, edit its description/slug/urlPattern, or set its `fieldsets`. Never touches `fields`: use add_field to extend the schema. Provide modelId plus what to change. `fieldsets` REPLACES the list (send null to drop them all); then put a field in one with add_field's `fieldsetId`. `urlPattern` is the collection's entry URL, e.g. '/blog/:slug' (null clears it). A slug already used in this project and branch is refused with 409 `slug_taken`.",
3624
- z.object({ modelId: z.string().min(1), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), urlPattern: urlPatternArg, fieldsets: fieldsetsArg.nullable(), ifMatch: z.number().int().min(0).optional().describe(MODEL_IF_MATCH_NOTE) }).shape,
3938
+ UPDATE_MODEL_DESCRIPTION,
3939
+ z.object({
3940
+ modelId: z.string().min(1).optional().describe("content model id (from list_content_models); or send pageId"),
3941
+ pageId: z.string().min(1).optional().describe("instead of modelId: the page whose schema model to edit; ifMatch is get_page backingModel.optimisticVersion"),
3942
+ name: z.string().optional(),
3943
+ slug: z.string().optional(),
3944
+ description: z.string().optional(),
3945
+ urlPattern: urlPatternArg,
3946
+ fieldsets: fieldsetsArg.nullable(),
3947
+ fieldUi: z.record(z.string(), z.record(z.string(), z.unknown()).nullable()).optional().describe(FIELD_UI_ARG_NOTE),
3948
+ fieldFieldset: z.record(z.string(), z.string().min(1).nullable()).optional().describe("path \u2192 fieldset id, null takes it out"),
3949
+ fieldOrder: z.strictObject({ parentPath: z.string().min(1).optional(), order: z.array(z.string().min(1)).min(1).max(200) }).optional().describe("the full new order of one parent's child keys (top level without parentPath)"),
3950
+ fieldLabel: z.record(z.string(), z.string().min(1).max(255)).optional().describe("path \u2192 label"),
3951
+ fieldHelpText: z.record(z.string(), z.string().max(500).nullable()).optional().describe("path \u2192 help text, null clears it"),
3952
+ override: z.boolean().optional().describe(OVERRIDE_NOTE),
3953
+ ifMatch: z.number().int().min(0).optional().describe(MODEL_IF_MATCH_NOTE)
3954
+ }).shape,
3625
3955
  // `fields` is deliberately not sent: the PATCH replaces `fields` wholesale when present.
3626
- async (c, a) => ok(
3627
- "Updated content model.",
3628
- (await c.fetchJSON(c.url(`/management/content/models/${s(a.modelId)}`), {
3629
- method: "PATCH",
3630
- body: JSON.stringify({ name: a.name, slug: a.slug, description: a.description, urlPattern: a.urlPattern, fieldsets: a.fieldsets }),
3631
- ...a.ifMatch !== void 0 ? { headers: { "if-match": `W/"${a.ifMatch}"` } } : {}
3632
- })).data
3633
- )
3956
+ async (c, a) => {
3957
+ if (Boolean(a.modelId) === Boolean(a.pageId)) return fail("Send exactly one of modelId or pageId.");
3958
+ const path = a.pageId ? `/management/pages/${s(a.pageId)}/model` : `/management/content/models/${s(a.modelId)}`;
3959
+ const body = Object.fromEntries(
3960
+ MODEL_UPDATE_KEYS.filter((k) => a[k] !== void 0).map((k) => [k, k === "fieldsets" ? dropProvenance(a[k]) : a[k]])
3961
+ );
3962
+ return ok(
3963
+ "Updated content model.",
3964
+ (await c.fetchJSON(c.url(path), {
3965
+ method: "PATCH",
3966
+ body: JSON.stringify(body),
3967
+ ...a.ifMatch !== void 0 ? { headers: { "if-match": `W/"${a.ifMatch}"` } } : {}
3968
+ })).data
3969
+ );
3970
+ }
3634
3971
  ),
3635
3972
  def(
3636
3973
  "get_content_types",
@@ -3640,6 +3977,25 @@ ${d.outline}` : "Proposed Content structure.", d);
3640
3977
  async (c) => ok("Content types.", await data(c, "GET", `/management/content/types`))
3641
3978
  ),
3642
3979
  // ── Pages (metadata edit; parity with remote /mcp) ──
3980
+ def(
3981
+ "remove_page_fields",
3982
+ "Remove fields from a page",
3983
+ "Delete page fields and their stored values (draft, published, every repeater row) in one transaction. Dry run by default: returns removedKeys, entriesWithContent, keysWithContent, liveCopyChanges and writes nothing. A field holding content needs discardContent:true (409 FIELDS_HOLD_CONTENT otherwise). keys: exact get_page keys, dotted for a group/repeater child (`features.icon`). Changing the live copy needs publish permission. Never use it to rename a field.",
3984
+ z.object({
3985
+ pageId: z.string().min(1).describe("page id (from list_pages)"),
3986
+ keys: z.array(z.string().min(1)).min(1).describe("field keys to remove"),
3987
+ dryRun: z.boolean().optional().describe("default true"),
3988
+ discardContent: z.boolean().optional().describe("delete fields that hold content, and the content")
3989
+ }).shape,
3990
+ // The whole body: a dry run answers with the report and no `data`.
3991
+ async (c, a) => {
3992
+ const res = await c.fetchJSON(c.url(`/management/pages/${s(a.pageId)}/fields/remove`), {
3993
+ method: "POST",
3994
+ body: JSON.stringify({ keys: a.keys, dryRun: a.dryRun, discardContent: a.discardContent })
3995
+ });
3996
+ return ok(res.dryRun ? "Dry run \u2014 nothing removed." : "Removed fields.", res);
3997
+ }
3998
+ ),
3643
3999
  def(
3644
4000
  "update_page",
3645
4001
  "Edit a page",
@@ -3811,7 +4167,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
3811
4167
  def(
3812
4168
  "get_binding_report",
3813
4169
  "Check what on the live site is editable",
3814
- "The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; per slot this returns `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them), what was bound, and `unmatched` \u2014 each path with its reason (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason`: `never-generated` \u2014 only a release BetterCMS builds and serves writes a report, so a HEADLESS site never gets one, redeploy or not (verify it by fetching it); `outdated` \u2014 the next release rewrites it. It certifies only that every non-empty field has SOME element carrying its path; it cannot see copy that was never modelled, so diff each route's visible text against its entry values before calling a page done. `unaddressable` is that measure: visible text on the live site no field owns, measured server-side on every inspected page each release, by route and bucket, biggest first. Over `count / visible > 0.02` is copy the CMS cannot see: fix it in the SOURCE (wrap the run in an element the codemod can bind, or declare `<BcmsField path=\u2026>`), deploy, re-read \u2014 playbook section 11. `builtRoutes` lists the routes the live build has HTML for (null = cannot say). `canvas.lane` is the editor's live-preview lane \u2014 `bridge`, `draft-route` or `none` (then get_next_steps carries the recipe). `skipReasons` says why pages were not inspected; `coverage.error` = `nothing-bound` means no element of this build carries a binding, so the percentage beside it describes a site this build is not.",
4170
+ "The receipt for 'is this site actually EDITABLE?'. Each release scans the built HTML for the element rendering each CMS field value; per slot this returns `mode` ('text-match' = guessed from rendered text, 'declared' = template-declared), what was bound, and `unmatched`: each path with its reason \u2014 not-declared, ambiguous-text, no-element, declared-only, needs-wrap, refused, layout-owned, page-owned, layout-locator-stale, locale-unvarianted, svg-unavailable, report-outdated. DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason`: `never-generated` \u2014 only a release BetterCMS builds and serves writes a report, so a HEADLESS site never gets one, redeploy or not; `outdated` \u2014 the next release rewrites it. It certifies only that every non-empty field has SOME element carrying its path. `unaddressable` is the copy it cannot see: visible live text no field owns, by route and bucket, biggest first (null = unmeasured: diff visible text against entry values). Over `count / visible > 0.02`, fix it in the SOURCE (wrap the run in an element the codemod can bind, or declare `<BcmsField path=\u2026>`), deploy, re-read \u2014 playbook \xA711. `builtRoutes`: routes the live build has HTML for (null = cannot say). `layoutIncomplete`: Layout paths the last content apply could not write (their publish waits); `layoutUnread`: Layout fields no route reads (`layoutUnreadJudged` false = not judged). `canvas.lane`: the editor's live-preview lane (`bridge`, `draft-route`; `none` \u2192 get_next_steps). `coverage.error` `nothing-bound`: no element of this build is bound, so its percentage describes another site.",
3815
4171
  z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read \u2014 everything here, `unaddressable` included, is measured PER SLOT, so on a promote-gated project pass 'current' to read the tree the editor frames. Defaults to the slot this project's releases land in. The response's `sha` is the build the report describes; `refreshRequired` is true when that is not the build this slot serves.") }).shape,
3816
4172
  async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
3817
4173
  ),
@@ -3853,18 +4209,19 @@ ${res.warnings.join("\n")}` : summary, res.data);
3853
4209
  def(
3854
4210
  "componentize_sections",
3855
4211
  "Turn this site's derived sections into components",
3856
- "Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections, how many components it will create and which pages it will rewrite; `dryRun` first. It creates each proposed component as a DRAFT and replaces each page's DRAFT blocks with ordered `component` instances, one per group, each carrying `props.bind: \"<groupKey>\"` \u2014 the page field group that already holds the copy. Nothing is copied or moved: the page keeps its `fields`, values and bindings, so click-to-edit and the coverage meter do not change (`copy: \"instance\"` moves the words onto each placement instead \u2014 see `copy`). Pass the plan's `digest`; a 409 `stale-plan` means the site changed: read the plan again, show the user what changed and confirm again. Read `components.wouldDuplicate` before applying: each id is an existing same-family component this run would sit a NEW one beside, because reuse is by identity and slug, never by family. Re-running is safe: an already-placed group returns in `sections.pending` as ALREADY_COMPONENTIZED, with no second component. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages, then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. Every component it CREATES is filed into a Component Group (see `group`). When the user says a proposed group is NOT a section, pass `decline: true` with those `sections` (and `pageIds`): nothing is componentized, the plan reports them `NOT_A_SECTION` / `DECLINED` and stops proposing them, and a plan that proposes nothing undeclined is done; `decline: false` offers them again.",
4212
+ "Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections, how many components it will create and which pages it will rewrite; `dryRun` first. It creates each proposed component as a DRAFT and replaces each page's DRAFT blocks with ordered `component` instances, one per group, each carrying `props.bind: \"<groupKey>\"` \u2014 the page field group that already holds the copy. Nothing is copied or moved: the page keeps its `fields`, values and bindings, so click-to-edit and the coverage meter do not change (`copy: \"instance\"` moves the words onto each placement instead \u2014 see `copy`). Pass the plan's `digest`; a 409 `stale-plan` means the site changed: read the plan again, show the user what changed and confirm again. Read `components.wouldDuplicate` first: each id is an existing same-family component a NEW one would sit beside (reuse is by identity and slug, never family). Re-running is safe: a placed group returns as ALREADY_COMPONENTIZED. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages, then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. Every component it CREATES is filed into a Component Group (see `group`). When the user says a proposed group is NOT a section, pass `decline: true` with those `sections` (and `pageIds`): nothing is componentized, the plan reports them `NOT_A_SECTION` / `DECLINED` and stops proposing them, and a plan that proposes nothing undeclined is done.",
3857
4213
  z.object({
3858
4214
  digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
3859
4215
  pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
3860
4216
  pages: z.array(z.string().min(1)).optional().describe("The same page filter under the name the dashboard uses. Unioned with pageIds; an EMPTY list is refused rather than treated as every page."),
3861
- sections: z.array(z.string().min(1)).optional().describe("Act only on these section groupKeys (from the plan). Every other section on the page keeps the placement it already has."),
3862
- copy: z.enum(["bind", "instance"]).optional().describe("Who owns each section's copy. 'bind' (default) leaves it in the page fields. 'instance' is the page-builder model: each placement takes that group's CURRENT draft values into its own `props.overrides` (a repeater becomes one table prop holding every row), records which page path each came from in `props.source`, and drops `props.bind`; the page's fields are KEPT and marked `origin: \"componentized\"`, so nothing is lost and the editor stops showing a second place to type the same words."),
4217
+ sections: z.array(z.string().min(1)).optional().describe("Act only on these section groupKeys (from the plan); every other section keeps its placement."),
4218
+ copy: z.enum(["bind", "instance"]).optional().describe("Who owns each section's copy. 'bind' (default) leaves it in the page fields. 'instance' is the page-builder model: each placement takes that group's CURRENT draft values into its own `props.overrides` (a repeater becomes one table prop holding every row), records which page path each came from in `props.source`, and drops `props.bind`; the page's fields are KEPT and marked `origin: \"componentized\"`."),
3863
4219
  dryRun: z.boolean().optional().describe("true = return the receipt without writing anything. Do this first."),
3864
4220
  group: z.string().min(1).max(100).optional().describe("File the components this run creates into this Component Group (a folder of the dashboard's Components tab; by NAME, created when missing), else its section family: nav, header, footer or menu \u2192 Layout, an unnamed section \u2192 Sections, otherwise the family name (Hero, FAQ). A reused component keeps its Group; the receipt's `components.groups` lists the Groups filed into."),
3865
- decline: z.boolean().optional().describe("true = record `sections` as NOT sections (nothing is componentized; the plan stops proposing them). false = offer them again. Needs `sections`.")
4221
+ decline: z.boolean().optional().describe("true = record `sections` as NOT sections (nothing is componentized; the plan stops proposing them). false = offer them again. Needs `sections`."),
4222
+ joinTwins: z.boolean().optional().describe("true = move each locale copy (`/fr`) still on its own component onto its twin's (`bindPaths` keeps its fields). A dry run lists each in `joins`. Whole plan only.")
3866
4223
  }).shape,
3867
- async (c, a) => ok("Componentized the sections.", await data(c, "POST", `/management/projects/current/componentize`, { digest: a.digest, pageIds: a.pageIds, pages: a.pages, sections: a.sections, copy: a.copy, dryRun: a.dryRun, group: a.group, decline: a.decline }))
4224
+ async (c, a) => ok("Componentized the sections.", await data(c, "POST", `/management/projects/current/componentize`, { digest: a.digest, pageIds: a.pageIds, pages: a.pages, sections: a.sections, copy: a.copy, dryRun: a.dryRun, group: a.group, decline: a.decline, joinTwins: a.joinTwins }))
3868
4225
  ),
3869
4226
  def(
3870
4227
  "get_analytics_overview",
@@ -4310,7 +4667,7 @@ ${notes.join("\n")}` : summary, created);
4310
4667
  ...args.description !== void 0 ? { description: args.description } : {},
4311
4668
  ...args.urlPattern !== void 0 ? { urlPattern: args.urlPattern } : {},
4312
4669
  ...args.kind !== void 0 ? { kind: args.kind } : {},
4313
- ...args.fieldsets !== void 0 ? { fieldsets: args.fieldsets } : {},
4670
+ ...args.fieldsets !== void 0 ? { fieldsets: dropProvenance(args.fieldsets) } : {},
4314
4671
  fields: toFields(args.fields)
4315
4672
  });
4316
4673
  return ok(
@@ -4667,7 +5024,7 @@ ${notes.join("\n")}` : summary, created);
4667
5024
  name: "publish_layout",
4668
5025
  config: {
4669
5026
  title: "Publish the Global Layout draft",
4670
- description: "Publish the connected project's GLOBAL Layout draft (navigation, footer, every reserved section) so the live site builds from it. Until this is called the layout stays draft and get_layout copy:'published' answers PUBLISHED_LAYOUT_UNAVAILABLE \u2014 site chrome authored with update_layout is NOT live. Read get_layout first and pass its revision as ifMatch; a stale revision returns 409 \u2014 re-read, never retry blindly. A 422 lists validation issues to fix with update_layout first. A new project's chrome is draft: publish_component navigation-default and footer-default first (each can publish while the other is draft), then publish_layout. navigation.logo is a REQUIRED image \u2014 new projects get a generated one; if it is empty (required_value) or not a stored asset (image_asset_missing), upload one and set it with update_layout as { id, url, name, altText }. Verify with get_layout copy:'published' and check the copy echo \u2014 this tool's own response is the write's echo, not a receipt. Page overrides go live with the page (update_page status:'published'). A 403 PUBLISH_NOT_GRANTED means this connection can author drafts but cannot publish \u2014 say so and let the user allow publishing or publish from the dashboard.",
5027
+ description: "Publish the connected project's GLOBAL Layout draft (navigation, footer, every reserved section). On a site BetterCMS serves, a Layout publish never builds: it writes the VALUES into the live pages, and a STRUCTURE change (Sections added, removed, moved) waits for a Rebuild or a push (`layout-rebuild-required`); a custom host gets its deploy hook. Until published, get_layout copy:'published' answers PUBLISHED_LAYOUT_UNAVAILABLE. Pass get_layout's `revision` as ifMatch; a stale one returns 409 \u2014 re-read, never retry blindly. A 422 lists validation issues (global, or per page override: pageSlug, copy) to fix with update_layout. A new project's chrome is draft: publish_component navigation-default and footer-default first (each independently). navigation.logo is optional: empty, the nav shows the brand mark (set_brand_assets) or the site name. Never add a logo the site does not have. Verify with get_layout copy:'published' \u2014 this response is the write's echo, not a receipt. Page Layout overrides go live with the page (update_page status:'published'). 403 PUBLISH_NOT_GRANTED: this connection may author drafts, not publish \u2014 say so; the user allows it or publishes from the dashboard.",
4671
5028
  inputSchema: publishLayoutInput.shape
4672
5029
  },
4673
5030
  handler: guard(async (args) => withClient(async (client) => {
@@ -4907,8 +5264,8 @@ ${lines.join("\n")}`, found);
4907
5264
  },
4908
5265
  handler: guard(
4909
5266
  async (args) => withClient(async (client) => {
4910
- const { componentId, ...input } = args;
4911
- const cmp = await client.updateComponent(componentId, input);
5267
+ const { componentId, ifMatch, ...input } = args;
5268
+ const cmp = await client.updateComponent(componentId, input, { ifMatch });
4912
5269
  return ok(`Updated component '${cmp.name}' (id ${cmp.id}).`, cmp);
4913
5270
  })
4914
5271
  )
@@ -5370,6 +5727,12 @@ AUTHORING_DECISION_REQUIRED** until a human has chosen this project's architectu
5370
5727
  asked ONCE per project, ever. It is not an error to retry or route around: read the message
5371
5728
  out, let the user pick, call \`set_authoring_preference\`, then deploy again.
5372
5729
 
5730
+ Until someone answers, the release lane behaves as \`components\`: the preference is null, and
5731
+ each release still componentizes the derived pages in bind mode. \`fields\` is the only answer
5732
+ that stops it. And a run over the WHOLE plan splits a section component an earlier run placed on
5733
+ 2 pages into one per page (the first page keeps it, the others get a clone), so two pages' heroes
5734
+ stop editing each other; a run narrowed to one page or section never splits.
5735
+
5373
5736
  It exists because an imported site arrives **field-driven whether anyone chose that or not**
5374
5737
  \u2014 a crawl-based import (Webflow, a starter, a template) emits pages with a typed field schema
5375
5738
  and an empty block tree, because that is all a crawl can infer. Nobody decided it. On a
@@ -5385,11 +5748,14 @@ component placement, and the page keeps its fields, its values and its bindings.
5385
5748
  2. get_componentize_plan one component per section, computed live; creates nothing. It
5386
5749
  reads this project's PUBLISHED pages, so no deploy is needed
5387
5750
  to reach it \u2014 the deploy is the last step, not the first.
5388
- On a site DERIVED at import the release hook has already run
5389
- this plan in bind mode and PUBLISHED what it created, so
5390
- expect ALREADY_COMPONENTIZED and skip to step 6 (publish the
5391
- pages) \u2014 the lane publishes the COMPONENTS, not the placements,
5392
- which are still draft blocks on the pages
5751
+ On a site DERIVED at import, each release after this answer
5752
+ runs this plan in bind mode for NEW sections (it never splits
5753
+ or joins a placement, and runs nothing while the answer is
5754
+ unset) and PUBLISHES what it created, so after one expect
5755
+ ALREADY_COMPONENTIZED and skip to step 6 (publish the pages) \u2014
5756
+ the lane publishes the COMPONENTS, not the placements, which
5757
+ are still draft blocks on the pages. A /fr still on its own
5758
+ components joins its twin's with \`joinTwins: true\` (step 4)
5393
5759
  3. show the user the plan and CONFIRM \u2014 it says how many components and which pages
5394
5760
  4. componentize_sections \`dryRun: true\` first, then for real; components land as DRAFTS
5395
5761
  and each placement carries \`props.bind\` to the page's field
@@ -5502,7 +5868,7 @@ renders. Three consequences, each load-bearing:
5502
5868
  Binding only one copy leaves the others showing the old text until the next rebuild.
5503
5869
  3. Make the chrome ITSELF editable by speaking the layout grammar: the nav/footer
5504
5870
  elements declare \`data-bcms-layout-section="navigation"\` / \`"footer"\`, and each
5505
- CMS-backed text inside them a \`data-bcms-layout-field="layout:<sectionId>:<fieldId>"\`
5871
+ CMS-backed text inside them a \`data-bcms-layout-field="layout:<sections[slug].id>:<fieldId>"\`
5506
5872
  marker (fieldId is the field's REAL id, verbatim \u2014 production layout ids are
5507
5873
  section-prefixed and dotted, e.g. \`footer.tagline\`, so the marker reads
5508
5874
  \`layout:footer:footer.tagline\`) \u2014 the canvas then gives them hover chrome, the
@@ -5512,18 +5878,31 @@ renders. Three consequences, each load-bearing:
5512
5878
  sub appends its slug (\`layout:navigation:navigation.cta.label\`), a repeater row its
5513
5879
  STORED index then the slug (\`layout:navigation:navigation.links.0.label\`), nesting
5514
5880
  as deep as the schema goes (\`layout:footer:footer.link-groups.0.links.1.label\`).
5515
- Three rules, each one a measured defect when broken:
5881
+ Five rules, each one a measured defect when broken:
5516
5882
  a. a field rendered by TWO elements gets a marker on BOTH \u2014 the editor keeps the copies
5517
5883
  in sync, and a marker on only one leaves the other stale;
5518
5884
  b. PROVENANCE \u2014 mark an element only when the LAYOUT supplied its value; a marker
5519
5885
  over a fallback/singleton-sourced string opens an editor for a row that does not
5520
5886
  exist;
5521
5887
  c. row markers use the value's STORED index (a row you filtered out of the render
5522
- still occupies its slot), or the edit lands on the wrong row.
5888
+ still occupies its slot), or the edit lands on the wrong row;
5889
+ d. the address names the DELIVERED entry's id, \`sections[slug].id\`, never the slug \u2014 a
5890
+ Section created in the dashboard has a UUID id. On a translated route address the
5891
+ entry that fills the slot, by ITS own id: a page's own \`layout\` already delivers it
5892
+ at \`sections['navigation']\`, the Global Layout carries it beside the base at
5893
+ \`sections['navigation--fr']\`; the \`data-bcms-layout-section\` wrapper still names
5894
+ the SLOT, the base slug (\`navigation\`);
5895
+ e. ONE OWNER PER ELEMENT: never a \`data-bcms-field\` inside a Layout-declared region.
5896
+ The report will not flag one you write: it binds, and the element gets two writers.
5897
+ \`layout-owned\` names only the page copies left with no element, which the strip removes.
5523
5898
  Text/longtext leaves edit inline; link/select/image leaves are side-panel-only by
5524
5899
  design (their formats need a real control). THE DOCTRINE: every string a marketer
5525
5900
  can see must be addressable \u2014 no marker means read-only on the canvas, so an
5526
5901
  unmarked CMS-backed string is a defect, not a style choice.
5902
+ On a site BetterCMS serves, a Layout publish never builds: it writes the new VALUES into
5903
+ the live pages, and a STRUCTURE change (a Section added, removed or moved) waits for a
5904
+ Rebuild or a push \u2014 get_next_steps says \`layout-rebuild-required\` until a build ships
5905
+ it. A custom host gets its deploy hook instead.
5527
5906
  4. Keep chrome semantic \u2014 \`<nav>\`, \`<footer>\`, page content inside \`<main>\`, mastheads
5528
5907
  as a top-level \`<header>\`. Chrome is edited through the project Layout, not the page,
5529
5908
  and semantic landmarks are how the editor keeps a nav edit from being written into page
@@ -5571,7 +5950,7 @@ below is one heading of that ask.
5571
5950
  **1. Global elements.** Navbar, mobile nav, header and footer are the LAYOUT, not page content:
5572
5951
  \`get_layout\` then \`update_layout\`, and a chrome COMPONENT is placed into a Layout section with
5573
5952
  the \`add-component\` command. CTA buttons are \`button\` BLOCKS inside the section that uses them,
5574
- exposed as \`text\` + \`url\` props \u2014 variant, size and icon are that block's own props and
5953
+ exposed as \`text\` + \`url\` props \u2014 variant, size and fullWidth are that block's own props and
5575
5954
  \`style\`, never a standalone Button component. Rich text is a \`richtext\` prop over a
5576
5955
  \`richtext\`/\`text\` block. Forms are \`create_form\` plus a \`form\` block, and form FIELDS are the
5577
5956
  one place the platform models \`required\`, \`placeholder\`, \`helpText\` and real \`validation\`
@@ -5590,8 +5969,8 @@ still gets its own component: its own family, plus \`allowedOn: ["slug:<page>"]\
5590
5969
  with NO \`sectionType\` never appears in the editor's "Add a section" picker. Start from the
5591
5970
  builtin blueprints \`list_components\` returns (\`builtin:*\`) instead of hand-writing block JSON.
5592
5971
  On a DERIVED site run \`get_componentize_plan\` \u2192 \`componentize_sections\` FIRST \u2014 it does most
5593
- of this in one call, and on a site derived at IMPORT the release hook has already run it in bind
5594
- mode and published the components, so expect ALREADY_COMPONENTIZED \u2014 the placements it wrote are
5972
+ of this in one call, and once \`components\` is recorded each release of a site derived at IMPORT
5973
+ runs it in bind mode and publishes the components, so expect ALREADY_COMPONENTIZED \u2014 the placements it wrote are
5595
5974
  still DRAFT blocks on the pages, so publish the pages, then \`npx @bettercms-ai/convert
5596
5975
  --componentize\`.
5597
5976
 
@@ -5889,7 +6268,7 @@ delivered value \u2014 they change editor chrome only.
5889
6268
  \`\`\`jsonc
5890
6269
  { "key": "testimonials", "label": "Testimonials", "type": "repeater",
5891
6270
  "ui": { "collapsed": true, "preview": { "title": "author", "media": "avatar" },
5892
- "layout": "grid", "reorderable": true },
6271
+ "layout": "grid", "icon": "double-quote", "itemLabel": "Testimonial" },
5893
6272
  "helpText": "Three to five. Shortest quotes read best.",
5894
6273
  "fields": [ { "key": "author", ... }, { "key": "avatar", "type": "image", ... }, ... ] }
5895
6274
  \`\`\`
@@ -5919,6 +6298,11 @@ delivered value \u2014 they change editor chrome only.
5919
6298
  \`select\` with a few short options (alignment, theme, size: one click instead of a dropdown);
5920
6299
  \`'slider'\` on a \`number\` with \`config.min\` < \`config.max\` (opacity, columns). Anywhere else it
5921
6300
  is refused with the rule named.
6301
+ - **\`ui.icon\`** \u2014 the branch row's icon: ONE name from \`bettercms://icons/dock\` (read that resource;
6302
+ any other string is a 400). Nesting fields, fieldsets and a prop's \`ui.group\` only. Layout
6303
+ section icons are a different vocabulary (Lucide names), so a name valid there may be refused here.
6304
+ - **\`ui.itemLabel\`** \u2014 what ONE item of a list is called ("Slide", "Person"), at most 24 characters:
6305
+ the add button, the crumbs and the empty state say it. Nesting fields only.
5922
6306
 
5923
6307
  **Six refusals, all deliberate, all 400 with the fix in the message.**
5924
6308
  1. An unknown key inside \`ui\` is REJECTED, not stripped. \`ui\` is a strict object precisely so
@@ -5942,7 +6326,10 @@ only prop whose value is a list (a \`group\` is one object, a \`slot\` one compo
5942
6326
  prop's \`preview\` keys name a SUB-FIELD from its own \`config.fields\` and are checked against
5943
6327
  them \u2014 except on a \`slot\`, whose children belong to whichever component fills it and therefore
5944
6328
  cannot be resolved at save time. \`update_component\` REPLACES the whole \`props\` array, so echo
5945
- back each prop's stored \`ui\` or you delete it along with the prop.
6329
+ back each prop's stored \`ui\` or you delete it along with the prop. Props have no fieldsets, so a
6330
+ prop joins a group with \`ui.group\`: the FIRST prop of a group defines \`{ id, label, kind?, icon?,
6331
+ collapsed? }\` (kind \`group\` or \`row\`, never \`tab\`), and every later prop sends \`{ id }\` only. A
6332
+ FIELD never takes \`ui.group\`; it joins a fieldset with \`fieldsetId\`.
5946
6333
 
5947
6334
  \`\`\`jsonc
5948
6335
  { "key": "slides", "label": "Slides", "type": "table",
@@ -5952,11 +6339,92 @@ back each prop's stored \`ui\` or you delete it along with the prop.
5952
6339
  { "key": "caption", "type": "text", ... } ] } }
5953
6340
  \`\`\`
5954
6341
 
6342
+ **Patterns \u2014 what the dock draws, and the key that asks for it.**
6343
+ - **leaf** \u2014 one field, one control. Nothing to declare; \`ui.control\` only when the default is wrong.
6344
+ - **group** \u2014 a \`kind: 'group'\` fieldset (a prop: \`ui.group\`): a disclosure that shows two values
6345
+ when closed. \`collapsed: true\` starts it closed.
6346
+ - **row** \u2014 a \`kind: 'row'\` fieldset: at most 3 short scalars (number, color, boolean, date,
6347
+ datetime, select) on one line. No \`ui.control\` on a row member.
6348
+ - **branch** \u2014 a nesting field drawn as a drill-in row with its \`ui.icon\` and a summary.
6349
+ - **list** \u2014 repeatable items named by \`ui.itemLabel\` and \`ui.preview\`.
6350
+ - **grid** \u2014 \`layout: 'grid'\`, media-led: the field needs an image or file child for \`preview.media\`.
6351
+ - **table** \u2014 \`layout: 'table'\`: at most 3 scalar columns (text, number, select, boolean, date,
6352
+ datetime, slug, email).
6353
+ - **section header** \u2014 a fieldset's \`name\` and \`description\` over its fields.
6354
+ - **conditional** \u2014 \`showIf\`, placed AFTER the field it reads.
6355
+
6356
+ **Budgets, per dock screen.** \`get_next_steps\` measures them (\`dock-budget\`):
6357
+ - at most **8 ungrouped leaves** at rest (the pinned fields plus any one tab);
6358
+ - at most **12 leaves in a group**, and branches at most **3 levels** deep;
6359
+ - **any value within 2 activations** of its section screen. A collapsed group, a non-first tab and a
6360
+ branch each cost 1, so a group inside tab 2 already costs 2: leave groups in non-first tabs open.
6361
+ Importance orders a screen: required fields first, then array order.
6362
+
6363
+ **Recipes \u2014 named key presets, never code.** Pick the one the section is; adapt the child keys.
6364
+
6365
+ | Recipe | Keys |
6366
+ |---|---|
6367
+ | Gallery | \`layout:'grid'\`, \`preview:{media:'image', title:'caption'}\`, \`itemLabel:'Image'\`, \`icon:'images'\` |
6368
+ | Logo wall | \`layout:'grid'\`, \`preview:{media:'logo', title:'name'}\`, \`itemLabel:'Logo'\`, \`icon:'image'\` |
6369
+ | Team | \`layout:'grid'\`, \`preview:{media:'photo', title:'name', subtitle:'role'}\`, \`itemLabel:'Person'\`, \`icon:'users'\` |
6370
+ | Testimonials | \`preview:{title:'author', subtitle:'role', media:'avatar'}\`, \`itemLabel:'Testimonial'\`, \`icon:'double-quote'\` |
6371
+ | Steps | \`preview:{title:'title'}\`, \`itemLabel:'Step'\`, \`icon:'olist'\`; \`reorderable:false\` only on Tier-1 evidence |
6372
+ | Pricing tiers | \`preview:{title:'name', subtitle:'price'}\`, \`itemLabel:'Tier'\`, \`icon:'tiers'\` |
6373
+ | FAQ | \`preview:{title:'question'}\`, \`itemLabel:'Question'\`, \`icon:'help-circle'\` |
6374
+ | Nav links | \`preview:{title:'label', subtitle:'href'}\`, \`itemLabel:'Link'\`, \`icon:'link'\` (never a table: a label and its href are one link) |
6375
+ | Stats | \`layout:'table'\` over \u22643 scalar columns (value, label, suffix), \`itemLabel:'Stat'\`, \`icon:'bar-chart'\` |
6376
+ | CTA | one \`kind:'group'\` fieldset holding the heading, text, button label AND href (a button's pair shares one fieldset), \`icon:'launch'\` |
6377
+ | Dimensions | one \`kind:'row'\` fieldset of up to 3 numbers (width, height, gap) |
6378
+
6379
+ **Tabs.** A \`kind: 'tab'\` fieldset is a tab:
6380
+ - at most **3** tabs, names at most 20 characters and unique;
6381
+ - top-level fields only: tabs never nest and props never take them;
6382
+ - untabbed fields stay PINNED above the strip;
6383
+ - a group or row fieldset joins a tab with \`tab: '<tab id>'\`; groups in a non-first tab default open;
6384
+ - with fewer than 2 non-empty tabs no strip renders, so a tab for one fieldset is noise.
6385
+
6386
+ \`\`\`jsonc
6387
+ "fieldsets": [
6388
+ { "id": "content", "name": "Content", "kind": "tab" },
6389
+ { "id": "style", "name": "Style", "kind": "tab" },
6390
+ { "id": "cta", "name": "Button", "kind": "group", "tab": "content", "icon": "launch" },
6391
+ { "id": "size", "name": "Size", "kind": "row", "tab": "style" }
6392
+ ]
6393
+ \`\`\`
6394
+
6395
+ **The write path \u2014 existing fields, by path, with If-Match.**
6396
+ - A model's fields: \`update_content_model\` with \`fieldUi\`, \`fieldFieldset\`, \`fieldOrder\`,
6397
+ \`fieldLabel\`, \`fieldHelpText\` (keyed by dotted key path, \`'hero.title'\`) and \`fieldsets\`. A page's
6398
+ schema: the same call with \`pageId\` instead of \`modelId\`, and the version from \`get_page\`'s
6399
+ \`backingModel\`.
6400
+ - A component's props: \`update_component\` with \`propUi\`, \`propGroup\`, \`propLabel\` (keyed by prop key).
6401
+ They write the DRAFT props; no publish is needed for the editor to show them.
6402
+ - Always send \`ifMatch\`, the \`optimisticVersion\` you read.
6403
+ - A NEW field carries its chrome in the same \`add_field\` / \`add_page_field\` call.
6404
+
6405
+ **The dock's refusals, each named in the message.**
6406
+ - **428** \`precondition-required\` \u2014 a chrome key without \`ifMatch\`. Read, then send it.
6407
+ - **409** \`version-conflict\` \u2014 someone wrote since your read. Read again, re-apply; never retry blindly.
6408
+ - **409** \`ui_human_owned\` \u2014 a person shaped those paths. Leave them, or send \`override: true\`, and
6409
+ only when the user said so.
6410
+ - \`tab_unknown\`, \`tab_nested\`, \`tab_props\`, \`tab_max\`, \`tab_name\`, \`tab_depth\` \u2014 the tab rules above.
6411
+ - \`fieldset_unknown\` / \`fieldset_orphan\` \u2014 a \`fieldsetId\` naming no fieldset, or a list that drops a
6412
+ fieldset its fields still name.
6413
+ - \`row_member_type\`, \`row_member_count\`, \`row_member_control\` \u2014 the row rule.
6414
+ - \`pair_split\` \u2014 a button's label and href in different fieldsets.
6415
+ - \`ui.icon\` or \`ui.itemLabel\` on a leaf; an icon name not in \`bettercms://icons/dock\`.
6416
+ - \`layout_grid\` \u2014 a grid with no image or file child. \`layout_table\` \u2014 more than 3 columns, or a
6417
+ column that is not a scalar.
6418
+ - \`prop_group_unknown\` / \`prop_group_redefined\` \u2014 the \`ui.group\` define-once rule.
6419
+ - \`field_path_unknown\` / \`field_path_ambiguous\` / \`field_order_not_permutation\` \u2014 a path that names
6420
+ no field or two, or an order that is not exactly the parent's keys.
6421
+
5955
6422
  **THE RECEIPT \u2014 read it back.** There is no separate verification tool and no mode to flip.
5956
6423
  \`get_content_model\` (and \`get_page\` / \`get_component\`) return each field's \u2014 and each component
5957
6424
  prop's \u2014 stored \`ui\`, \`helpText\` and \`showIf\`. A prop that is ABSENT from that response was not
5958
- stored, whatever the write returned \u2014 \xA711 RECEIPTS rule 3, applied to this feature. Declare, then read
5959
- back, then say it works.
6425
+ stored, whatever the write returned \u2014 \xA711 RECEIPTS rule 3, applied to this feature. For a page, read
6426
+ \`get_page\` and its \`backingModel\`. Then call \`get_next_steps\`: \`dock-budget\`, \`dock-repeater-unnamed\`
6427
+ and \`dock-showif-order\` must all be gone. Declare, then read back, then say it works.
5960
6428
 
5961
6429
  **No \`order\` property. Ever.** A field's order IS its index in \`fields\`. To put a new field
5962
6430
  somewhere other than the end, pass \`after: '<existing top-level field key>'\` to \`add_field\` /
@@ -6032,6 +6500,35 @@ Do not stop at the plan or at a partial receipt \u2014 finish the whole site, ne
6032
6500
  reads mode "declared", unmatched 0, bound above 0, \`coverage.pending\` empty. Send me that report.
6033
6501
  10. Ask me whether to commit and push to my repository, commit only, or leave it uncommitted (unpushed, the
6034
6502
  next rebuild from my repo drops it). Touch git only as I answer.`;
6503
+ var FIELD_UI_PROMPT = {
6504
+ title: "Declare how fields LOOK in the editor (guided)",
6505
+ description: "Set the editor chrome of an existing schema and its component props: groups, tabs, rows, icons, item names, what titles and thumbnails a list's rows, help text, conditional visibility. Derives it from your source, then from the field shapes, and asks only what it cannot know.",
6506
+ request: "scope, e.g. 'the Team and Testimonials models' or 'the whole project'"
6507
+ };
6508
+ var FIELD_UI_TEXT = (request) => `Declare the editor UI for my BetterCMS fields and component props.${request ? ` Scope: "${request}".` : ""}
6509
+
6510
+ Read bettercms://playbook/schema section 14 first: the vocabulary, patterns, budgets, recipes, the
6511
+ tabs rule, every refusal, and the three tiers that decide each value (my source, then the field
6512
+ shape, then ask me, once and batched). Icon names are in bettercms://icons/dock.
6513
+
6514
+ **Order of work**
6515
+ 1. \`get_next_steps\`: its dock-budget, dock-repeater-unnamed and dock-showif-order steps name the
6516
+ screens to fix. Read each target with \`get_content_model\`, \`get_page\` (its \`backingModel\`) or
6517
+ \`get_component\`; note its \`optimisticVersion\` and the chrome a person already set.
6518
+ 2. Decide each field's chrome by the \xA714 tiers and write down which tier answered.
6519
+ 3. Apply one write per model or component, always with \`ifMatch\` (428 without it; 409
6520
+ version-conflict when stale: read again and re-apply, never retry blindly):
6521
+ - fields that exist: \`update_content_model\` fieldUi / fieldFieldset / fieldOrder / fieldLabel /
6522
+ fieldHelpText by field path, plus \`fieldsets\`. A singleton page: send \`pageId\` instead of
6523
+ modelId, with its backingModel's version;
6524
+ - component props: \`update_component\` propUi / propGroup / propLabel;
6525
+ - a NEW field: ui / helpText / showIf in the same \`add_field\` / \`add_page_field\` call, with \`after\`.
6526
+ A 409 \`ui_human_owned\` names chrome a person set: leave it, or ask me and send \`override: true\`
6527
+ only on my word.
6528
+ 4. **RECEIPT, non-negotiable.** Re-read every target and confirm the stored chrome matches what you
6529
+ sent: a key ABSENT from the read was not stored, whatever the write said. Call \`get_next_steps\`
6530
+ until no dock-* step is left. Report per field the tier that decided it, what you set, and that
6531
+ the read-back confirmed it; say plainly what you could not confirm.`;
6035
6532
  var SCHEMA_PROPOSAL_FLOW = `### Whole-project design (confirm-first) \u2192 \`create_component\` / \`create_page\` / \`create_content_model\`
6036
6533
  Design the WHOLE project from its brief or its code, and **confirm the shape with the user
6037
6534
  BEFORE creating anything**. Never silently guess.
@@ -6576,104 +7073,12 @@ On 401/403, the MCP key needs (re)authorizing.`
6576
7073
  server.registerPrompt(
6577
7074
  "field-ui",
6578
7075
  {
6579
- title: "Declare how fields LOOK in the editor (guided)",
6580
- description: "Set the authoring chrome on an existing schema \u2014 collapsed panels, what titles and thumbnails a repeater's rows, help text, conditional visibility. Derives it from your site's source where there is source, from the field shapes where there isn't, and only asks you about the three things it genuinely cannot know.",
6581
- argsSchema: {
6582
- request: z2.string().optional().describe("scope, e.g. 'the Team and Testimonials models' or 'the whole project'")
6583
- }
7076
+ title: FIELD_UI_PROMPT.title,
7077
+ description: FIELD_UI_PROMPT.description,
7078
+ argsSchema: { request: z2.string().optional().describe(FIELD_UI_PROMPT.request) }
6584
7079
  },
6585
7080
  ({ request }) => ({
6586
- messages: [
6587
- {
6588
- role: "user",
6589
- content: {
6590
- type: "text",
6591
- text: `Declare the editor UI for my BetterCMS fields.${request ? ` Scope: "${request}".` : ""}
6592
-
6593
- Read bettercms://playbook/schema section 14 first \u2014 it has the exact prop shapes and the refusals.
6594
-
6595
- **What you are setting** (all of these ride on any field, on create_content_model / create_page /
6596
- add_field / add_page_field; none of them affect delivery):
6597
- ui.collapsed start a nesting field's panel closed
6598
- ui.preview {title,subtitle,media} which CHILD FIELD KEY titles a collapsed row, its second line, its thumb
6599
- ui.layout 'list'|'grid'|'table' how its items are arranged; table is for repeatable row schemas; omit for list
6600
- ui.reorderable omit (= true) unless the ORDER IS THE MEANING; false LOCKS the list
6601
- ui.control 'segmented'|'slider' a LEAF's control: segmented on a select, slider on a number with config.min < max
6602
- helpText one line under the field: what to WRITE here
6603
- showIf hide a field until another field has a value
6604
-
6605
- **The same \`ui\` rides on COMPONENT PROPS** \u2014 create_component / update_component, one per entry
6606
- of \`props\`. Placement differs because the vocabulary does: preview/layout on 'group', 'table'
6607
- and 'slot'; reorderable on 'table' ALONE (the only prop whose value is a list). A prop's preview
6608
- keys name a SUB-FIELD from its own config.fields. If this scope includes components, do them in
6609
- the same pass \u2014 but note update_component REPLACES the whole props array, so read get_component
6610
- first and echo every prop back WITH its stored ui.
6611
-
6612
- **Work the three tiers in order. Stop at the first that answers \u2014 do not escalate what a tier
6613
- below already settled.**
6614
-
6615
- TIER 1 \u2014 DERIVE FROM SOURCE, if and only if you have a checkout of the site.
6616
- Read the component that renders each field. \`<img src={item.photo}>\` next to
6617
- \`<h3>{item.name}</h3>\` in the map over a list IS the answer: preview {title:'name',
6618
- media:'photo'}. A block inside \`<details>\` or behind a "Show more" is collapsed:true.
6619
- A \`grid-cols-*\` wrapper around the map is layout:'grid'. A FIXED-ARITY render \u2014
6620
- steps[0]/steps[1]/steps[2], or copy that names positions ("Step 1", "finally") \u2014 is
6621
- reorderable:false, and it is the ONLY evidence that earns that key.
6622
- \u{1F534} THE SERVER NEVER READS YOUR SOURCE. \`pull_project_source\` / \`get_conversion_brief\` hand
6623
- the repo to YOU; add_field does not look at it. This tier is your own reading, in your own
6624
- checkout, before you call any tool. ON A GREENFIELD SCHEMA-FIRST PROJECT THERE IS NO SOURCE \u2014
6625
- Tier 1 does not apply and TIER 2 IS THE FLOOR. Do not stall waiting for code that will
6626
- never exist.
6627
-
6628
- TIER 2 \u2014 DERIVE FROM THE FIELD SHAPE, when the code is silent or absent.
6629
- One image-ish child in a repeater \u2192 that is \`media\`. The first required short-text child
6630
- (name/title/heading/label) \u2192 that is \`title\`. More than ~6 children, or a label that reads
6631
- optional ("Advanced", "Extras") \u2192 collapsed:true. A repeatable whose item is mostly its
6632
- image (gallery, logos, team) \u2192 layout:'grid'; everything else stays list, so OMIT layout.
6633
- \`reorderable\` has NO Tier-2 guess \u2014 leave it absent. The default is true, and locking a
6634
- list the user did not ask to lock silently removes something they could do yesterday.
6635
- GUESS HERE otherwise. A wrong preview costs one edit; a question per repeater costs the
6636
- user the session.
6637
-
6638
- TIER 3 \u2014 ASK ME, and ONLY for these three. Batch them into ONE AskUserQuestion at the end,
6639
- never one field at a time:
6640
- a) A panel with MANY fields, where grouping and order depend on which content I treat as
6641
- primary \u2014 you cannot infer my priorities.
6642
- b) CONDITIONAL VISIBILITY (showIf) \u2014 it is business logic about when a field is irrelevant.
6643
- Never invent a rule.
6644
- c) VARIANTS SHARING A sectionType \u2014 their prop keys must match, or swapping the layout loses
6645
- content. Confirm the shared key set before declaring per-variant chrome.
6646
-
6647
- **Order of work**
6648
- 1. \`list_content_models\` / \`list_pages\`, then \`get_content_model\` / \`get_page\` for each target.
6649
- Note which fields ALREADY carry ui/helpText/showIf \u2014 do not overwrite a human's choice
6650
- without asking.
6651
- 2. Decide each field's chrome by the tiers above. Write down which tier answered; you will
6652
- report it.
6653
- 3. Apply. For a field that already exists, the declaration goes with the field: use the
6654
- dashboard or a model update \u2014 \`add_field\` APPENDS and refuses an existing key, so it is
6655
- the wrong tool for editing one. For a NEW field, pass ui/helpText/showIf in the same
6656
- \`add_field\` / \`add_page_field\` call, and pass \`after: '<existing key>'\` if it belongs
6657
- somewhere other than the bottom.
6658
- 4. **RECEIPT \u2014 non-negotiable.** Re-read every target with \`get_content_model\` / \`get_page\` /
6659
- \`get_component\` and
6660
- confirm the stored \`ui\` matches what you sent. A prop ABSENT from that response was NOT
6661
- stored, whatever the write said. Report per field: the tier that decided it, what you set,
6662
- and that the read-back confirmed it. Anything you could not confirm, say so plainly.
6663
-
6664
- **Refusals you should expect and must not work around:** an unknown key inside \`ui\` is a 400
6665
- (it is strict on purpose \u2014 that is your typo, named); \`ui.preview\` or \`ui.layout\` on a leaf
6666
- field is a 400 (both describe ITEMS); \`ui.reorderable\` anywhere without a LIST is a 400 \u2014 a
6667
- \`group\` and a non-repeatable zone hold one item, so only \`repeater\`, an \`array\` with
6668
- config.zones.repeatable, and \`modular\` take it; a preview key that is not a child of that field
6669
- is a 400 listing the valid child keys. Fix the declaration; never retry without it and call that
6670
- success.
6671
-
6672
- There is deliberately NO \`order\` property and no mode to turn on \u2014 order is the array index,
6673
- and \`ui\` only changes editor chrome, so there is nothing to flip.`
6674
- }
6675
- }
6676
- ]
7081
+ messages: [{ role: "user", content: { type: "text", text: FIELD_UI_TEXT(request) } }]
6677
7082
  })
6678
7083
  );
6679
7084
  }
@@ -6697,9 +7102,9 @@ function buildServer(deps) {
6697
7102
  // 🔴 The plain-language ask, routed — the stdio twin of the hosted connector's line
6698
7103
  // (src/routes/mcp/index.ts MCP_INSTRUCTIONS). "Make my site editable" has one recipe and
6699
7104
  // one definition of done; without this an agent converts the page it landed on and stops.
6700
- instructions: RECEIPT_FIRST_INSTRUCTION + " When the user asks to make a site or all of its pages editable, to convert it, or to bind its fields: this is playbook \xA713. Read `bettercms://playbook/schema` \xA713, call get_binding_report and get_conversion_brief { complete: true }, convert EVERY route the brief lists, and finish only when get_binding_report shows coverage.pending empty on every route \u2014 not when the first page works. On a workspace-wide connection pass projectId on every call; never ask the user to re-scope the connection. When the user asks to componentize the whole site, to turn every section into a component, or to build a component library from the site: this is playbook \xA712. Read `bettercms://playbook/schema` \xA712, start with get_site_composition, and use the batch tools \u2014 create_components, compose_pages, update_layout with `commands`, publish_components \u2014 rather than one call per component. Finish with get_site_composition and tell the user what the platform does not model (cookie banners, modals, breadcrumbs, pagination) and which components still need the owner's approval in the dashboard before they can be published. BetterCMS never executes a customer's Section renderer or app code. An ordinary MCP connection is not a push runner: explicitly poll list_section_validation_requests, claim one request at an exact git commit, run implementation and responsive checks inside the user's own repository and real app shell, then submit manifest + validation with that requestId and complete it\u2014or truthfully fail it when implementation/evidence is missing. Never invent a manifest, a passing validation, or visual evidence; these tools cannot grant the separate human Visual Approval required for publication. " + // The default after authoring (the structure standard): organise what you made. Same
7105
+ instructions: RECEIPT_FIRST_INSTRUCTION + " When the user asks to make a site or all of its pages editable, to convert it, or to bind its fields: this is playbook \xA713. Read `bettercms://playbook/schema` \xA713, call get_binding_report and get_conversion_brief { complete: true }, convert EVERY route the brief lists, and finish only when get_binding_report shows coverage.pending empty on every route \u2014 not when the first page works. On a workspace-wide connection pass projectId on every call; never ask the user to re-scope the connection. " + LAYOUT_CHROME_INSTRUCTION + " When the user asks to componentize the whole site, to turn every section into a component, or to build a component library from the site: this is playbook \xA712. Read `bettercms://playbook/schema` \xA712, start with get_site_composition, and use the batch tools \u2014 create_components, compose_pages, update_layout with `commands`, publish_components \u2014 rather than one call per component. Finish with get_site_composition and tell the user what the platform does not model (cookie banners, modals, breadcrumbs, pagination) and which components still need the owner's approval in the dashboard before they can be published. BetterCMS never executes a customer's Section renderer or app code. An ordinary MCP connection is not a push runner: explicitly poll list_section_validation_requests, claim one request at an exact git commit, run implementation and responsive checks inside the user's own repository and real app shell, then submit manifest + validation with that requestId and complete it\u2014or truthfully fail it when implementation/evidence is missing. Never invent a manifest, a passing validation, or visual evidence; these tools cannot grant the separate human Visual Approval required for publication. " + // The default after authoring (the structure standard): organise what you made. Same
6701
7106
  // sentence as the hosted connector's MCP_INSTRUCTIONS.
6702
- STRUCTURE_DEFAULT_INSTRUCTION + " " + SKILLS_ROUTING_INSTRUCTION
7107
+ STRUCTURE_DEFAULT_INSTRUCTION + " " + SKILLS_ROUTING_INSTRUCTION + " " + DOCK_UI_ROUTING_INSTRUCTION
6703
7108
  }
6704
7109
  );
6705
7110
  server.registerResource(
@@ -6726,6 +7131,10 @@ function buildServer(deps) {
6726
7131
  contents: [{ uri: PLAYBOOK_URI, mimeType: "text/markdown", text: SCHEMA_PLAYBOOK }]
6727
7132
  })
6728
7133
  );
7134
+ const { uri: dockIconsUri, name: dockIconsName, ...dockIconsMeta } = DOCK_ICONS_RESOURCE;
7135
+ server.registerResource(dockIconsName, dockIconsUri, dockIconsMeta, () => ({
7136
+ contents: [{ uri: dockIconsUri, mimeType: dockIconsMeta.mimeType, text: DOCK_ICONS_TEXT }]
7137
+ }));
6729
7138
  registerTools(server, {
6730
7139
  auth: deps.auth,
6731
7140
  // `project` is the per-call target of a workspace-wide grant; the SDK sends it as