@bettercms-ai/mcp 0.38.0 → 0.38.2

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
@@ -2108,6 +2108,21 @@ var fieldType = z.enum([
2108
2108
  ]);
2109
2109
  var slug = z.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only");
2110
2110
  var fieldKey = z.string().regex(/^[a-zA-Z0-9_]+$/, "letters, numbers, and underscores only");
2111
+ var uiObject = z.object({
2112
+ collapsed: z.boolean().optional().describe("start this panel collapsed. Use it for long optional sections."),
2113
+ preview: z.object({
2114
+ title: z.string().min(1).optional().describe("CHILD KEY whose value titles a collapsed row"),
2115
+ media: z.string().min(1).optional().describe("CHILD KEY whose value is the row's thumbnail (image/file/url all work)")
2116
+ }).optional().describe(
2117
+ "how ONE ITEM summarises itself when collapsed. Both slots name 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."
2118
+ ),
2119
+ layout: z.enum(["list", "grid"]).optional().describe(
2120
+ "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`."
2121
+ ),
2122
+ reorderable: z.boolean().optional().describe(
2123
+ "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."
2124
+ )
2125
+ });
2111
2126
  var fieldShape = {
2112
2127
  key: fieldKey.describe(
2113
2128
  "machine field key. \u{1F534} UNIQUE ACROSS THE WHOLE MODEL \u2014 API IDs share ONE FLAT NAMESPACE, so a field nested inside a group must NOT reuse a key used by another field or group. Every section's heading cannot be 'title'. Prefix with the section: 'hero_title', 'pricing_title', 'faq_title'. Reusing a key is refused, and if it slips through it leaves permanent errors in the editor and breaks conditional-visibility rules, which reference fields by bare key."
@@ -2124,6 +2139,29 @@ var fieldShape = {
2124
2139
  ),
2125
2140
  fields: z.array(z.lazy(() => fieldObject)).optional().describe(
2126
2141
  "NESTED child fields \u2014 REQUIRED for type 'group' (one nested object, a Non-Repeatable Zone like blog_hero \u2192 heading, description, hero_image) and type 'repeater' (a repeatable array of such objects, a Repeatable Zone / section-list like testimonials \u2192 quote, author). A section with repeating items is a 'repeater'; a fixed grouped block is a 'group'. Recurse to any depth \u2014 do NOT flatten zones into separate top-level fields."
2142
+ ),
2143
+ // ── Authoring chrome + editor behaviour ────────────────────────────────────
2144
+ // These three already persist end-to-end and already have a panel in the dashboard's schema
2145
+ // builder. They were unreachable from MCP for ONE reason: the OutField/toField whitelist
2146
+ // below did not model them, so they were stripped after the model had gone to the trouble of
2147
+ // sending them. Declaring them here is what makes the existing panel agent-writable.
2148
+ helpText: z.string().max(500).optional().describe(
2149
+ "one line of guidance shown UNDER this field in the editor \u2014 what to write here, not what the field is. Write it for the person typing ('Roughly 60 characters; it becomes the browser tab title'), not for a developer."
2150
+ ),
2151
+ showIf: z.object({
2152
+ match: z.enum(["all", "any"]),
2153
+ rules: z.array(
2154
+ z.object({
2155
+ field: z.string().describe("the BARE KEY of another field on this same model"),
2156
+ op: z.enum(["eq", "neq", "in"]),
2157
+ value: z.union([z.string(), z.array(z.string())])
2158
+ })
2159
+ )
2160
+ }).optional().describe(
2161
+ "conditional visibility \u2014 hide this field until another field has a given value (e.g. show `external_url` only when `link_type` is 'external'). Admin-only: it changes what an author SEES, never what is stored or delivered. Rules reference fields by BARE KEY, and the key namespace is flat, so the key must exist on this model."
2162
+ ),
2163
+ ui: uiObject.optional().describe(
2164
+ "AUTHORING CHROME \u2014 how this field presents in the editor. Never a delivery effect. `preview` and `layout` are valid ONLY on a nesting field ('group', 'repeater', an 'array' with config.zones, or 'modular'); `reorderable` only where there is a LIST to lock ('repeater', an 'array' with config.zones.repeatable, or 'modular' \u2014 a 'group' holds one item). preview keys must name real children, or the write is refused with the valid keys listed. An unknown key inside `ui` is a 400, not a silent strip. Read it back with get_content_model / get_page to confirm it landed. There is deliberately no `order`: field order is the array index \u2014 use add_field's `after` to insert."
2127
2165
  )
2128
2166
  };
2129
2167
  var fieldObject = z.object(fieldShape);
@@ -2164,7 +2202,10 @@ function toField(f) {
2164
2202
  const base = {
2165
2203
  key: f.key,
2166
2204
  label: f.label,
2167
- ...f.required !== void 0 ? { required: f.required } : {}
2205
+ ...f.required !== void 0 ? { required: f.required } : {},
2206
+ ...f.helpText !== void 0 ? { helpText: f.helpText } : {},
2207
+ ...f.showIf !== void 0 ? { showIf: f.showIf } : {},
2208
+ ...f.ui !== void 0 ? { ui: f.ui } : {}
2168
2209
  };
2169
2210
  if (f.type === "group") {
2170
2211
  return { ...base, type: "array", config: { zones: { nonRepeatable: toFields(f.fields) } } };
@@ -2358,6 +2399,18 @@ function buildToolDefs(deps) {
2358
2399
  text: z.string().min(1).describe("the content to derive SEO metadata from"),
2359
2400
  context: z.string().optional().describe("optional surrounding context, e.g. the page slug")
2360
2401
  });
2402
+ const connectNativeAiInput = z.object({
2403
+ capabilities: z.array(z.string().min(1)).min(1).optional().describe(
2404
+ "optional \u2014 any of inspect, propose, implement, cancel, question. Defaults to inspect+propose+cancel."
2405
+ )
2406
+ });
2407
+ const nativeBridgeHeartbeatInput = z.object({
2408
+ protocolVersion: z.string().min(1).describe("executor protocol version; currently '1'"),
2409
+ capabilities: z.array(z.string().min(1)).min(1).describe(
2410
+ "what this executor can do: any of inspect, propose, implement, cancel, question. Must include inspect, propose and cancel. An unknown value is refused, never ignored."
2411
+ ),
2412
+ session: z.object({ sessionId: z.string(), sessionEpoch: z.number().int().positive() }).optional().describe("the `session` from the previous response; omit on first contact to reconnect")
2413
+ });
2361
2414
  const createModelInput = z.object({
2362
2415
  name: z.string().min(1).describe("human model name, e.g. 'Blog Post'"),
2363
2416
  slug: slug.describe("url-safe unique slug, e.g. 'blog-post'"),
@@ -2369,13 +2422,18 @@ function buildToolDefs(deps) {
2369
2422
  "the model's typed schema fields. 'group'/'repeater' NEST their child fields (any depth) \u2014 don't flatten zones into top-level fields."
2370
2423
  )
2371
2424
  });
2425
+ const afterArg = z.string().min(1).optional().describe(
2426
+ "OPTIONAL insertion point: the key of an existing TOP-LEVEL field to insert this one directly after. Omit to append at the end (the default, and the behaviour before this existed). Use it to put a field where an author expects it instead of at the bottom \u2014 e.g. after:'hero_title' for a subtitle. An unknown key is refused rather than silently appended."
2427
+ );
2372
2428
  const addFieldInput = z.object({
2373
2429
  modelId: z.string().min(1).describe("id of the content model to extend"),
2374
- ...fieldShape
2430
+ ...fieldShape,
2431
+ after: afterArg
2375
2432
  });
2376
2433
  const addPageFieldInput = z.object({
2377
2434
  pageId: z.string().min(1).describe("id of the page to extend (from list_pages / create_page)"),
2378
- ...fieldShape
2435
+ ...fieldShape,
2436
+ after: afterArg
2379
2437
  });
2380
2438
  const uploadAssetInput = z.object({
2381
2439
  localPath: z.string().min(1).optional().describe("absolute path to a local file (e.g. a repo image); provide this OR url"),
@@ -2510,7 +2568,13 @@ function buildToolDefs(deps) {
2510
2568
  // get_site_composition counts, never a refusal. Parity: componentPropDefSchema on the server.
2511
2569
  required: z.boolean().optional().describe("mark the field required in the editor. An editor HINT: the API still accepts a placement that leaves it empty, and get_site_composition is what reports one."),
2512
2570
  helpText: z.string().max(200).optional().describe("hint shown under the control, muted (\u2264 200 chars)"),
2513
- placeholder: z.string().max(120).optional().describe("ghost text inside a text-ish control (\u2264 120 chars)")
2571
+ placeholder: z.string().max(120).optional().describe("ghost text inside a text-ish control (\u2264 120 chars)"),
2572
+ // ⚠ A STRIP SITE, exactly like OutField above: this is a `z.object`, so a key the model
2573
+ // sends and this shape does not declare is dropped BEFORE the request leaves — a 200 with
2574
+ // the declaration gone. `ui` rode through the backend's looseObject `config` and died here.
2575
+ ui: uiObject.optional().describe(
2576
+ "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."
2577
+ )
2514
2578
  });
2515
2579
  const getLayoutInput = z.object({
2516
2580
  scope: z.enum(["global", "page"]).default("global"),
@@ -2585,7 +2649,9 @@ function buildToolDefs(deps) {
2585
2649
  allowedOn: allowedOn.optional(),
2586
2650
  description: z.string().optional(),
2587
2651
  blockJson: z.array(blockObject).default([]).describe("the component's block tree"),
2588
- props: z.array(componentPropObject).default([]).describe("overridable fields")
2652
+ props: z.array(componentPropObject).default([]).describe(
2653
+ "overridable fields. Each one may carry `ui` to say how its control presents in the inspector \u2014 a collapsed group, what titles and thumbnails a table's rows, whether that table's rows can be dragged at all."
2654
+ )
2589
2655
  });
2590
2656
  const updateComponentInput = z.object({
2591
2657
  componentId: z.string().min(1).describe("component id (from list_components)"),
@@ -2598,7 +2664,9 @@ function buildToolDefs(deps) {
2598
2664
  allowedOn: allowedOn.optional().describe("REPLACES the placement allowlist"),
2599
2665
  description: z.string().optional(),
2600
2666
  blockJson: z.array(blockObject).optional().describe("REPLACES the block tree"),
2601
- props: z.array(componentPropObject).optional()
2667
+ props: z.array(componentPropObject).optional().describe(
2668
+ "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."
2669
+ )
2602
2670
  });
2603
2671
  const getComponentInput = z.object({
2604
2672
  componentId: z.string().min(1).describe("component id (from list_components)")
@@ -3016,7 +3084,7 @@ function buildToolDefs(deps) {
3016
3084
  def(
3017
3085
  "get_content_model",
3018
3086
  "Get a content model",
3019
- "Get one content model by id INCLUDING its full field schema (keys, types, nested group/repeater children). Read this before add_field so you know the existing keys.",
3087
+ "Get one content model by id INCLUDING its full field schema (keys, types, nested group/repeater children) AND each field's stored authoring props \u2014 `helpText`, `showIf`, `ui`. Read it before add_field so you know the existing keys, and AFTER declaring `ui`/`showIf`/`helpText` as the receipt that the declaration actually landed: a prop that is missing from this response was not stored, whatever the write returned.",
3020
3088
  z.object({ modelId: z.string().min(1) }).shape,
3021
3089
  async (c, a) => ok("Content model.", await data(c, "GET", `/management/content/models/${s(a.modelId)}`))
3022
3090
  ),
@@ -3098,7 +3166,7 @@ function buildToolDefs(deps) {
3098
3166
  def(
3099
3167
  "get_binding_report",
3100
3168
  "Check what on the live site is editable",
3101
- "The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; this returns what that scan found, per slot: `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them), `pagesInspected`, `bound` (elements carrying a binding), and `unmatched` \u2014 per page, each path with its kind and the reason it failed (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report and this answers pages 0, mode null, refreshRequired true. It certifies exactly one thing \u2014 that every non-empty field of every page 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 yourself before calling a page done. `builtRoutes` lists the routes the live build has HTML for (null when unknown: runtime release, >500 routes, or a report older than this field). `canvas.lane` names the live-preview lane this build gives the editor \u2014 `bridge` (the build ships BcmsDraftBridge), `draft-route` (a node runtime rendering drafts server-side) or `none`, in which case get_next_steps carries the recipe (playbook section 11); null means the report predates the field. `unaddressable` counts visible text on the live site that no field owns, as the editor's x-ray measured it against the sha now serving \u2014 the one thing `unmatched` structurally cannot see; absent means nobody has turned that control on for this build. `unaddressable` is measured PER SLOT by the editor, which frames `current`; on a promote-gated project pass slot:'current' to read it. Pass `slot` ('current' or 'staging') to read the other tree; the default is the slot this project's releases land in.",
3169
+ "The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; this returns what that scan found, per slot: `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them), `pagesInspected`, `bound` (elements carrying a binding), and `unmatched` \u2014 per page, each path with its kind and the reason it failed (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report and this answers pages 0, mode null, refreshRequired true. It certifies exactly one thing \u2014 that every non-empty field of every page 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 yourself before calling a page done. `builtRoutes` lists the routes the live build has HTML for (null when unknown: runtime release, >500 routes, or a report older than this field). `canvas.lane` names the live-preview lane this build gives the editor \u2014 `bridge` (the build ships BcmsDraftBridge), `draft-route` (a node runtime rendering drafts server-side) or `none`, in which case get_next_steps carries the recipe (playbook section 11); null means the report predates the field. `unaddressable` counts visible text on the live site that no field owns \u2014 the one thing `unmatched` structurally cannot see, because it only ever speaks about fields that already exist. EVERY release measures it server-side on every inspected page: `{count, pages, measuredAt, routes:[{path, count, visible, buckets:[{tag, context, chars, nodes, samples}]}]}`, where `path` is the route (the empty string is the home page), `visible` its countable characters (ornaments, skip links and the platform badge excluded from both sides) and `buckets` where the unowned text is, biggest first. Anything 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 has the loop. An author turning the editor's Unbound text control on posts a bare count of text NODES for the route they opened (no `visible`, no buckets); those rows are reported only for a build the release never measured, never summed with the release's characters. `skipReasons` says why pages were not inspected (`no-html`, `route-cap`, `authored-content` = the singleton lane declined the project, so NO route of it has a page), and `coverage.error` = `nothing-bound` means not one element of this build carries a binding, so the percentage beside it describes a site this build is not. `unaddressable` is measured PER SLOT; on a promote-gated project pass slot:'current' to read the tree the editor frames. Pass `slot` ('current' or 'staging') to read the other tree; the default is the slot this project's releases land in.",
3102
3170
  z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read; defaults to the one this project deploys to") }).shape,
3103
3171
  async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
3104
3172
  ),
@@ -3427,11 +3495,26 @@ function buildToolDefs(deps) {
3427
3495
  const model = await client.getModel(args.modelId);
3428
3496
  const dupes = duplicateKeys([...flatFieldKeys(model.fields), args.key]);
3429
3497
  if (dupes.length > 0) return fail(duplicateKeyFailure(dupes));
3498
+ const existingFields = model.fields;
3499
+ let at = existingFields.length;
3500
+ if (args.after !== void 0) {
3501
+ const idx = existingFields.findIndex(
3502
+ (f) => f.key === args.after
3503
+ );
3504
+ if (idx === -1) {
3505
+ return fail(
3506
+ `after: '${args.after}' is not a top-level field on this model. Valid keys: ${existingFields.map((f) => `'${f.key}'`).join(", ")}. Omit \`after\` to append at the end.`
3507
+ );
3508
+ }
3509
+ at = idx + 1;
3510
+ }
3511
+ const nextFields = [...existingFields];
3512
+ nextFields.splice(at, 0, toField(args));
3430
3513
  const updated = await client.updateModel(args.modelId, {
3431
- fields: [...model.fields, toField(args)]
3514
+ fields: nextFields
3432
3515
  });
3433
3516
  return ok(
3434
- `Added field '${args.key}' to '${updated.name}'. Model now has ${updated.fields.length} field(s).`,
3517
+ `Added field '${args.key}' to '${updated.name}'${args.after ? ` after '${args.after}'` : ""}. Model now has ${updated.fields.length} field(s).`,
3435
3518
  updated
3436
3519
  );
3437
3520
  })
@@ -3446,7 +3529,10 @@ function buildToolDefs(deps) {
3446
3529
  },
3447
3530
  handler: guard(
3448
3531
  async (args) => withClient(async (client) => {
3449
- const page = await client.addPageFields(args.pageId, { addFields: [toField(args)] });
3532
+ const page = await client.addPageFields(args.pageId, {
3533
+ addFields: [toField(args)],
3534
+ ...args.after !== void 0 ? { after: args.after } : {}
3535
+ });
3450
3536
  return ok(
3451
3537
  `Added field '${args.key}' to page '${page.title}'. Page now has ${page.fields.length} field(s).`,
3452
3538
  page
@@ -3764,7 +3850,7 @@ function buildToolDefs(deps) {
3764
3850
  name: "get_component",
3765
3851
  config: {
3766
3852
  title: "Get a component (with its blockJson)",
3767
- description: "Get one component by id INCLUDING its blockJson tree and props. Read this before update_component so you keep the existing blocks.",
3853
+ description: "Get one component by id INCLUDING its blockJson tree and props (with any stored `ui` authoring chrome on them). Read this before update_component so you keep the existing blocks, and after declaring field UI as the receipt that it landed \u2014 a prop absent here was not stored.",
3768
3854
  inputSchema: getComponentInput.shape
3769
3855
  },
3770
3856
  handler: guard(
@@ -4013,6 +4099,55 @@ ${lines.join("\n")}`, found);
4013
4099
  })
4014
4100
  )
4015
4101
  },
4102
+ // ── Native bridge (executor presence) ──────────────────────────────────────
4103
+ {
4104
+ name: "connect_native_ai",
4105
+ config: {
4106
+ title: "Connect this client to the project's Native AI Connection",
4107
+ description: "Connect this client to the project's Native AI Connection, so the dashboard shows it as an available executor. Call it ONCE when the user asks to connect \u2014 after that, ordinary tool calls on the same project keep it listed, and nothing needs to run on a timer. Idempotent. Returns the project it connected to and `presenceWindowMs`: the executor stays listed for that long after the last tool call, then drops off until the next one. THIS GRANTS NOTHING \u2014 it is presence only, and starting a run is still a human act in the dashboard. A 403 BRIDGE_GRANT_REQUIRED means this client is using a bare API key rather than an approved connection; a 400 means the connection covers the whole workspace and you must pass `projectId`.",
4108
+ inputSchema: connectNativeAiInput.shape
4109
+ },
4110
+ handler: guard(
4111
+ async (args) => withClient(async (client) => {
4112
+ const result = await client.fetchJSON(client.url("/management/orchestration/bridge/connect"), {
4113
+ method: "POST",
4114
+ body: JSON.stringify({
4115
+ protocolVersion: "1",
4116
+ ...args.capabilities ? { capabilities: args.capabilities } : {}
4117
+ })
4118
+ });
4119
+ const mins = result.presenceWindowMs ? Math.round(result.presenceWindowMs / 6e4) : null;
4120
+ return ok(
4121
+ `Connected to project ${result.projectId}. The dashboard now lists this client as an available executor${mins ? `; it stays listed for ${mins} minutes after the last tool call` : ""}.`,
4122
+ result
4123
+ );
4124
+ })
4125
+ )
4126
+ },
4127
+ {
4128
+ name: "native_bridge_heartbeat",
4129
+ config: {
4130
+ title: "Register as a native bridge and report liveness",
4131
+ description: "Register this client as a native-bridge executor for the connected project and report that it is alive. The dashboard's 'Native AI Connection' panel shows an executor as live ONLY while heartbeats keep arriving: call this again every `nextHeartbeatMs` (25s), because liveness lapses 90s after the last call and the panel then stops offering this executor. Idempotent \u2014 registration happens on the first call, so a restart or a reconnect is just another call. Echo back the `session` from the previous response so the same connection epoch is kept; omit it to reconnect, which fences the previous one. A 422 names a refused capability or protocol; it does not retry into success. THIS GRANTS NO REPOSITORY MUTATION \u2014 it is presence only, and starting a run still requires a human in the dashboard.",
4132
+ inputSchema: nativeBridgeHeartbeatInput.shape
4133
+ },
4134
+ handler: guard(
4135
+ async (args) => withClient(async (client) => {
4136
+ const result = await client.fetchJSON(client.url("/management/orchestration/bridge/heartbeat"), {
4137
+ method: "POST",
4138
+ body: JSON.stringify({
4139
+ protocolVersion: args.protocolVersion,
4140
+ capabilities: args.capabilities,
4141
+ ...args.session ? { session: args.session } : {}
4142
+ })
4143
+ });
4144
+ return ok(
4145
+ `Bridge ${result.liveness}. Call again within ${Math.round(result.nextHeartbeatMs / 1e3)}s to stay live.`,
4146
+ result
4147
+ );
4148
+ })
4149
+ )
4150
+ },
4016
4151
  // ── Lifecycle tools (parity with the remote /mcp surface) ──────────────────
4017
4152
  // Media management, form submissions (leads), redirects, SEO, AEO site-files,
4018
4153
  // promote, and entry version history. These call the management endpoints straight
@@ -4254,7 +4389,12 @@ component placement, and the page keeps its fields, its values and its bindings.
4254
4389
  1. set_authoring_preference { preference: "components" }
4255
4390
  2. get_componentize_plan one component per section, computed live; creates nothing. It
4256
4391
  reads this project's PUBLISHED pages, so no deploy is needed
4257
- to reach it \u2014 the deploy is the last step, not the first
4392
+ to reach it \u2014 the deploy is the last step, not the first.
4393
+ On a site DERIVED at import the release hook has already run
4394
+ this plan in bind mode and PUBLISHED what it created, so
4395
+ expect ALREADY_COMPONENTIZED and skip to step 6 (publish the
4396
+ pages) \u2014 the lane publishes the COMPONENTS, not the placements,
4397
+ which are still draft blocks on the pages
4258
4398
  3. show the user the plan and CONFIRM \u2014 it says how many components and which pages
4259
4399
  4. componentize_sections \`dryRun: true\` first, then for real; components land as DRAFTS
4260
4400
  and each placement carries \`props.bind\` to the page's field
@@ -4319,10 +4459,28 @@ lane, so every structural draft falls back to the platform renderer and its gene
4319
4459
  \`null\` means the report predates the field; redeploy to learn. When it is \`none\`,
4320
4460
  \`get_next_steps\` names the recipe for this project's framework (\`canvas-lane-missing\`), and
4321
4461
  \xA713 step 5c has both. The same report carries \`unaddressable\`: how much VISIBLE text on the
4322
- live site no field owns, counted against the sha now serving when an author turns the visual
4323
- editor's **"Unbound text"** control on \u2014 \`unmatched\` cannot see it, because it only ever speaks
4324
- about fields that already exist. Absent means nobody has turned that control on for this build,
4325
- not that the site is clean.
4462
+ live site no field owns \u2014 \`unmatched\` cannot see it, because it only ever speaks about fields
4463
+ that already exist. EVERY release measures it server-side, per route, with a denominator and the
4464
+ places it is (\`unaddressable.routes[]\`: \`path\`, \`count\`, \`visible\`, \`buckets[]\`). An author
4465
+ turning the editor's **"Unbound text"** control on posts a bare count of text NODES for the route
4466
+ they opened; it is reported only for a build the release never measured, and never added to
4467
+ the release's characters. Ornaments, skip links and the platform badge are excluded from both sides.
4468
+
4469
+ **THE LOOP, and it is not optional \u2014 a deploy is not a conversion.**
4470
+
4471
+ 1. deploy, then \`get_binding_report\`;
4472
+ 2. for each \`unaddressable.routes[]\` where \`count / visible > 0.02\`:
4473
+ read its \`buckets\` \u2014 each one is a TAG in a LANDMARK with a sample of the copy;
4474
+ 3. fix it in the SOURCE: wrap that run of text in an element the codemod can bind (a
4475
+ \`<span>\`, a \`<p>\`, a \`<div>\` \u2014 never a bare text node between tags), or declare it
4476
+ yourself with \`<BcmsField path="\u2026">\`;
4477
+ 4. deploy again and re-read. Repeat until every route is under 2%.
4478
+
4479
+ \`get_next_steps\` runs the same test and hands you the worst route with its buckets
4480
+ (\`unaddressable-copy\`). A \`coverage\` carrying \`error: "nothing-bound"\` means NOT ONE element
4481
+ of the build is bound \u2014 usually the singleton lane declined the project (\`skipReasons\`
4482
+ \`authored-content\`: its content models were authored by the repo), so the percentage beside it
4483
+ describes a site this build is not.
4326
4484
 
4327
4485
  **A site built in the CMS with NO deployed build has only that renderer** \u2014 live and in the
4328
4486
  editor \u2014 so the generic theme IS the site until you theme it. Theme it without a deploy:
@@ -4437,7 +4595,10 @@ still gets its own component: its own family, plus \`allowedOn: ["slug:<page>"]\
4437
4595
  with NO \`sectionType\` never appears in the editor's "Add a section" picker. Start from the
4438
4596
  builtin blueprints \`list_components\` returns (\`builtin:*\`) instead of hand-writing block JSON.
4439
4597
  On a DERIVED site run \`get_componentize_plan\` \u2192 \`componentize_sections\` FIRST \u2014 it does most
4440
- of this in one call.
4598
+ of this in one call, and on a site derived at IMPORT the release hook has already run it in bind
4599
+ mode and published the components, so expect ALREADY_COMPONENTIZED \u2014 the placements it wrote are
4600
+ still DRAFT blocks on the pages, so publish the pages, then \`npx @bettercms-ai/convert
4601
+ --componentize\`.
4441
4602
 
4442
4603
  **3. The hierarchy editors see is the GROUP.** \`create_component\` and \`update_component\` take
4443
4604
  \`group\`: a NAME, resolved against the project's component groups and created when missing. That
@@ -4686,6 +4847,123 @@ conversion is yours to write.
4686
4847
  **Done means.** Whichever recipe you ran: \`get_binding_report\` reads \`mode "declared"\`,
4687
4848
  \`unmatched\` empty, \`bound\` above 0, \`coverage.pending\` empty, and \`canvas.lane\` is \`bridge\` or
4688
4849
  \`draft-route\` when the site should show structural drafts.
4850
+
4851
+ ## 14. Declare how a field LOOKS in the editor \u2014 \`ui\`, \`helpText\`, \`showIf\`
4852
+
4853
+ You can create the schema. You can also say how it PRESENTS to the person filling it in. These
4854
+ three props ride on any field, on any field-creating tool (\`create_content_model\`,
4855
+ \`create_page\`, \`add_field\`, \`add_page_field\`), and none of them can move, reshape or hide a
4856
+ delivered value \u2014 they change editor chrome only.
4857
+
4858
+ \`\`\`jsonc
4859
+ { "key": "testimonials", "label": "Testimonials", "type": "repeater",
4860
+ "ui": { "collapsed": true, "preview": { "title": "author", "media": "avatar" },
4861
+ "layout": "grid", "reorderable": true },
4862
+ "helpText": "Three to five. Shortest quotes read best.",
4863
+ "fields": [ { "key": "author", ... }, { "key": "avatar", "type": "image", ... }, ... ] }
4864
+ \`\`\`
4865
+
4866
+ - **\`helpText\`** \u2014 one line UNDER the field telling the author what to write here, not what the
4867
+ field is. "Roughly 60 characters; it becomes the browser tab title" earns its place.
4868
+ "The title field" does not.
4869
+ - **\`showIf\`** \u2014 hide a field until another has a value:
4870
+ \`{match:'all',rules:[{field:'link_type',op:'eq',value:'external'}]}\`. Rules name fields by
4871
+ BARE KEY, and the key namespace is FLAT across the model (\xA7 the duplicate-key rule), so the
4872
+ key you reference must exist.
4873
+ - **\`ui.collapsed\`** \u2014 start a nesting field's panel closed. For long optional sections.
4874
+ - **\`ui.preview\`** \u2014 how ONE ITEM of a nesting field summarises itself when collapsed. Both
4875
+ slots name a **CHILD FIELD KEY of that field** \u2014 not a value, not a dotted path.
4876
+ Without it a repeater of ten testimonials reads "Item 1 \u2026 Item 10" and the author has to open
4877
+ each one to find the one they meant. \`media\` may point at an \`image\`, \`file\` or url-ish
4878
+ \`text\` child; the type is not constrained.
4879
+ - **\`ui.layout\`** \u2014 \`'list'\` (the default; omit it) or \`'grid'\`. Grid when the THUMBNAIL is
4880
+ what the author scans: a gallery, a logo wall, a team. List when the words are.
4881
+ - **\`ui.reorderable\`** \u2014 omit it (\u2261 \`true\`) unless the ORDER IS PART OF THE MEANING. \`false\`
4882
+ LOCKS the list so its items cannot be dragged: a "three steps" band that must stay three steps
4883
+ in that order, a nav whose order is semantic, a timeline. Locking a list nobody should reorder
4884
+ is the declaration there was previously no way to make; locking one out of tidiness takes a
4885
+ capability away from the author, so do not set it "just in case".
4886
+
4887
+ **Five refusals, all deliberate, all 400 with the fix in the message.**
4888
+ 1. An unknown key inside \`ui\` is REJECTED, not stripped. \`ui\` is a strict object precisely so
4889
+ that a typo is a refusal you can read instead of a 200 with your declaration gone.
4890
+ 2. \`ui.preview\` on a LEAF field is refused \u2014 it summarises an item, and a leaf has no items.
4891
+ Valid on \`group\`, \`repeater\`, an \`array\` with \`config.zones\`, or \`modular\`.
4892
+ 3. \`preview.title\` / \`preview.media\` naming a key that is not a child of that field is refused,
4893
+ and the error lists the valid child keys.
4894
+ 4. \`ui.layout\` follows the same rule as \`preview\`: nesting fields only. A leaf has no items
4895
+ to arrange.
4896
+ 5. \`ui.reorderable\` is STRICTER \u2014 it needs a LIST, not merely children. Valid on \`repeater\`,
4897
+ an \`array\` with \`config.zones.repeatable\`, or \`modular\`. A \`group\` (and a non-repeatable
4898
+ zone) holds exactly ONE item, so there is nothing there to drag and the write is refused.
4899
+
4900
+ **The same \`ui\` rides on COMPONENT PROPS.** \`create_component\` / \`update_component\` take it on
4901
+ each entry of \`props\`, with the placement rules the prop vocabulary implies: \`preview\` and
4902
+ \`layout\` on \`group\`, \`table\` and \`slot\`; \`reorderable\` on \`table\` ALONE, because a table is the
4903
+ only prop whose value is a list (a \`group\` is one object, a \`slot\` one component instance). A
4904
+ prop's \`preview\` keys name a SUB-FIELD from its own \`config.fields\` and are checked against
4905
+ them \u2014 except on a \`slot\`, whose children belong to whichever component fills it and therefore
4906
+ cannot be resolved at save time. \`update_component\` REPLACES the whole \`props\` array, so echo
4907
+ back each prop's stored \`ui\` or you delete it along with the prop.
4908
+
4909
+ \`\`\`jsonc
4910
+ { "key": "slides", "label": "Slides", "type": "table",
4911
+ "target": { "blockId": "slider", "path": "props.slides" },
4912
+ "ui": { "preview": { "title": "caption", "media": "image" }, "layout": "grid" },
4913
+ "config": { "fields": [ { "key": "image", "type": "image", ... },
4914
+ { "key": "caption", "type": "text", ... } ] } }
4915
+ \`\`\`
4916
+
4917
+ **THE RECEIPT \u2014 read it back.** There is no separate verification tool and no mode to flip.
4918
+ \`get_content_model\` (and \`get_page\` / \`get_component\`) return each field's \u2014 and each component
4919
+ prop's \u2014 stored \`ui\`, \`helpText\` and \`showIf\`. A prop that is ABSENT from that response was not
4920
+ stored, whatever the write returned \u2014 \xA712 rule 3, applied to this feature. Declare, then read
4921
+ back, then say it works.
4922
+
4923
+ **No \`order\` property. Ever.** A field's order IS its index in \`fields\`. To put a new field
4924
+ somewhere other than the end, pass \`after: '<existing top-level field key>'\` to \`add_field\` /
4925
+ \`add_page_field\`; omit it and the field appends, exactly as before. \`after\` is an argument
4926
+ applied once at the write \u2014 a stored ordering prop would be a second source of truth that
4927
+ disagrees with the array the moment anyone drags a row in the builder.
4928
+
4929
+ **Where the values come from \u2014 three tiers, in order. Stop at the first that answers.**
4930
+
4931
+ TIER 1 DERIVE FROM THE SITE'S SOURCE, when you have a checkout.
4932
+ <img src={item.image}> + <h3>{item.name}</h3> inside the map over \`team\`
4933
+ \u2192 ui.preview = { title: 'name', media: 'image' }
4934
+ A section rendered inside a <details> or behind a "Show advanced" toggle
4935
+ \u2192 ui.collapsed: true
4936
+ grid-cols-* / display:grid on the wrapper of the .map \u2192 ui.layout: 'grid'
4937
+ A FIXED-ARITY render \u2014 steps[0]/steps[1]/steps[2], or copy that names the
4938
+ positions ("Step 1", "then", "finally") \u2192 ui.reorderable: false
4939
+ A prop read only when another is set \u2192 showIf
4940
+ \u{1F534} THE SERVER NEVER READS YOUR SOURCE on the add_field path. \`pull_project_source\`
4941
+ and \`get_conversion_brief\` hand YOU the repo; this tier is work you do in your own
4942
+ checkout before you call the tool. On a greenfield, schema-first project there is
4943
+ no source at all \u2014 Tier 1 is vacuous and TIER 2 IS THE FLOOR.
4944
+
4945
+ TIER 2 DERIVE FROM THE FIELD SHAPE, when the code is silent.
4946
+ A repeater whose children include exactly one image-ish child \u2192 that is \`media\`.
4947
+ Its first required short-text child (name/title/heading/label) \u2192 that is \`title\`.
4948
+ A nesting field with more than ~6 children, or one whose label reads as optional
4949
+ ("Advanced", "Extras", "SEO overrides") \u2192 \`collapsed: true\`.
4950
+ A repeatable whose item is MOSTLY its image (gallery, logos, team) \u2192 \`layout:
4951
+ 'grid'\`; anything whose words are the point stays 'list' \u2014 so OMIT \`layout\`.
4952
+ \`reorderable\` HAS NO TIER-2 GUESS. The default (true) is the safe one, and
4953
+ locking a list the user did not ask to lock takes a capability away from them.
4954
+ Leave it absent unless Tier 1 showed you a fixed arity or the user said so.
4955
+ Guessing here is CORRECT. A wrong preview costs one edit; asking about every
4956
+ repeater on a 40-field schema costs the user the session.
4957
+
4958
+ TIER 3 ASK THE USER \u2014 only when 1 and 2 are both silent, and only for these three:
4959
+ a) MANY fields in one panel, where the grouping and the order depend on which
4960
+ content the user considers primary. You cannot know their priorities.
4961
+ b) CONDITIONAL VISIBILITY (\`showIf\`). It encodes business logic \u2014 when a field is
4962
+ irrelevant \u2014 and nothing in the code or the shape tells you that.
4963
+ c) VARIANTS SHARING A \`sectionType\`. Their prop keys MUST match (\xA74), so a preview
4964
+ or a rename that diverges between two variants makes a layout swap lose content.
4965
+ Confirm the shared key set before you declare per-variant chrome.
4966
+ Ask with AskUserQuestion, batched, once \u2014 never one field at a time.
4689
4967
  `;
4690
4968
 
4691
4969
  // src/prompts.ts
@@ -5191,6 +5469,108 @@ On 401/403, the MCP key needs (re)authorizing.`
5191
5469
  ]
5192
5470
  })
5193
5471
  );
5472
+ server.registerPrompt(
5473
+ "field-ui",
5474
+ {
5475
+ title: "Declare how fields LOOK in the editor (guided)",
5476
+ 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.",
5477
+ argsSchema: {
5478
+ request: z2.string().optional().describe("scope, e.g. 'the Team and Testimonials models' or 'the whole project'")
5479
+ }
5480
+ },
5481
+ ({ request }) => ({
5482
+ messages: [
5483
+ {
5484
+ role: "user",
5485
+ content: {
5486
+ type: "text",
5487
+ text: `Declare the editor UI for my BetterCMS fields.${request ? ` Scope: "${request}".` : ""}
5488
+
5489
+ Read bettercms://playbook/schema section 14 first \u2014 it has the exact prop shapes and the refusals.
5490
+
5491
+ **What you are setting** (all of these ride on any field, on create_content_model / create_page /
5492
+ add_field / add_page_field; none of them affect delivery):
5493
+ ui.collapsed start a nesting field's panel closed
5494
+ ui.preview {title,media} which CHILD FIELD KEY titles a collapsed row, and which is its thumb
5495
+ ui.layout 'list'|'grid' how its items are arranged; omit for list
5496
+ ui.reorderable omit (= true) unless the ORDER IS THE MEANING; false LOCKS the list
5497
+ helpText one line under the field: what to WRITE here
5498
+ showIf hide a field until another field has a value
5499
+
5500
+ **The same \`ui\` rides on COMPONENT PROPS** \u2014 create_component / update_component, one per entry
5501
+ of \`props\`. Placement differs because the vocabulary does: preview/layout on 'group', 'table'
5502
+ and 'slot'; reorderable on 'table' ALONE (the only prop whose value is a list). A prop's preview
5503
+ keys name a SUB-FIELD from its own config.fields. If this scope includes components, do them in
5504
+ the same pass \u2014 but note update_component REPLACES the whole props array, so read get_component
5505
+ first and echo every prop back WITH its stored ui.
5506
+
5507
+ **Work the three tiers in order. Stop at the first that answers \u2014 do not escalate what a tier
5508
+ below already settled.**
5509
+
5510
+ TIER 1 \u2014 DERIVE FROM SOURCE, if and only if you have a checkout of the site.
5511
+ Read the component that renders each field. \`<img src={item.photo}>\` next to
5512
+ \`<h3>{item.name}</h3>\` in the map over a list IS the answer: preview {title:'name',
5513
+ media:'photo'}. A block inside \`<details>\` or behind a "Show more" is collapsed:true.
5514
+ A \`grid-cols-*\` wrapper around the map is layout:'grid'. A FIXED-ARITY render \u2014
5515
+ steps[0]/steps[1]/steps[2], or copy that names positions ("Step 1", "finally") \u2014 is
5516
+ reorderable:false, and it is the ONLY evidence that earns that key.
5517
+ \u{1F534} THE SERVER NEVER READS YOUR SOURCE. \`pull_project_source\` / \`get_conversion_brief\` hand
5518
+ the repo to YOU; add_field does not look at it. This tier is your own reading, in your own
5519
+ checkout, before you call any tool. ON A GREENFIELD SCHEMA-FIRST PROJECT THERE IS NO SOURCE \u2014
5520
+ Tier 1 does not apply and TIER 2 IS THE FLOOR. Do not stall waiting for code that will
5521
+ never exist.
5522
+
5523
+ TIER 2 \u2014 DERIVE FROM THE FIELD SHAPE, when the code is silent or absent.
5524
+ One image-ish child in a repeater \u2192 that is \`media\`. The first required short-text child
5525
+ (name/title/heading/label) \u2192 that is \`title\`. More than ~6 children, or a label that reads
5526
+ optional ("Advanced", "Extras") \u2192 collapsed:true. A repeatable whose item is mostly its
5527
+ image (gallery, logos, team) \u2192 layout:'grid'; everything else stays list, so OMIT layout.
5528
+ \`reorderable\` has NO Tier-2 guess \u2014 leave it absent. The default is true, and locking a
5529
+ list the user did not ask to lock silently removes something they could do yesterday.
5530
+ GUESS HERE otherwise. A wrong preview costs one edit; a question per repeater costs the
5531
+ user the session.
5532
+
5533
+ TIER 3 \u2014 ASK ME, and ONLY for these three. Batch them into ONE AskUserQuestion at the end,
5534
+ never one field at a time:
5535
+ a) A panel with MANY fields, where grouping and order depend on which content I treat as
5536
+ primary \u2014 you cannot infer my priorities.
5537
+ b) CONDITIONAL VISIBILITY (showIf) \u2014 it is business logic about when a field is irrelevant.
5538
+ Never invent a rule.
5539
+ c) VARIANTS SHARING A sectionType \u2014 their prop keys must match, or swapping the layout loses
5540
+ content. Confirm the shared key set before declaring per-variant chrome.
5541
+
5542
+ **Order of work**
5543
+ 1. \`list_content_models\` / \`list_pages\`, then \`get_content_model\` / \`get_page\` for each target.
5544
+ Note which fields ALREADY carry ui/helpText/showIf \u2014 do not overwrite a human's choice
5545
+ without asking.
5546
+ 2. Decide each field's chrome by the tiers above. Write down which tier answered; you will
5547
+ report it.
5548
+ 3. Apply. For a field that already exists, the declaration goes with the field: use the
5549
+ dashboard or a model update \u2014 \`add_field\` APPENDS and refuses an existing key, so it is
5550
+ the wrong tool for editing one. For a NEW field, pass ui/helpText/showIf in the same
5551
+ \`add_field\` / \`add_page_field\` call, and pass \`after: '<existing key>'\` if it belongs
5552
+ somewhere other than the bottom.
5553
+ 4. **RECEIPT \u2014 non-negotiable.** Re-read every target with \`get_content_model\` / \`get_page\` /
5554
+ \`get_component\` and
5555
+ confirm the stored \`ui\` matches what you sent. A prop ABSENT from that response was NOT
5556
+ stored, whatever the write said. Report per field: the tier that decided it, what you set,
5557
+ and that the read-back confirmed it. Anything you could not confirm, say so plainly.
5558
+
5559
+ **Refusals you should expect and must not work around:** an unknown key inside \`ui\` is a 400
5560
+ (it is strict on purpose \u2014 that is your typo, named); \`ui.preview\` or \`ui.layout\` on a leaf
5561
+ field is a 400 (both describe ITEMS); \`ui.reorderable\` anywhere without a LIST is a 400 \u2014 a
5562
+ \`group\` and a non-repeatable zone hold one item, so only \`repeater\`, an \`array\` with
5563
+ config.zones.repeatable, and \`modular\` take it; a preview key that is not a child of that field
5564
+ is a 400 listing the valid child keys. Fix the declaration; never retry without it and call that
5565
+ success.
5566
+
5567
+ There is deliberately NO \`order\` property and no mode to turn on \u2014 order is the array index,
5568
+ and \`ui\` only changes editor chrome, so there is nothing to flip.`
5569
+ }
5570
+ }
5571
+ ]
5572
+ })
5573
+ );
5194
5574
  }
5195
5575
 
5196
5576
  // src/server.ts