@bettercms-ai/mcp 0.69.0 → 0.70.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
@@ -2673,8 +2673,9 @@ function listsTool(name, sets) {
2673
2673
  }
2674
2674
 
2675
2675
  // src/tool-text.ts
2676
- var ADD_FIELD_DESCRIPTION = "Append ONE field to a content model (`modelId`) or to a page's schema (`pageId`) \u2014 additive, never overwrites; a key already in use is refused. For a section/zone add ONE 'group' (fixed block) or 'repeater' (repeating list) carrying its child `fields`, not the zone's inner fields as separate top-level fields. Pass `after` to insert it after an existing top-level field instead of at the end; `ui`, `helpText`, `showIf`, `placement` and `fieldsetId` declare how it presents in the editor \u2014 read them back with get_content_model or get_page. On a page a 'group' is stored as type 'array' with `config.zones.nonRepeatable`: entry and delivery data carry `<key>.nonRepeatable.<sub>`, while data-bcms-field paths are `<key>.<sub>`. Schema only \u2014 never places a section; use componentize_sections to turn a group into a section. To change a field that exists, use update_field.";
2676
+ var ADD_FIELD_DESCRIPTION = "Append ONE field to a content model (`modelId`) or to a page's schema (`pageId`) \u2014 additive, never overwrites; a key already in use is refused. For a section/zone add ONE 'group' (fixed block) or 'repeater' (repeating list) carrying its child `fields`, not the zone's inner fields as separate top-level fields. Pass `after` to insert it after an existing top-level field instead of at the end; `ui`, `helpText`, `showIf`, `placement` and `fieldsetId` declare how it presents in the editor \u2014 read them back with get_content_model or get_page. On a page a 'group' is stored as type 'array' with `config.zones.nonRepeatable`: entry and delivery data carry `<key>.nonRepeatable.<sub>`, while data-bcms-field paths are `<key>.<sub>`. Schema only \u2014 never places a section; use componentize_sections to turn a group into a section. `multiselect` stores a string[] of `config.options` (`allowCustom` for tags); `icon` is a BetterCMS icon name or an uploaded icon. To change a field that exists, use update_field.";
2677
2677
  var MOVE_STAGE_DESCRIPTION = "Move a content entry (`entryId`) or a page (`pageId`) to another editorial stage \u2014 this is how you take it OUT of review so it can be published. `workflowStage` is a stage key from get_workflow_board; pass `fromStage` to 409 if someone moved it first. Moving into the publish-gate stage is an approval and needs a publish-enabled connection. Publish afterwards with update_content_entry or update_page status:'published' \u2014 approving and publishing are deliberately two separate calls.";
2678
+ var WINDOW_NOTE = "{from?, until?}, ISO 8601 with offset: shown only between the two instants; null clears (skill publishing).";
2678
2679
  var WEBHOOK_EVENTS = [
2679
2680
  "content.publish",
2680
2681
  "content.unpublish",
@@ -2715,6 +2716,7 @@ var PARAM_TEXT = {
2715
2716
  lockReason: "which system owns the value (\u2264 200 chars)",
2716
2717
  lockClears: "as on add_field; null clears it",
2717
2718
  helpText: "shown under the field: its purpose, where it shows, any constraint; never its label or value. Omitted: written for you.",
2719
+ fromBuiltin: "a builtin:* id from list_components: copies it into the project; fields you send win",
2718
2720
  describes: "for editors: what it is and holds; omitted: written for you",
2719
2721
  runtimeBundle: "instead of pageId: the server-app bundle cleanup",
2720
2722
  pageRef: "page id or path ('/de/about'); a bare slug only when one page has it",
@@ -2755,6 +2757,8 @@ var SEO_KEY_TEXT = {
2755
2757
  var VARIANT_GROUP_NOTE = "When the component belongs to a family of looks, `variantGroup` names the family and lists its looks.";
2756
2758
  var UNPROPPED_COPY_NOTE = "A `warnings` entry UNPROPPED_COPY names copy no prop targets, which nobody can edit: add a prop with a `target` for each leaf it lists.";
2757
2759
  var UPDATE_PAGE_IF_MATCH_NOTE = "get_page's `optimisticVersion`: the write applies only at that version, and a 409 means the page changed since, so read it again. Send it whenever you pass blockJson, which REPLACES the whole composition.";
2760
+ var SLOT_MULTIPLE_NOTE = "'slot' with config.multiple holds a list [{componentId, overrides?}] (config.maxItems, default 12, at most 50): target.path 'props.children' on a section.";
2761
+ var LAYOUT_LOCK_NOTE = "A Global field with `overridable:false` + `lockReason` (add-field `field`, update-field `patch`) refuses page overrides (LAYOUT_FIELD_NOT_OVERRIDABLE); get_layout scope page lists stored ones as `shadowed`.";
2758
2762
 
2759
2763
  // src/entry-query.ts
2760
2764
  var text = (v) => Array.isArray(v) ? v.map(String).join(",") : String(v);
@@ -2916,7 +2920,9 @@ var fieldType = z.enum([
2916
2920
  "color",
2917
2921
  "json",
2918
2922
  "file",
2919
- "sections"
2923
+ "sections",
2924
+ "multiselect",
2925
+ "icon"
2920
2926
  ]);
2921
2927
  var slug = z.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only");
2922
2928
  var fieldKey = z.string().regex(/^[a-zA-Z0-9_-]+$/, "letters, numbers, underscores, and hyphens only");
@@ -2935,8 +2941,8 @@ var uiObject = z.strictObject({
2935
2941
  reorderable: z.boolean().optional().describe(
2936
2942
  "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."
2937
2943
  ),
2938
- control: z.enum(["segmented", "slider"]).optional().describe(
2939
- "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."
2944
+ control: z.enum(["segmented", "slider", "rating"]).optional().describe(
2945
+ "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); 'rating' on a number with config.min, max and step. Anything else is refused with the rule named."
2940
2946
  ),
2941
2947
  // A refine, not an enum: 236 names inlined into every schema that carries `ui` would be paid on
2942
2948
  // every tools/list. The layoutIcon precedent; the server's z.enum is the guard.
@@ -3344,6 +3350,7 @@ function buildToolDefs(deps) {
3344
3350
  }
3345
3351
  };
3346
3352
  }
3353
+ const windowArg = z.object({ from: z.string().optional(), until: z.string().optional() });
3347
3354
  const blockObject = z.object({
3348
3355
  type: z.enum([
3349
3356
  "heading",
@@ -3368,8 +3375,9 @@ function buildToolDefs(deps) {
3368
3375
  "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|tertiary|brand|dark|light|outline|link, size? sm|md|lg, fullWidth?, arrow? boolean, icon? ui.icon name, newTab? boolean, rel? nofollow|sponsored|ugc}; 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 hold ANY block; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?, source? {modelId}, filters? [{field, op, value}], sort?, pinnedIds?, excludeCurrent?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>; with `source`, that model's entries as cards on any page"
3369
3376
  ),
3370
3377
  style: z.record(z.string(), z.unknown()).optional().describe(
3371
- "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."
3372
- )
3378
+ "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), hideOn [mobile|tablet|desktop], never all three. A real marketing band is a `section` block carrying bg + padding + contentWidth."
3379
+ ),
3380
+ window: windowArg.nullable().optional().describe(WINDOW_NOTE)
3373
3381
  });
3374
3382
  const createPageInput = z.object({
3375
3383
  title: z.string().min(1).describe("display title of the page, e.g. 'Home'"),
@@ -3541,7 +3549,9 @@ function buildToolDefs(deps) {
3541
3549
  "date",
3542
3550
  "url",
3543
3551
  "consent",
3544
- "hidden"
3552
+ "hidden",
3553
+ "step"
3554
+ // `step` is a divider: the fields after it are one page
3545
3555
  ]),
3546
3556
  placeholder: z.string().optional(),
3547
3557
  helpText: z.string().optional().describe("hint shown under the control, muted"),
@@ -3601,8 +3611,8 @@ function buildToolDefs(deps) {
3601
3611
  // enum omitted could not be echoed back, and an agent reading a component and writing it
3602
3612
  // straight back DESTROYED every prop of that type on a 200.
3603
3613
  type: z.enum(COMPONENT_PROP_TYPES),
3604
- // 'slot' holds ONE nested component instance; config.componentIds restricts what may
3605
- // fill it. Absent here until now, so a slot allowlist was unreachable from stdio even
3614
+ // 'slot' holds ONE nested component instance, or a list with config.multiple (the keys
3615
+ // pass through to the server); config.componentIds restricts what may fill it. Absent here until now, so a slot allowlist was unreachable from stdio even
3606
3616
  // once the enum allowed the type.
3607
3617
  config: z.object({
3608
3618
  componentIds: z.array(z.string()).optional(),
@@ -3661,9 +3671,9 @@ function buildToolDefs(deps) {
3661
3671
  z.object({ type: z.literal("swap-component-variant"), ...sectionTarget, itemId: z.string().min(1), componentId: z.string().min(1) }),
3662
3672
  z.object({ type: z.literal("set-page-state"), ...sectionTarget, state: z.enum(["inherit", "override-content", "customize-structure", "disable", "reset"]) }),
3663
3673
  z.object({ type: z.literal("set-section-state"), ...sectionTarget, state: z.enum(["inherit", "override-content", "customize-structure", "disable", "reset"]) }),
3664
- z.object({ type: z.literal("add-section"), name: z.string().min(1), icon: layoutIcon, zone: z.enum(["before_page", "after_page"]) }),
3674
+ z.object({ type: z.literal("add-section"), name: z.string().min(1), icon: layoutIcon, zone: z.enum(["before_page", "after_page"]), window: windowArg.optional() }),
3665
3675
  z.object({ type: z.literal("restore-section"), section: z.object({ id: z.string().min(1) }).passthrough(), data: z.object({ fields: z.record(z.string(), z.unknown()) }), placement: z.object({ zone: z.enum(["above-page-content", "below-page-content"]), localOrderKey: z.string().min(1), afterSectionId: z.string().optional(), beforeSectionId: z.string().optional() }).optional() }),
3666
- z.object({ type: z.literal("update-section"), ...sectionTarget, name: z.string().min(1).optional(), icon: layoutIcon.optional(), slug: z.string().min(1).optional(), zone: z.enum(["above-page-content", "below-page-content"]).optional(), orderKey: z.string().min(1).optional(), description: z.string().optional() }),
3676
+ z.object({ type: z.literal("update-section"), ...sectionTarget, name: z.string().min(1).optional(), icon: layoutIcon.optional(), slug: z.string().min(1).optional(), zone: z.enum(["above-page-content", "below-page-content"]).optional(), orderKey: z.string().min(1).optional(), description: z.string().optional(), window: windowArg.nullable().optional().describe("as a block's `window`") }),
3667
3677
  z.object({ type: z.literal("remove-section"), ...sectionTarget }),
3668
3678
  z.object({ type: z.literal("move-section"), ...sectionTarget, afterId: z.string().nullable().optional(), beforeId: z.string().nullable().optional() }),
3669
3679
  z.object({ type: z.literal("add-field"), ...sectionTarget, parentFieldId: z.string().optional(), fieldType: z.enum(["text", "longtext", "richtext", "number", "toggle", "date", "image", "file", "link", "email", "phone", "select", "color", "json", "reference", "multi-reference", "group", "repeater"]).optional(), field: z.object({ id: z.string(), slug: z.string(), label: z.string(), type: z.string() }).passthrough().optional() }),
@@ -3712,12 +3722,13 @@ function buildToolDefs(deps) {
3712
3722
  group: z.string().min(1).max(100).optional().describe("the folder editors see in the dashboard's Components tab, by NAME (e.g. 'Sections', 'Forms'); CREATED when missing. Separate from `category`, the picker tab."),
3713
3723
  sectionType: sectionType.optional(),
3714
3724
  variantOf: z.string().min(1).max(255).optional().describe("id/slug of the component this is a VARIANT of: joins or starts its family (422 if its shared inputs are missing)"),
3725
+ from: z.string().min(1).max(255).optional().describe(PARAM_TEXT.fromBuiltin),
3715
3726
  projectId: z.string().nullable().optional().describe("owning project id; null creates a workspace-wide global component"),
3716
3727
  allowedOn: allowedOn.optional(),
3717
3728
  description: z.string().optional().describe(PARAM_TEXT.describes),
3718
3729
  blockJson: z.array(blockObject).default([]).describe("the component's block tree"),
3719
3730
  props: z.array(componentPropObject).default([]).describe(
3720
- "overridable fields. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: only the leaves a prop targets are click-to-edit. 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. 'button' is ONE unit {label, href, variant?, size?, arrow?, icon?, newTab?} on a button block's 'props', never a text prop plus a url prop. 'link' an href, 'date' YYYY-MM-DD, 'color' a hex, 'file' {url, name?}, 'icon' an icon name or an image, 'multiselect' a string[] from config.options (required)."
3731
+ "overridable fields. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: only the leaves a prop targets are click-to-edit. 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. 'button' is ONE unit {label, href, variant?, size?, arrow?, icon?, newTab?} on a button block's 'props', never a text prop plus a url prop. 'link' an href, 'date' YYYY-MM-DD, 'color' a hex, 'file' {url, name?}, 'icon' an icon name or an image, 'multiselect' a string[] from config.options (required). " + SLOT_MULTIPLE_NOTE
3721
3732
  )
3722
3733
  });
3723
3734
  const updateComponentInput = z.object({
@@ -4287,7 +4298,7 @@ ${d.outline}` : "Proposed Content structure.", d);
4287
4298
  def(
4288
4299
  "submit_conversion_receipt",
4289
4300
  "Record what the conversion codemod could and could not do",
4290
- "Hand BetterCMS the codemod's own account of a run, so the meter can say WHY a path is undeclared. Submit the receipt `npx @bettercms-ai/convert --receipt out.json` wrote as `{ briefDigest, receipt }`, for the SAME `briefDigest` get_conversion_brief { complete: true } returned (its shape: see `receipt`). \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: the response answers `complete` (`sourceComplete` is the receipt's own verdict; `liveChecks` gives the last release's `structureUnstamped` route count and `reportSha`) and `pendingTotal`, and echoes the first 40 pending rows with their `fix` \u2014 `{ action, file, line, col?, snippet, why?, kind? }`, `action` one of `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data`, `manual`; `snippet` and `why` say what to change there. On a `declare-richtext` with `kind: document`, bind the ONE element wrapping every block of the Body, never the paragraph holding its first. Apply every fix, rerun, resubmit: only `complete: true` ends a conversion \u2014 never report a site converted on less. It is a RECORD, not a release: nothing on the site changes; the meter reads it on the get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here. \u{1F534} The same run owes two more things (playbook \xA713 steps 2b and 3b): the `--forms` receipt, submitted as a SECOND call under the same `briefDigest`, and the `styleEdits` of `npx @bettercms-ai/convert --verify-styles` inside this receipt \u2014 one listing a changed class, className or style is refused with 409 `style-edits`. Requires artifact:write.",
4301
+ "Hand BetterCMS the codemod's own account of a run, so the meter can say WHY a path is undeclared. Submit the receipt `npx @bettercms-ai/convert --receipt out.json` wrote as `{ briefDigest, receipt }`, for the SAME `briefDigest` get_conversion_brief { complete: true } returned (its shape: see `receipt`). \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: the response answers `complete` and `pendingTotal`, and echoes the first 40 pending rows with their `fix` (`action` one of `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data`, `manual`; the row shape: skill bindings; a 404 `unknown-brief` means that digest was never issued here). Apply every fix, rerun, resubmit: only `complete: true` ends a conversion \u2014 never report a site converted on less. It is a RECORD, not a release: nothing on the site changes; the meter reads it on the get_binding_report after the next deploy. \u{1F534} The same run owes two more things (playbook \xA713 steps 2b and 3b): the `--forms` receipt (a SECOND call, same `briefDigest`) and the `styleEdits` of `convert --verify-styles` inside this receipt; any changed class or style is refused with 409 `style-edits`. Requires artifact:write.",
4291
4302
  z.object({
4292
4303
  briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
4293
4304
  receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim: `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message, fix }] }` plus `styleEdits`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length; each pending `reason` is the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026); a path with no row reads `not-declared`.")
@@ -4427,7 +4438,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
4427
4438
  def(
4428
4439
  "deploy_project",
4429
4440
  "Deploy new source/build",
4430
- "Deploy new source/build for the connected project and make it live at its <handle>.bettercms.site. Pass a .tgz or .zip of the project as a base64 string in `data`, or the `uploadKey` of an archive you PUT through create_deploy_upload (no size ceiling: use it for anything large, or after a 413/502). SOURCE (has package.json) is built server-side in an isolated sandbox; a prebuilt static site is served as-is. Returns the release id + sha \u2014 then poll get_deploy_status until it is live. IMPORTANT \u2014 you MUST exclude node_modules, .git and build output/caches (dist, build, .next, .astro, .cache) from the archive: the source is reinstalled and built server-side, and the ~100 MB upload ceiling is enforced in transit (a 413/502 with no server-side detail) \u2014 server-side stripping runs AFTER the upload and cannot rescue an over-limit body. Returns `canvas.lane` (the editor's live-preview lane: `bridge` / `draft-route` / `none`; playbook section 11) and `editorUrl`, the visual editor to hand the user. On a workspace-wide connection pass `projectId` (from list_projects) to say which site this ships to.",
4441
+ "Deploy new source/build for the connected project and make it live at its <handle>.bettercms.site. Pass a .tgz or .zip of the project as a base64 string in `data`, or the `uploadKey` of an archive you PUT through create_deploy_upload (no size ceiling: use it for anything large, or after a 413/502). SOURCE (has package.json) is built server-side in an isolated sandbox; a prebuilt static site is served as-is. Returns the release id + sha \u2014 then poll get_deploy_status until it is live. IMPORTANT \u2014 you MUST exclude node_modules, .git and build output/caches (dist, build, .next, .astro, .cache) from the archive: the source is reinstalled and built server-side, and the ~100 MB upload ceiling is enforced in transit (a 413/502 with no server-side detail) \u2014 server-side stripping runs AFTER the upload and cannot rescue an over-limit body. Returns `canvas.lane` (skill bindings) and `editorUrl`, the visual editor to hand the user. On a workspace-wide connection pass `projectId` (from list_projects) to say which site this ships to.",
4431
4442
  z.object({
4432
4443
  data: z.string().min(1).optional().describe("base64 .tgz/.zip of the project; or send uploadKey"),
4433
4444
  uploadKey: z.string().min(1).optional().describe("instead of data: the uploadKey from create_deploy_upload, after PUTting the archive to its uploadUrl"),
@@ -4791,14 +4802,14 @@ ${res.warnings.join("\n")}` : summary, res.data);
4791
4802
  def(
4792
4803
  "get_binding_report",
4793
4804
  "Check what on the live site is editable",
4794
- "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, partial and unstamped (a page list the content apply left as built: `kind` `list <bound>/<total>`), mismatch (a text-match stamp whose element shows MORE than the value, or no longer the text it was matched on (`data-bcms-from`): never written), 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. Global Sections count only on routes that render them (`layoutRoutes`). `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. `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.",
4795
- 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. Also per slot: `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).") }).shape,
4805
+ "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 (reasons: skill bindings). DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason`: a HEADLESS site never gets one (`never-generated`); `layoutRoutes` and `coverage.error`: skill bindings. `unaddressable` is the visible live text no field owns, by route (null = unmeasured: diff visible text against entry values); over `count / visible > 0.02`, fix it in the SOURCE and re-read (playbook \xA711). `canvas.lane`: `none` \u2192 get_next_steps.",
4806
+ 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. Other per-slot fields: skill bindings.") }).shape,
4796
4807
  async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
4797
4808
  ),
4798
4809
  def(
4799
4810
  "get_conversion_brief",
4800
4811
  "Get the brief for making this site's bindings durable",
4801
- "The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route, every bindable field path with its `label`, `kind`, the CMS value (`current`) and the copy the repo renders today (`original`), the exact attributes to declare, and the ordered steps. Call it for any site whose pages were DERIVED at import, and whenever get_next_steps reports `bindings-not-declared`. It REPLACES re-registering a schema: these pages, fields and values exist already, so create_page / add_field / create_content_model would build a second schema over the first \u2014 edit values with set_page_content instead. `lane`: 'git-connected' = pull_project_source returns a repo; 'archive' = a tarball. Full recipe: section 13 of bettercms://playbook/schema; get_binding_report is the receipt that says you finished. Pass `complete: true` before running the CODEMOD (`npx @bettercms-ai/convert`): the brief UNCAPPED and paged, its full path list pinned under a `briefDigest` \u2014 what the coverage meter measures the build against. The complete brief also carries `forms` (id, status, fields, submitUrl), which `npx @bettercms-ai/convert --forms --brief brief.json --root . --receipt forms-receipt.json` wires into the repository's own <form> elements after the binding pass; read its `forms.pending` and publish every form its `forms.notes` names in the Forms tab (a draft form rejects every submission with a 403).",
4812
+ "The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route, every bindable field path with its `label`, `kind`, the CMS value (`current`) and the copy the repo renders today (`original`), the exact attributes to declare, and the ordered steps. Call it for any site whose pages were DERIVED at import, and whenever get_next_steps reports `bindings-not-declared`. It REPLACES re-registering a schema: these pages, fields and values exist already, so create_page / add_field / create_content_model would build a second schema over the first \u2014 edit values with set_page_content instead. `lane`: 'git-connected' = pull_project_source returns a repo; 'archive' = a tarball. Recipe: playbook \xA713; get_binding_report is the finish line. Pass `complete: true` before running the CODEMOD (`npx @bettercms-ai/convert`): the brief UNCAPPED and paged, its full path list pinned under a `briefDigest` \u2014 what the coverage meter measures the build against. The complete brief also carries `forms`: wiring them and publishing the forms it names, skill bindings.",
4802
4813
  z.object({
4803
4814
  complete: z.boolean().optional().describe("true = the COMPLETE brief for a codemod: nothing truncated, no page omitted, paged 50 pages at a time. Page 1 pins the path list the coverage meter measures against."),
4804
4815
  cursor: z.string().optional().describe("The `cursor` from the previous page. Implies complete. A 409 BRIEF_CHANGED means the brief was re-derived mid-pagination \u2014 start again with no cursor.")
@@ -4815,14 +4826,14 @@ ${res.warnings.join("\n")}` : summary, res.data);
4815
4826
  def(
4816
4827
  "get_site_composition",
4817
4828
  "Check every page is assembled from registered components",
4818
- "The receipt for 'is every page assembled from registered components?' \u2014 read-only, computed live. Call it before a whole-site componentize run (playbook \xA712) and after it, as the proof. Per page: `kind` ('blocks' | 'fields-only' | 'empty'), a block census, `nonComponentBlocks` (loose TOP-LEVEL bands the editor's section lane cannot outline), and every `component` placement with the component's `status` ('published' | 'draft' | 'missing' \u2014 a draft renders as an EMPTY STRING on the live site), whether it is `inPicker` (a component with no sectionType is dropped from 'Add a section'), and any `emptyRequiredProps` \u2014 `required` is not enforced on write, so only this read reports it. Site-wide: `components` counts published/draft, `awaitingEvidence` and `awaitingApproval` (a HUMAN approves in the BetterCMS dashboard \u2014 no tool here can, and there is no bulk approve), plus `notInPicker` and `unused` ids; `layout.placements` is the chrome placed in the Global Layout draft. `verdict.everyPageComposed` with `verdict.reasons` is the answer to give the user. Pages are read DRAFT-inclusive: check each page's own `status` before claiming the site is live.",
4829
+ "The receipt for 'is every page assembled from registered components?' \u2014 read-only, computed live. Call it before a whole-site componentize run (playbook \xA712) and after it, as the proof. Per page: `kind`, a block census, `nonComponentBlocks` and every `component` placement with its `status` (a draft renders as an EMPTY STRING on the live site), `inPicker` and `emptyRequiredProps`. Site-wide: counts, `awaitingApproval` (a HUMAN approves in the dashboard; no tool here can), `notInPicker`, `unused` and `layout.placements`. Each field: skill components. `verdict.everyPageComposed` with `verdict.reasons` is the answer to give the user. Pages are read DRAFT-inclusive: check each page's own `status` before claiming the site is live.",
4819
4830
  z.object({}).shape,
4820
4831
  async (c) => ok("Site composition.", await data(c, "GET", `/management/projects/current/composition`))
4821
4832
  ),
4822
4833
  def(
4823
4834
  "get_componentize_plan",
4824
4835
  "Get the plan for turning this site's sections into components",
4825
- "What this site's SECTIONS would become as components \u2014 a proposal that creates nothing and is computed live. For a site whose pages were DERIVED at import, each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import folded (`group-*`). Per page: each section's `groupKey`, `sectionType` family, leaf `fields` with their CMS values, `shapeHash`, and either the component already rendering it (`reuse.componentId`) or the proposed one (`reuse.proposedSlug`). Under `components`, a NEW one carries the exact `props` and `blockJson` create_component would take; an EXISTING one only `componentId`, `slug` and `name` (nothing is written for it). Every page gets its OWN components; a locale copy (`/fr`) reuses its default-locale page's. A COLLECTION TEMPLATE (dynamic page, `pattern: \"{slug}\"`) is planned ONCE, every prop reading the rendered entry: only its prose body and object-row lists are bands; frontmatter (author, images, tags, dates) stays entry fields. `pending` says why a group is not offered (its codes: see `summary`). Chrome (`nav`/`footer`/`modal`: LAYOUT_CHROME, or in the Layout) is NEVER a section: edit it through the Layout. READ `summary: true` FIRST \u2014 the full plan runs to hundreds of KB; `paged` pages it (409 PLAN_CHANGED = start again); the unpaged full read is what `--plan plan.json` takes. Keep the `digest` \u2014 componentize_sections refuses any other.",
4836
+ "What this site's SECTIONS would become as components \u2014 a proposal that creates nothing and is computed live. For a site whose pages were DERIVED at import, each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import folded (`group-*`). Per page: each section's `groupKey`, `sectionType` family, leaf `fields` with their CMS values, `shapeHash`, and either the component already rendering it (`reuse.componentId`) or the proposed one (`reuse.proposedSlug`). Under `components`, a NEW one carries the exact `props` and `blockJson` create_component would take; an EXISTING one only `componentId`, `slug` and `name` (nothing is written for it). `pending` says why a group is not offered (its codes: see `summary`). Per-page components, collection templates and chrome (never a section): skill componentize. READ `summary: true` FIRST \u2014 the full plan runs to hundreds of KB; `paged` pages it (409 PLAN_CHANGED = start again); the unpaged full read is what `--plan plan.json` takes. Keep the `digest` \u2014 componentize_sections refuses any other.",
4826
4837
  z.object({
4827
4838
  summary: z.boolean().optional().describe("true = no values: each section's groupKey, family, pending and component slug. Read this first. `pending` codes: `NO_GROUP_ROOT` (keys are still derive-lane names like `h1-welcome` \u2014 rename them into families first), `NOT_A_SECTION` (a lone scalar or the page's own metadata; `detail: ENTRY_METADATA` = collection-template frontmatter, `detail: DECLINED` = declined with componentize_sections `decline`), `IMAGE_AS_TEXT` (a text field holding an image path \u2014 make it an image field), `ALREADY_COMPONENTIZED`, `EMPTY_GROUP`, `NESTED_REPEATER` (three deep; two levels become a nested `table` sub-field), `PAGE_NOT_EMPTY` (blocks this lane does not own and will not overwrite)."),
4828
4839
  paged: z.boolean().optional().describe("true = 10 pages at a time; the response carries `page`, `pageCount` and a `cursor` for the next."),
@@ -4833,7 +4844,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
4833
4844
  def(
4834
4845
  "componentize_sections",
4835
4846
  "Turn this site's derived sections into components",
4836
- "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 call it again with `codemod: true` (or 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.",
4847
+ "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 (`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 (skill components). 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 call it again with `codemod: true` (or run `npx @bettercms-ai/convert --componentize` in the repo) so its templates render these sections from `pages[].blocks`. When the user says a proposed group is NOT a section, pass `decline: true` with those `sections` (and `pageIds`): nothing is componentized and the plan stops proposing them (skill componentize).",
4837
4848
  z.object({
4838
4849
  digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
4839
4850
  pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
@@ -5062,7 +5073,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
5062
5073
  def(
5063
5074
  "get_deploy_status",
5064
5075
  "Get deploy/build status",
5065
- "Get the connected project's deploy/build status: state (idle|queued|building|failed), whether it's publishing, the live commit sha, when it went live, and any build error. `editorSetup`: whether editing is set up for the live build (never or stale = call editor_setup). Poll this after deploy_project until state is idle with your sha live. `release` is the newest release (id, sha, status, phaseMarks, error, createdAt): while an uploaded build ingests or releases, state is `building`, `release.sha` the new sha and `lastLiveSha` still the old one (re-sending the live sha is a no-op); a failed deploy shows `release.status` failed with its `error`, and `held` state (`held.by`: staff, archived or no-subdomain) means the release waits until that is cleared. `runtime` ({state: ok|crash-loop, restarts, oomKilled}) appears for a server app only: crash-loop means its container keeps restarting (often out of memory) though the build went live fine. Also returns `canvas.lane` (the editor's live-preview lane for this site: `bridge` / `draft-route` / `none`) and `editorUrl`, the visual editor to hand the user.",
5076
+ "Get the connected project's deploy/build status: state (idle|queued|building|failed), whether it's publishing, the live commit sha, when it went live, and any build error. `editorSetup`: whether editing is set up for the live build (never or stale = call editor_setup). Poll this after deploy_project until state is idle with your sha live. `release` and `held`: skill publishing. `runtime` ({state: ok|crash-loop, restarts, oomKilled}) appears for a server app only: crash-loop means its container keeps restarting (often out of memory) though the build went live fine. Also returns `canvas.lane` (skill bindings) and `editorUrl`, the visual editor to hand the user.",
5066
5077
  z.object({}).shape,
5067
5078
  // Flat body, no `data` envelope: `data()` read it as undefined.
5068
5079
  async (c) => ok("Deploy status.", await c.fetchJSON(c.url(`/management/projects/deploy-status`), { method: "GET" }))
@@ -5507,7 +5518,7 @@ ${notes.join("\n")}` : summary, created);
5507
5518
  name: "create_form",
5508
5519
  config: {
5509
5520
  title: "Create a form",
5510
- description: "Create a form in the connected project from a field schema. Confirm the fields with the user first. Field types: text, email, textarea, select (needs options), radio (needs options), checkboxes (needs options), checkbox, number, phone, date, url, consent, hidden. Returns the new form's id. It lands as a DRAFT; call publish_form before embedding (a draft form returns 403 on submit). Embed it with `<BcmsForm form={getForm('Name')} />` from @bettercms-ai/next.",
5521
+ description: "Create a form in the connected project from a field schema. Confirm the fields with the user first. Field types: text, email, textarea, select (needs options), radio (needs options), checkboxes (needs options), checkbox, number, phone, date, url, consent, hidden, step (a divider: the fields after it are one page). Returns the new form's id. It lands as a DRAFT; call publish_form before embedding (a draft form returns 403 on submit). Embed it with `<BcmsForm form={getForm('Name')} />` from @bettercms-ai/next.",
5511
5522
  inputSchema: createFormInput.shape
5512
5523
  },
5513
5524
  handler: guard(
@@ -5566,7 +5577,7 @@ ${notes.join("\n")}` : summary, created);
5566
5577
  name: "update_layout",
5567
5578
  config: {
5568
5579
  title: "Apply Layout draft commands",
5569
- description: "Apply permission-shaped values/composition/schema commands to the Global Layout or a page override. Draft-only: this never publishes. Pass the revision from get_layout as ifMatch. ONE command in `command`, or an ordered batch in `commands` (prefer the batch for more than three of anything \u2014 placing site chrome is add-section, add-component and set-bindings in sequence): each command is applied against the revision the previous one RETURNED, so you do not re-read get_layout between them. A batch stops at the first failure and tells you how many were applied and the revision it stopped at \u2014 the earlier ones stand, because a Layout command is not a transaction.",
5580
+ description: "Apply permission-shaped values/composition/schema commands to the Global Layout or a page override. Draft-only: this never publishes. Pass the revision from get_layout as ifMatch. ONE command in `command`, or an ordered batch in `commands` (prefer the batch for more than three of anything \u2014 placing site chrome is add-section, add-component and set-bindings in sequence): each command is applied against the revision the previous one RETURNED, so you do not re-read get_layout between them. A batch stops at the first failure and tells you how many were applied and the revision it stopped at \u2014 the earlier ones stand, because a Layout command is not a transaction. " + LAYOUT_LOCK_NOTE,
5570
5581
  inputSchema: commandLayoutInput.shape
5571
5582
  },
5572
5583
  handler: guard(async (args) => withClient(async (client) => {
@@ -5604,7 +5615,7 @@ ${notes.join("\n")}` : summary, created);
5604
5615
  name: "publish_layout",
5605
5616
  config: {
5606
5617
  title: "Publish the Global Layout draft",
5607
- 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.",
5618
+ 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 (the order and the optional logo: skill components). 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.",
5608
5619
  inputSchema: publishLayoutInput.shape
5609
5620
  },
5610
5621
  handler: guard(async (args) => withClient(async (client) => {
@@ -5942,7 +5953,7 @@ ${lines.join("\n")}`, found);
5942
5953
  name: "publish_component",
5943
5954
  config: {
5944
5955
  title: "Publish a component",
5945
- description: "Publish one project-scoped component variant after its exact implementation evidence and human approval are ready: copy its DRAFT definition to the live copy and re-bake every published page that embeds it. This is the ONLY way a component reaches the live site. Built-in `builtin:*` rows are locked blueprints, not implementations; materialize one with create_component first. Workspace-global component publication is currently fail-closed. Drafts (create_component, update_component) render as NOTHING live, with no error: publish every component you place on a page. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish: say so and let the user publish from the dashboard. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: no tool on this connection can clear it. It is never a component you just made with create_component in no variant group \u2014 that is dashboard-managed and publishes on the preflight alone. The refusal is for a REPO-managed component without exact validation evidence and a human's approval, or for a variant-group member (the provisioned navigation and footer) whose group's canonical-input contract is not published. Its `readiness` names what is missing \u2014 relay it to the user instead of retrying or routing around it. 422 means publishing would break a Layout that uses it \u2014 the response names which.",
5956
+ description: "Publish one project-scoped component variant after its exact implementation evidence and human approval are ready: copy its DRAFT definition to the live copy and re-bake every published page that embeds it. This is the ONLY way a component reaches the live site. Built-in `builtin:*` rows are locked blueprints, not implementations; materialize one with create_component first. Workspace-global component publication is currently fail-closed. Drafts (create_component, update_component) render as NOTHING live, with no error: publish every component you place on a page. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish: say so and let the user publish from the dashboard. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: no tool on this connection can clear it. It asks for exact validation evidence and a human's approval, never of a component you just made with create_component (which ones: skill components). Its `readiness` names what is missing \u2014 relay it to the user instead of retrying or routing around it. 422 means publishing would break a Layout that uses it \u2014 the response names which.",
5946
5957
  inputSchema: getComponentInput.shape
5947
5958
  },
5948
5959
  handler: guard(
@@ -7213,7 +7224,7 @@ Author a form (then the user embeds it with \`<BcmsForm form={getForm('Name')} /
7213
7224
  1. **Discover** \u2014 \`list_forms\` to see existing forms; \`get_form\` to read one before editing.
7214
7225
  2. **Collect fields (loop)** \u2014 for each: key (machine key for the value), label, type. Types:
7215
7226
  text, email, textarea, select (needs \`options: string[]\`), checkbox, checkboxes (needs
7216
- \`options\`), radio (needs \`options\`), number, phone, date, url, consent, hidden. Optional per
7227
+ \`options\`), radio (needs \`options\`), number, phone, date, url, consent, hidden, step (divider). Optional per
7217
7228
  field: required?, placeholder?, helpText? (shown under the input), defaultValue?,
7218
7229
  \`validation\` (\`emailPolicy\` on email, \`min\`/\`max\` on number, \`phoneFormat\` on phone,
7219
7230
  \`pattern\` on text/textarea/url \u2014 a rule on any other type is a 400),
@@ -7241,7 +7252,7 @@ error-prone, so go slow and confirm. Never guess the layout.
7241
7252
  { key, label, target: { blockId, path },
7242
7253
  type: text|richtext|image|url|boolean|number|select|group|table|slot|form|link|button|date|color|file|icon|multiselect }.
7243
7254
  'select'/'multiselect' need config.options; 'group'/'table' need config.fields; a 'button'
7244
- is ONE prop on a button block's 'props', never a label prop plus a url prop; 'slot' holds ONE nested component;
7255
+ is ONE prop on a button block's 'props', never a label prop plus a url prop; 'slot' holds ONE nested component (a list with config.multiple);
7245
7256
  'form' holds ONE Form document \u2014 point target.path at 'props' on an empty form block and set
7246
7257
  defaultValue to {formId}. Never re-declare a form's FIELDS as props: the Form document owns
7247
7258
  them, and a second copy is a second source of truth that drifts on the first edit.