@bettercms-ai/mcp 0.68.1 → 0.69.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
@@ -27,7 +27,16 @@ var COMPONENT_PROP_TYPES = [
27
27
  "group",
28
28
  "table",
29
29
  "slot",
30
- "form"
30
+ "form",
31
+ // The seven below each store what the same-named content-model FIELD stores (`icon` and
32
+ // `multiselect` have no field twin; see the value notes on ComponentPropDef.type).
33
+ "link",
34
+ "button",
35
+ "date",
36
+ "color",
37
+ "file",
38
+ "icon",
39
+ "multiselect"
31
40
  ];
32
41
 
33
42
  // ../types/src/layout-lucide-icons.ts
@@ -2510,7 +2519,7 @@ function allKeys(fields) {
2510
2519
  })
2511
2520
  ]);
2512
2521
  }
2513
- var CLEARABLE = ["helpText", "showIf", "ui", "placement"];
2522
+ var CLEARABLE = ["helpText", "showIf", "ui", "placement", "readOnly", "lockReason"];
2514
2523
  var SETTABLE = ["label", "required", ...CLEARABLE];
2515
2524
  function mergeConfig(current, patch) {
2516
2525
  const out = { ...current ?? {} };
@@ -2548,7 +2557,7 @@ function planFieldEdit(model, edit, callerIfMatch) {
2548
2557
  }
2549
2558
  const changes = SETTABLE.filter((k) => edit[k] !== void 0);
2550
2559
  if (!retype && rename === void 0 && edit.config === void 0 && changes.length === 0) {
2551
- return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, ui, placement, type or key." };
2560
+ return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, ui, placement, readOnly, lockReason, type or key." };
2552
2561
  }
2553
2562
  const echo = helpTextEcho(edit.helpText, { label: edit.label ?? current.label, defaultValue: current.defaultValue });
2554
2563
  if (echo) return { ok: false, error: echo };
@@ -2601,6 +2610,7 @@ var LISTING_TAIL = /* @__PURE__ */ new Set([
2601
2610
  "extract_component",
2602
2611
  "list_extraction_candidates",
2603
2612
  "create_deploy_upload",
2613
+ "import_redirects",
2604
2614
  "set_authoring_preference",
2605
2615
  "set_media_delivery",
2606
2616
  "delete_form_submission",
@@ -2679,7 +2689,7 @@ var NEW_TOOL_TEXT = {
2679
2689
  discard_draft: "Throw away unpublished edits to an entry (`entryId`) or a page (`pageId`) \u2014 exactly one: the draft returns to exactly what is live and `pendingChanges` becomes false. A page resets its blockJson and its published Layout overrides. 409 no_published_version means it was never published (delete it instead). Pass ifMatch (optimisticVersion) to refuse if it changed since you read it.",
2680
2690
  give_feedback: "Tell the BetterCMS team about a tool that failed, a capability you needed and did not have, or docs that misled you. Opens a ticket for the team; nothing in the project changes. Name the `tool`, and paste the request and response into `context`. Never include secrets or personal data.",
2681
2691
  publish_entries: "Publish or unpublish many entries and pages in ONE call (up to 100 ids of each; pages publish only). Needs a publish-enabled connection, and each item still passes its own gates (editorial stage, required fields). Nothing stops the run: the result lists what changed (`published` or `unpublished`) and every refusal in `failed` with its reason. A publish answers 202 with the routes being rebuilt.",
2682
- create_content_entries: "Create up to 200 DRAFT entries of one collection in ONE call, each {slug?, data} as create_content_entry takes it. Every row reports `created` (with id and slug) or `failed` (with the error), a failure does not stop the others, and `meta` counts both. Publish afterwards with publish_entries.",
2692
+ create_content_entries: "Create up to 200 DRAFT entries of one collection in ONE call, each {slug?, data} as create_content_entry takes it. Every row reports `created` (with id and slug) or `failed` (with the error), a failure does not stop the others, and `meta` counts them. A row with `externalKey` is safe to send again: its entry is `updated` (draft only), or `unchanged` when the row is the same. A row setting a developer-locked field fails (FIELD_LOCKED). Publish afterwards with publish_entries.",
2683
2693
  translate_entry: "Create an entry's `targetLocale` translation with AI (uses AI credits: 402/403 when out or not on the plan). Text and longtext fields are translated; rich text and documents are copied for you to translate; the rest is copied. The new entry is a DRAFT with `locale` set and `translationOf` = the source; `meta` lists translated and copied keys. `targetLocale` must be one of the project's locales; 409 locale_conflict means that translation exists, so edit it instead.",
2684
2694
  list_webhooks: "List the project's outgoing webhooks: name, url, events, whether active, and delivery health (total, failed, last attempt). Signing secrets are never returned.",
2685
2695
  create_webhook: "Create an outgoing webhook: BetterCMS POSTs each subscribed event to your https `url`, signed with HMAC-SHA256 in X-BetterCMS-Signature. The response carries the signing `secret` ONCE: store it now. `test: true` also sends a sample delivery. Payloads carry ids and metadata, never the content: fetch it from the API. A receiver that writes back on content.updated loops unless it skips its own writes.",
@@ -2689,11 +2699,21 @@ var NEW_TOOL_TEXT = {
2689
2699
  search_docs: "Search the BetterCMS documentation. Returns the best pages (title, url, breadcrumbs, snippet). Use it before guessing how a feature works; `meta.unavailable` means docs search is down right now.",
2690
2700
  generate_image: "Generate an image with AI and add it to the Media Library (5 AI credits; free with the workspace's own Together key). `width`/`height` are 256-1440 in multiples of 16 (default 1024x768). Returns the new asset (id, url, altText): use the url as an 'image' field value. Always set `altText`.",
2691
2701
  refresh_search_index: "Tell search engines and site search about published changes; costs nothing. action 'indexnow' submits `urls` (this site's own, up to 1000) to IndexNow, or every change since the last submission when omitted. action 'reindex' rebuilds the project's site-search index. 503 means the index is unreachable: try again later.",
2702
+ import_redirects: "Bulk-create redirects for a migration, from `rows` or from `text` in `format` (our CSV export, a Netlify `_redirects` file, or a vercel.json). Call it with `dryRun: true` FIRST: nothing is written and each row comes back `would-create`, `existing` or `skipped` with a reason. Rows get create_redirect's rules. Max 5000.",
2692
2703
  editor_setup: "Make the live build editable. A build imports only SEO/meta, media, brand and the models bcms-content.json declares; this derives the rest from the live release: pages, collections, forms, bindings, button and section components (when the authoring preference is components), Component Groups and the Content sidebar. Run it after an import or a rebuild, before the conversion brief. Idempotent: `ready` when it already ran for the live sha, else it starts (`running`; poll with `check: true` or get_deploy_status `editorSetup`). `state`: never or stale (run it), running, ready, failed. 409 = nothing is live yet: deploy first. Then get_next_steps.",
2693
2704
  transform_image: "Edit an existing Media Library image with an instruction (e.g. 'remove the background', 'make it dusk'); the result is saved as a NEW asset and the original is kept (5 AI credits). Returns the new asset (id, url, altText)."
2694
2705
  };
2695
2706
  var DELETE_PAGE_RUNTIME_BUNDLE = " Or, with no pageId, `runtimeBundle`: 'report' lists the duplicate pages derived from a server app's own bundle (`app/` folders); 'apply' retires them and their empty folders.";
2707
+ var FIELD_LOCKED_NOTE = "A readOnly 'developer' field keeps its stored value: changing or omitting it without schema access is 403 FIELD_LOCKED.";
2696
2708
  var PARAM_TEXT = {
2709
+ dryRun: "validates and reports what would be written; writes nothing",
2710
+ externalKey: "id in the source system; sent again, it updates that entry's draft or leaves it unchanged",
2711
+ upsertSource: "source URL, kept with the entry",
2712
+ upsertRunId: "your run's id, kept with the entry",
2713
+ externalKeyFilter: "exact externalKey an entry was created with",
2714
+ readOnly: "'developer' = the value and this lock change only with schema access (needs lockReason); true = an editor marker",
2715
+ lockReason: "which system owns the value (\u2264 200 chars)",
2716
+ lockClears: "as on add_field; null clears it",
2697
2717
  helpText: "shown under the field: its purpose, where it shows, any constraint; never its label or value. Omitted: written for you.",
2698
2718
  describes: "for editors: what it is and holds; omitted: written for you",
2699
2719
  runtimeBundle: "instead of pageId: the server-app bundle cleanup",
@@ -2719,14 +2739,14 @@ var PARAM_TEXT = {
2719
2739
  confirmedInput: "true only after the user explicitly approved adding this custom input (`ui.input`); else setting or changing one is refused input_needs_confirmation. Removing one needs no approval"
2720
2740
  };
2721
2741
  var INPUT_CONFIRMED_HEADER = "x-bcms-confirmed-input";
2722
- var BUTTON_FIELD_NOTE = "'button' is ONE call to action, value {label, href, variant?, size?, arrow?, icon?, newTab?} (arrow:false, icon:null turn one off): never a text field beside a link field.";
2723
- var UPDATE_FIELD_DESCRIPTION = "Change ONE existing field of a content model (`modelId`) or of a page's schema (`pageId`), addressed by `path`: its key, or dot-separated keys through groups and repeaters ('hero.title'). Set its label, helpText, required, validation `config` (merged by key; null deletes one), showIf, ui or placement (null clears those). The rest of the schema is written back unchanged at the version read; a 409 means it changed since, so read again. `type` changes how every stored value is read and needs allowRetype: true. `key` renames it: stored values stay under the old key, so the route answers 409 with the entry count unless allowFieldRemoval: true discards them \u2014 migrate the values first. Ask the user before a retype or a rename. A page's label + link pair (primaryCtaText + primaryCtaHref) becomes one button with `mergeHref`.";
2742
+ var BUTTON_FIELD_NOTE = "'button' is ONE call to action, value {label, href, variant?, size?, arrow?, icon?, newTab?, rel?} (arrow:false, icon:null turn one off): never a text field beside a link field.";
2743
+ var UPDATE_FIELD_DESCRIPTION = "Change ONE existing field of a content model (`modelId`) or of a page's schema (`pageId`), addressed by `path`: its key, or dot-separated keys through groups and repeaters ('hero.title'). Set its label, helpText, required, validation `config` (merged by key; null deletes one), showIf, ui, placement, readOnly or lockReason (null clears those). The rest of the schema is written back unchanged at the version read; a 409 means it changed since, so read again. `type` changes how every stored value is read and needs allowRetype: true. `key` renames it: stored values stay under the old key, so the route answers 409 with the entry count unless allowFieldRemoval: true discards them \u2014 migrate the values first. Ask the user before a retype or a rename. A page's label + link pair (primaryCtaText + primaryCtaHref) becomes one button with `mergeHref`.";
2724
2744
  var GET_SEO_DESCRIPTION = "Get the connected project's site-wide SEO settings \u2014 every key update_seo writes: seoDefaults (metaTitle, metaDescription, ogImage, twitterHandle, og, twitter, collectionSchemaTypes), siteSchema (the site's JSON-LD), robotsConfig, sitemapConfig, rssConfig, indexnowConfig and searchConfig.";
2725
2745
  var UPDATE_SEO_DESCRIPTION = "Set the connected project's site-wide SEO defaults and config. Each key you send is REPLACED whole (not deep-merged), so read get_seo and send the complete object. `seoDefaults` applies to every page that does not override it, and its `collectionSchemaTypes` maps a collection's model id to the schema.org type of its entries. `siteSchema` is the site's own JSON-LD (Organization/WebSite, emitted on the home page). `robotsConfig`, `sitemapConfig` and `rssConfig` shape robots.txt, sitemap.xml and the feed; `indexnowConfig` pings search engines on publish; `searchConfig` is site search. A key the plan does not include answers 403 FEATURE_LOCKED. Rebuilds the site.";
2726
2746
  var SEO_KEY_TEXT = {
2727
2747
  seoDefaults: "site-wide meta defaults every page inherits",
2728
2748
  siteSchema: "the site's JSON-LD: an object or an array of objects",
2729
- robotsConfig: "robots.txt: 'auto', or 'custom' with its text",
2749
+ robotsConfig: "robots.txt: 'auto' (`disallow` names AI crawlers to block), or 'custom' with its text",
2730
2750
  sitemapConfig: "sitemap.xml: generated ('auto') or 'custom', defaults and extra URLs",
2731
2751
  rssConfig: "the RSS feed on or off",
2732
2752
  indexnowConfig: "IndexNow on publish, minus URL patterns to leave out",
@@ -2753,7 +2773,7 @@ function whereToQuery(where) {
2753
2773
  }
2754
2774
  function entryListSearch(args) {
2755
2775
  const q = new URLSearchParams();
2756
- for (const key of ["modelId", "pageId", "status"]) if (args[key]) q.set(key, String(args[key]));
2776
+ for (const key of ["modelId", "pageId", "status", "externalKey"]) if (args[key]) q.set(key, String(args[key]));
2757
2777
  for (const [k, v] of whereToQuery(args.where)) q.append(k, v);
2758
2778
  for (const key of ["sort", "page", "limit"]) if (args[key] !== void 0 && args[key] !== null) q.set(key, String(args[key]));
2759
2779
  if (args.detail !== true) q.set("select", "summary");
@@ -2954,7 +2974,7 @@ var fieldShape = {
2954
2974
  "choices when type is 'select'. A plain string, or {label, value} when the stored value differs from what the editor shows (e.g. {label:'Sold out', value:'soldOut'})."
2955
2975
  ),
2956
2976
  config: z.record(z.string(), z.unknown()).optional().describe(
2957
- "per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime}, modular {blockSlugs: ['quote','gallery'], minItems?, maxItems?} \u2014 blockSlugs is REQUIRED, non-empty, and each slug must name an existing kind:'block' model; richtext {inline:true, hostTag:'h2', accept:['h2','p','b','i','u','link'], features:['bold','italic','underline','link','headings'], allowMultipleParagraphs:false} \u2014 for a field bound to ONE element on the page: `hostTag` is that element's tag and `accept` must name it, so the editor's block-style control opens already showing 'Heading 2' instead of 'Paragraph'. Name exactly ONE heading level: publish refuses an <h3> value bound to an <h2> rather than nesting one heading inside the other. Keep 'p' in `accept` or the sanitiser strips a block demoted to Paragraph"
2977
+ "per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime, timezone}, modular {blockSlugs: ['quote','gallery'], minItems?, maxItems?} \u2014 blockSlugs is REQUIRED, non-empty, and each slug must name an existing kind:'block' model; richtext {inline:true, hostTag:'h2', accept:['h2','p','b','i','u','link'], features:['bold','italic','underline','link','headings'], allowMultipleParagraphs:false} \u2014 for a field bound to ONE element on the page: `hostTag` is that element's tag and `accept` must name it, so the editor's block-style control opens already showing 'Heading 2' instead of 'Paragraph'. Name exactly ONE heading level: publish refuses an <h3> value bound to an <h2> rather than nesting one heading inside the other. Keep 'p' in `accept` or the sanitiser strips a block demoted to Paragraph"
2958
2978
  ),
2959
2979
  fields: z.array(z.lazy(() => fieldObject)).optional().describe(
2960
2980
  "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."
@@ -2970,8 +2990,8 @@ var fieldShape = {
2970
2990
  rules: z.array(
2971
2991
  z.object({
2972
2992
  field: z.string().describe("the BARE KEY of another field on this same model"),
2973
- op: z.enum(["eq", "neq", "in"]),
2974
- value: z.union([z.string(), z.array(z.string())])
2993
+ op: z.enum(["eq", "neq", "in", "isSet"]),
2994
+ value: z.union([z.string(), z.array(z.string())]).optional().describe("omit for 'isSet' (the source holds any value); required for every other op")
2975
2995
  })
2976
2996
  )
2977
2997
  }).optional().describe(
@@ -2991,7 +3011,9 @@ var fieldShape = {
2991
3011
  searchable: z.boolean().optional().describe("omit (\u2261 true) to index this field for site search; false excludes it"),
2992
3012
  unique: z.boolean().optional().describe(
2993
3013
  "true: no two entries of this model may hold the same value (compared trimmed and case-insensitively). On a repeater child such as a variant SKU it also covers every row of every entry. Only on text, longtext, slug, email, phone, link and number. A clash is refused with 409 `unique_conflict`."
2994
- )
3014
+ ),
3015
+ readOnly: z.union([z.literal(true), z.literal("developer")]).optional().describe(PARAM_TEXT.readOnly),
3016
+ lockReason: z.string().max(200).optional().describe(PARAM_TEXT.lockReason)
2995
3017
  };
2996
3018
  var fieldObject = z.object(fieldShape);
2997
3019
  var urlPatternArg = z.string().nullable().optional().describe(
@@ -3016,7 +3038,7 @@ var fieldsetsArg = z.array(
3016
3038
  );
3017
3039
  var dropProvenance = (sets) => sets?.map(({ uiOrigin: _origin, uiBefore: _before, ...set }) => set) ?? sets;
3018
3040
  var RICH_TEXT_NOTE = "\u{1F534} A field of type 'text' is created as RICH TEXT unless you send `richText: false` on it. Send `richText: false` for every value that is not prose: a name used as a title, a URL, slug, id, SKU, email, phone, CSS class or icon name.";
3019
- var SCHEMA_ORG_TYPES = ["WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness"];
3041
+ var SCHEMA_ORG_TYPES = ["WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness", "JobPosting", "Course", "Recipe", "HowTo", "Service", "Person", "VideoObject"];
3020
3042
  var changefreq = z.enum(["auto", "always", "hourly", "daily", "weekly", "monthly", "yearly", "never"]);
3021
3043
  var jsonObject = z.record(z.string(), z.unknown());
3022
3044
  var seoPatchShape = {
@@ -3030,7 +3052,7 @@ var seoPatchShape = {
3030
3052
  collectionSchemaTypes: z.record(z.string().min(1).max(64), z.enum(SCHEMA_ORG_TYPES)).optional()
3031
3053
  }).optional().describe(SEO_KEY_TEXT.seoDefaults),
3032
3054
  siteSchema: z.union([jsonObject, z.array(jsonObject)]).optional().describe(SEO_KEY_TEXT.siteSchema),
3033
- robotsConfig: z.object({ mode: z.enum(["auto", "custom"]), custom: z.string().max(1e4).optional() }).optional().describe(SEO_KEY_TEXT.robotsConfig),
3055
+ robotsConfig: z.object({ mode: z.enum(["auto", "custom"]), custom: z.string().max(1e4).optional(), disallow: z.array(z.enum(["GPTBot", "OAI-SearchBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "anthropic-ai", "PerplexityBot", "Google-Extended", "CCBot", "Bytespider", "Applebot-Extended", "Amazonbot", "Meta-ExternalAgent"])).max(32).optional() }).optional().describe(SEO_KEY_TEXT.robotsConfig),
3034
3056
  sitemapConfig: z.object({
3035
3057
  enabled: z.boolean(),
3036
3058
  mode: z.enum(["auto", "custom"]).optional(),
@@ -3079,6 +3101,8 @@ function proseType(f) {
3079
3101
  }
3080
3102
  var confirmedHeaders = (confirmed) => confirmed === true ? { [INPUT_CONFIRMED_HEADER]: "true" } : {};
3081
3103
  var withHeaders = (headers) => Object.keys(headers).length > 0 ? { headers } : {};
3104
+ var DRY_RUN_SUMMARY = "Dry run: nothing was written.";
3105
+ var dryRunOf = async (client, method, path, body, headers = {}) => ok(DRY_RUN_SUMMARY, await client.fetchJSON(client.url(path), { method, body: JSON.stringify({ ...body, dryRun: true }), ...withHeaders(headers) }));
3082
3106
  function toField(f) {
3083
3107
  const base = {
3084
3108
  key: f.key,
@@ -3093,7 +3117,10 @@ function toField(f) {
3093
3117
  ...f.defaultValue !== void 0 ? { defaultValue: f.defaultValue } : {},
3094
3118
  ...f.searchable !== void 0 ? { searchable: f.searchable } : {},
3095
3119
  // BE-6: a leaf key like the four above, so it rides on `base` and survives every branch.
3096
- ...f.unique !== void 0 ? { unique: f.unique } : {}
3120
+ ...f.unique !== void 0 ? { unique: f.unique } : {},
3121
+ // The developer lock: stripped here, an agent writing a model back would unlock every field.
3122
+ ...f.readOnly !== void 0 ? { readOnly: f.readOnly } : {},
3123
+ ...f.lockReason !== void 0 ? { lockReason: f.lockReason } : {}
3097
3124
  };
3098
3125
  if (f.type === "group") {
3099
3126
  return { ...base, type: "array", config: { zones: { nonRepeatable: toFields(f.fields) } } };
@@ -3338,7 +3365,7 @@ function buildToolDefs(deps) {
3338
3365
  ]).describe("block type; section/slider/tabs/columns nest child blocks"),
3339
3366
  id: z.string().min(1).describe("stable unique block id"),
3340
3367
  props: z.record(z.string(), z.unknown()).describe(
3341
- "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}; 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?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>"
3368
+ "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"
3342
3369
  ),
3343
3370
  style: z.record(z.string(), z.unknown()).optional().describe(
3344
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."
@@ -3391,7 +3418,8 @@ function buildToolDefs(deps) {
3391
3418
  fields: z.array(fieldObject).optional().describe(
3392
3419
  "the model's typed schema fields. 'group'/'repeater' NEST their child fields (any depth) \u2014 don't flatten zones into top-level fields."
3393
3420
  ),
3394
- fieldsets: fieldsetsArg
3421
+ fieldsets: fieldsetsArg,
3422
+ dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun)
3395
3423
  });
3396
3424
  const afterArg = z.string().min(1).optional().describe(
3397
3425
  "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."
@@ -3399,6 +3427,7 @@ function buildToolDefs(deps) {
3399
3427
  const addFieldInput = z.object({
3400
3428
  modelId: z.string().min(1).optional().describe("the content model to extend (from list_content_models); or send pageId"),
3401
3429
  pageId: z.string().min(1).optional().describe("instead of modelId: the page whose schema to extend (from list_pages / create_page)"),
3430
+ dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun),
3402
3431
  ...fieldShape,
3403
3432
  after: afterArg,
3404
3433
  confirmed: z.boolean().optional().describe(PARAM_TEXT.confirmedInput)
@@ -3413,13 +3442,20 @@ function buildToolDefs(deps) {
3413
3442
  transcript: z.string().max(1e5).optional().describe("video/audio only: the spoken words (\u2264 100,000 chars), published wherever the file is used"),
3414
3443
  folderId: z.string().optional().describe("target Media Library folder (defaults to project root)")
3415
3444
  });
3445
+ const upsertShape = {
3446
+ externalKey: z.string().min(1).max(255).optional().describe(PARAM_TEXT.externalKey),
3447
+ source: z.string().max(2048).optional().describe(PARAM_TEXT.upsertSource),
3448
+ runId: z.string().max(100).optional().describe(PARAM_TEXT.upsertRunId)
3449
+ };
3416
3450
  const createEntryInput = z.object({
3417
3451
  contentModelId: z.string().min(1).describe("id of the model this entry belongs to"),
3418
3452
  slug: slug.optional(),
3419
3453
  status: z.enum(["draft", "published"]).optional().describe("defaults to draft"),
3420
3454
  data: z.record(z.string(), z.unknown()).optional().describe("field values keyed by field key"),
3421
3455
  locale: z.string().nullable().optional().describe(PARAM_TEXT.locale),
3422
- translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf)
3456
+ translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf),
3457
+ dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun),
3458
+ ...upsertShape
3423
3459
  });
3424
3460
  const getPageInput = z.object({
3425
3461
  pageId: z.string().min(1).describe(PARAM_TEXT.pageRef)
@@ -3439,6 +3475,7 @@ function buildToolDefs(deps) {
3439
3475
  modelId: z.string().optional().describe("filter by content model id"),
3440
3476
  pageId: z.string().optional().describe("filter by page id (a singleton page has one entry)"),
3441
3477
  status: z.enum(["draft", "published"]).optional(),
3478
+ externalKey: z.string().optional().describe(PARAM_TEXT.externalKeyFilter),
3442
3479
  where: z.record(z.string(), z.unknown()).optional().describe(PARAM_TEXT.where),
3443
3480
  sort: z.string().max(100).optional().describe(PARAM_TEXT.sort),
3444
3481
  page: z.number().int().min(1).optional().describe(PARAM_TEXT.page),
@@ -3456,7 +3493,7 @@ function buildToolDefs(deps) {
3456
3493
  metaDescription: clearableText(500),
3457
3494
  noindex: z.boolean().optional().describe("hide this entry from search engines and site search"),
3458
3495
  ...headPatchShape,
3459
- schemaType: z.enum(["WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness"]).nullable().optional(),
3496
+ schemaType: z.enum(SCHEMA_ORG_TYPES).nullable().optional(),
3460
3497
  schema: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).nullable().optional()
3461
3498
  });
3462
3499
  const updateEntryInput = z.object({
@@ -3587,6 +3624,17 @@ function buildToolDefs(deps) {
3587
3624
  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."),
3588
3625
  helpText: z.string().max(200).optional().describe("hint shown under the control, muted (\u2264 200 chars)"),
3589
3626
  placeholder: z.string().max(120).optional().describe("ghost text inside a text-ish control (\u2264 120 chars)"),
3627
+ // Stored markers on a prop: nothing refuses a prop value for them. Declared so an echo keeps them.
3628
+ readOnly: z.union([z.literal(true), z.literal("developer")]).optional(),
3629
+ lockReason: z.string().max(200).optional(),
3630
+ showIf: z.object({
3631
+ match: z.enum(["all", "any"]),
3632
+ rules: z.array(z.object({
3633
+ field: z.string().min(1),
3634
+ op: z.enum(["eq", "neq", "in", "isSet"]),
3635
+ value: z.union([z.string(), z.array(z.string())]).optional()
3636
+ })).min(1).max(20)
3637
+ }).optional().describe("show this prop only while SIBLING props (by key) match: {match:'all'|'any', rules:[{field, op:'eq'|'neq'|'in'|'isSet', value?}]}. 'isSet' takes no value. Editor-only."),
3590
3638
  // ⚠ A STRIP SITE, exactly like OutField above: this is a `z.object`, so a key the model
3591
3639
  // sends and this shape does not declare is dropped BEFORE the request leaves — a 200 with
3592
3640
  // the declaration gone. `ui` rode through the backend's looseObject `config` and died here.
@@ -3669,7 +3717,7 @@ function buildToolDefs(deps) {
3669
3717
  description: z.string().optional().describe(PARAM_TEXT.describes),
3670
3718
  blockJson: z.array(blockObject).default([]).describe("the component's block tree"),
3671
3719
  props: z.array(componentPropObject).default([]).describe(
3672
- "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."
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)."
3673
3721
  )
3674
3722
  });
3675
3723
  const updateComponentInput = z.object({
@@ -3729,7 +3777,8 @@ function buildToolDefs(deps) {
3729
3777
  slug: z.string().min(1).optional().describe("a page slug, when one page has it; or pass `pageId`"),
3730
3778
  blockJson: z.array(blockObject).describe("REPLACES this page's whole block composition")
3731
3779
  })
3732
- ).min(1).max(50)
3780
+ ).min(1).max(50),
3781
+ dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun)
3733
3782
  });
3734
3783
  const listComponentsInput = z.object({
3735
3784
  detail: z.boolean().optional().describe("true = every component's full blockJson and props. Costly and rarely what you want \u2014 read one definition with get_component instead.")
@@ -3795,16 +3844,17 @@ function buildToolDefs(deps) {
3795
3844
  def(
3796
3845
  "update_media",
3797
3846
  "Update a media asset",
3798
- "Describe an existing media asset: set its alt text, caption, transcript or focal point (focalX/focalY, the spot crops keep in frame; delivered images carry it with width, height and thumbHash). A transcript (video/audio only, \u2264 100,000 chars) is stored ON the asset, so every page and entry using the file publishes it \u2014 hosted pages, the .md twin, llms-full.txt and the delivery API. Pass null to clear a field. Use this after your own speech-to-text; BetterCMS does not generate transcripts.",
3847
+ "Describe an existing media asset: set its alt text, caption, transcript, `decorative` (no alt text needed) or focal point (focalX/focalY, the spot crops keep in frame; delivered images carry it with width, height and thumbHash). A transcript (video/audio only, \u2264 100,000 chars) is stored ON the asset, so every page and entry using the file publishes it \u2014 hosted pages, the .md twin, llms-full.txt and the delivery API. Pass null to clear a field. Use this after your own speech-to-text; BetterCMS does not generate transcripts.",
3799
3848
  z.object({
3800
3849
  assetId: z.string().min(1).describe("media asset id (from list_media / upload_asset)"),
3801
3850
  altText: z.string().max(500).nullable().optional().describe("alt text; null clears"),
3802
3851
  caption: z.string().max(1e3).nullable().optional().describe("caption; null clears"),
3803
3852
  transcript: z.string().max(1e5).nullable().optional().describe("the spoken words; null clears. Video/audio only."),
3853
+ decorative: z.boolean().optional().describe("needs no alt text"),
3804
3854
  focalX: z.number().min(0).max(1).nullable().optional().describe(PARAM_TEXT.focalX),
3805
3855
  focalY: z.number().min(0).max(1).nullable().optional().describe(PARAM_TEXT.focalY)
3806
3856
  }).shape,
3807
- async (c, a) => ok("Updated media asset.", await data(c, "PATCH", `/management/media/${encodeURIComponent(s(a.assetId))}`, { altText: a.altText, caption: a.caption, transcript: a.transcript, focalX: a.focalX, focalY: a.focalY }))
3857
+ async (c, a) => ok("Updated media asset.", await data(c, "PATCH", `/management/media/${encodeURIComponent(s(a.assetId))}`, { altText: a.altText, caption: a.caption, transcript: a.transcript, decorative: a.decorative, focalX: a.focalX, focalY: a.focalY }))
3808
3858
  ),
3809
3859
  def(
3810
3860
  "list_media",
@@ -3994,15 +4044,27 @@ ${d.outline}` : "Proposed Content structure.", d);
3994
4044
  "update_redirect",
3995
4045
  "Update a redirect",
3996
4046
  "Update an existing redirect in place \u2014 change where it points, its HTTP status, or disable it without deleting. Prefer this over delete+create: it keeps the redirect's id and preserves the chain-collapse rewrites of other rules pointing at it. Only provided fields change. Loops 422, duplicate sources 409.",
3997
- z.object({ redirectId: z.string().min(1).describe("redirect id (from list_redirects)"), sourcePath: z.string().min(1).optional(), destination: z.string().min(1).optional(), redirectType: z.enum(["301", "302", "307", "308"]).optional(), isActive: z.boolean().optional().describe("set false to disable without deleting") }).shape,
4047
+ z.object({ redirectId: z.string().min(1).describe("redirect id (from list_redirects)"), sourcePath: z.string().min(1).optional(), destination: z.string().min(1).optional(), redirectType: z.enum(["301", "302", "307", "308", "410"]).optional().describe("HTTP status; 410 = Gone"), isActive: z.boolean().optional().describe("set false to disable without deleting") }).shape,
3998
4048
  async (c, a) => ok("Updated redirect.", await data(c, "PATCH", `/management/redirects/${s(a.redirectId)}`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType, isActive: a.isActive }))
3999
4049
  ),
4000
4050
  def(
4001
4051
  "create_redirect",
4002
4052
  "Create a redirect",
4003
4053
  "Create a URL redirect \u2014 e.g. a 301 after renaming a page's slug. `sourcePath` is the path to redirect FROM ('/old-page'), `destination` the path/url TO ('/new-page'). Chains collapse; loops/dupes rejected. Defaults to 301.",
4004
- z.object({ sourcePath: z.string().min(1), destination: z.string().min(1), redirectType: z.enum(["301", "302", "307", "308"]).optional() }).shape,
4005
- async (c, a) => ok("Created redirect.", await data(c, "POST", `/management/redirects`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType }))
4054
+ z.object({ sourcePath: z.string().min(1), destination: z.string().min(1).optional(), redirectType: z.enum(["301", "302", "307", "308", "410"]).optional().describe("default '301'; 410 = Gone, omit destination"), dryRun: z.boolean().optional().describe("true = return the verdict, write nothing") }).shape,
4055
+ async (c, a) => ok(a.dryRun === true ? "Dry run: nothing written." : "Created redirect.", await data(c, "POST", `/management/redirects`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType, dryRun: a.dryRun }))
4056
+ ),
4057
+ def(
4058
+ "import_redirects",
4059
+ "Import redirects",
4060
+ NEW_TOOL_TEXT.import_redirects,
4061
+ z.object({
4062
+ rows: z.array(z.record(z.string(), z.unknown())).optional().describe("[{ sourcePath, destination?, redirectType? }]"),
4063
+ text: z.string().optional().describe("the file's contents, instead of rows"),
4064
+ format: z.enum(["csv", "_redirects", "vercel"]).optional().describe("of `text` (default 'csv')"),
4065
+ dryRun: z.boolean().optional().describe("default false")
4066
+ }).shape,
4067
+ async (c, a) => ok(a.dryRun === true ? "Dry run: nothing written." : "Imported redirects.", await data(c, "POST", `/management/redirects/import`, { rows: a.rows, text: a.text, format: a.format, dryRun: a.dryRun }))
4006
4068
  ),
4007
4069
  def(
4008
4070
  "delete_redirect",
@@ -4336,7 +4398,7 @@ ${d.outline}` : "Proposed Content structure.", d);
4336
4398
  "update_page",
4337
4399
  "Edit a page",
4338
4400
  "Edit a page: title, slug, SEO metaTitle/metaDescription, structured data (`schemaType`, `schema`), publish status (draft|published), and `blockJson` (its block composition \u2014 passing it REPLACES the whole array, so read get_page first and send its `optimisticVersion` as `ifMatch`). A `blockJson` that swaps a component block to another component comes back with `warnings` (COMPONENT_SWAP_DROPS_OVERRIDES) naming the overrides the new component does not render; they stay on the block, so swapping back restores them. It does NOT change the field SCHEMA \u2014 use add_field (pageId) / set_page_content for that. Renaming the slug keeps content intact. Publishing copies the draft blocks live in the same call \u2014 and on a singleton it publishes the page's content entry too; if the entry's workflow stage refuses, the page still goes live and `warnings` carries ENTRY_STILL_DRAFT with the entry id and the reason. STRUCTURED DATA is native SEO, never a field: leave it out and the page gets Automatic JSON-LD from its content (WebSite on the home page, Blog/CollectionPage on a collection's list page, WebPage otherwise); `schemaType` picks an explicit schema.org type whose properties are filled from the content; `schema` is pasted JSON-LD and wins over both (null clears it). " + PAGE_HEAD_NOTE + " " + SECTION_DOCTRINE,
4339
- z.object({ ...headPatchShape, noindex: z.boolean().optional().describe("hide this page from search engines and site search"), pageId: z.string().min(1).describe(PARAM_TEXT.pageRef), title: z.string().optional(), slug: z.string().optional(), blockJson: z.array(blockObject).optional().describe("REPLACES the page's block composition"), metaTitle: z.string().optional(), metaDescription: z.string().optional(), schemaType: z.enum(["auto", "WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness"]).optional().describe("structured data type; 'auto' (the default) derives it from the content"), schema: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).nullable().optional().describe("custom JSON-LD; wins over schemaType; null clears it"), status: z.enum(["draft", "published"]).optional(), scheduledAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageScheduledAt), unpublishAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageUnpublishAt), locale: z.string().nullable().optional().describe(PARAM_TEXT.locale), translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf), ifMatch: z.number().int().min(0).optional().describe(UPDATE_PAGE_IF_MATCH_NOTE), dynamic: z.object({ previewPath: z.string().nullable().optional().describe(PARAM_TEXT.previewPath), modelId: z.string().min(1).optional().describe(PARAM_TEXT.pageModelId) }).optional().describe(PARAM_TEXT.pageDynamic) }).shape,
4401
+ z.object({ ...headPatchShape, noindex: z.boolean().optional().describe("hide this page from search engines and site search"), pageId: z.string().min(1).describe(PARAM_TEXT.pageRef), title: z.string().optional(), slug: z.string().optional(), blockJson: z.array(blockObject).optional().describe("REPLACES the page's block composition"), metaTitle: z.string().optional(), metaDescription: z.string().optional(), schemaType: z.enum(["auto", ...SCHEMA_ORG_TYPES]).optional().describe("structured data type; 'auto' (the default) derives it from the content"), schema: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).nullable().optional().describe("custom JSON-LD; wins over schemaType; null clears it"), status: z.enum(["draft", "published"]).optional(), scheduledAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageScheduledAt), unpublishAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageUnpublishAt), locale: z.string().nullable().optional().describe(PARAM_TEXT.locale), translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf), ifMatch: z.number().int().min(0).optional().describe(UPDATE_PAGE_IF_MATCH_NOTE), dynamic: z.object({ previewPath: z.string().nullable().optional().describe(PARAM_TEXT.previewPath), modelId: z.string().min(1).optional().describe(PARAM_TEXT.pageModelId) }).optional().describe(PARAM_TEXT.pageDynamic) }).shape,
4340
4402
  async (c, a) => {
4341
4403
  const res = await c.fetchJSON(
4342
4404
  c.url(`/management/pages/${s(a.pageId)}/meta`),
@@ -4494,6 +4556,8 @@ ${res.warnings.join("\n")}` : summary, res.data);
4494
4556
  showIf: z.record(z.string(), z.unknown()).nullable().optional().describe("same shape as on add_field; null clears it"),
4495
4557
  ui: z.record(z.string(), z.unknown()).nullable().optional().describe("same vocabulary as on add_field; null clears it"),
4496
4558
  placement: z.enum(["cover", "title", "excerpt", "body", "inline", "panel"]).nullable().optional(),
4559
+ readOnly: z.union([z.literal(true), z.literal("developer")]).nullable().optional().describe(PARAM_TEXT.lockClears),
4560
+ lockReason: z.string().max(200).nullable().optional().describe(PARAM_TEXT.lockClears),
4497
4561
  type: fieldType.optional().describe("a new type; needs allowRetype"),
4498
4562
  key: fieldKey.optional().describe("a new key (flat namespace, unique in the schema)"),
4499
4563
  allowRetype: z.boolean().optional().describe("confirm a type change, once the user has agreed"),
@@ -4604,10 +4668,11 @@ ${res.warnings.join("\n")}` : summary, res.data);
4604
4668
  NEW_TOOL_TEXT.create_content_entries,
4605
4669
  z.object({
4606
4670
  contentModelId: z.string().min(1).describe("the collection every entry belongs to (from list_content_models)"),
4607
- entries: z.array(z.object({ slug: z.string().optional(), data: z.record(z.string(), z.unknown()) })).min(1).max(200).describe("1-200 entries, each {slug?, data}")
4671
+ entries: z.array(z.object({ slug: z.string().optional(), data: z.record(z.string(), z.unknown()), ...upsertShape })).min(1).max(200).describe("1-200 entries, each {slug?, data}"),
4672
+ dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun)
4608
4673
  }).shape,
4609
4674
  // The whole body: the per-row receipt is `data`, the counts ride in `meta`.
4610
- async (c, a) => ok("Batch receipt.", await c.fetchJSON(c.url(`/management/content/entries/batch`), { method: "POST", body: JSON.stringify({ contentModelId: a.contentModelId, entries: a.entries }) }))
4675
+ async (c, a) => ok(a.dryRun ? DRY_RUN_SUMMARY : "Batch receipt.", await c.fetchJSON(c.url(`/management/content/entries/batch`), { method: "POST", body: JSON.stringify({ contentModelId: a.contentModelId, entries: a.entries, dryRun: a.dryRun }) }))
4611
4676
  ),
4612
4677
  def(
4613
4678
  "translate_entry",
@@ -4800,7 +4865,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
4800
4865
  def(
4801
4866
  "list_seo_issues",
4802
4867
  "List SEO issues",
4803
- "Scan every published page for SEO problems (missing or over-long titles and descriptions, missing OG image, noindex, absent schema) and return them with severities. Computed live \u2014 nothing is stored.",
4868
+ "Scan every published page for SEO problems (missing or over-long titles and descriptions, missing OG image, noindex, invalid pasted schema) and return them with severities. Also reports accessibility issues as `a11y.*` codes with block ids. Computed live \u2014 nothing is stored.",
4804
4869
  z.object({}).shape,
4805
4870
  async (c) => ok("SEO issues.", await data(c, "GET", `/management/insights/seo-issues`))
4806
4871
  ),
@@ -5142,7 +5207,7 @@ ${notes.join("\n")}` : summary, created);
5142
5207
  async (args) => withClient(async (client) => {
5143
5208
  const modelDupes = duplicateKeys(flatFieldKeys(args.fields));
5144
5209
  if (modelDupes.length > 0) return fail(duplicateKeyFailure(modelDupes));
5145
- const model = await client.createModel({
5210
+ const input = {
5146
5211
  name: args.name,
5147
5212
  slug: args.slug,
5148
5213
  ...args.description !== void 0 ? { description: args.description } : {},
@@ -5150,7 +5215,9 @@ ${notes.join("\n")}` : summary, created);
5150
5215
  ...args.kind !== void 0 ? { kind: args.kind } : {},
5151
5216
  ...args.fieldsets !== void 0 ? { fieldsets: dropProvenance(args.fieldsets) } : {},
5152
5217
  fields: toFields(args.fields)
5153
- });
5218
+ };
5219
+ if (args.dryRun) return dryRunOf(client, "POST", `/management/content/models`, input);
5220
+ const model = await client.createModel(input);
5154
5221
  return ok(
5155
5222
  `Created content model '${model.name}' (${model.fields.length} field(s)).`,
5156
5223
  model
@@ -5168,6 +5235,15 @@ ${notes.join("\n")}` : summary, created);
5168
5235
  handler: guard(
5169
5236
  async (args) => withClient(async (client) => {
5170
5237
  if (!args.modelId === !args.pageId) return fail("Pass exactly one of modelId (a content model) or pageId (a page).");
5238
+ if (args.dryRun) {
5239
+ return dryRunOf(
5240
+ client,
5241
+ args.pageId ? "PATCH" : "POST",
5242
+ args.pageId ? `/management/pages/${args.pageId}` : `/management/content/models/${args.modelId}/fields`,
5243
+ { addFields: [toField(args)], ...args.after !== void 0 ? { after: args.after } : {} },
5244
+ confirmedHeaders(args.confirmed)
5245
+ );
5246
+ }
5171
5247
  if (args.pageId) {
5172
5248
  const input2 = {
5173
5249
  // Cast: the SDK types `options` as string[], narrower than the API, which also takes
@@ -5224,19 +5300,23 @@ ${notes.join("\n")}` : summary, created);
5224
5300
  },
5225
5301
  handler: guard(
5226
5302
  async (args) => withClient(async (client) => {
5227
- const created = await client.createEntry({
5303
+ const input = {
5228
5304
  contentModelId: args.contentModelId,
5229
5305
  ...args.slug !== void 0 ? { slug: args.slug } : {},
5230
5306
  ...args.data !== void 0 ? { data: args.data } : {},
5231
5307
  // The SDK's input type predates localization; its body is the input as given.
5232
5308
  ...args.locale !== void 0 ? { locale: args.locale } : {},
5233
- ...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {}
5234
- });
5309
+ ...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {},
5310
+ ...args.externalKey !== void 0 ? { externalKey: args.externalKey, source: args.source, runId: args.runId } : {}
5311
+ };
5312
+ if (args.dryRun) return dryRunOf(client, "POST", `/management/content/entries`, input);
5313
+ const created = await client.createEntry(input);
5235
5314
  const published = args.status !== void 0 && args.status !== "draft" ? await client.updateEntry(created.id, { status: args.status }) : created;
5236
5315
  const { warnings, ...entry } = published;
5237
5316
  const notes = [.../* @__PURE__ */ new Set([...created.warnings ?? [], ...warnings ?? []])];
5238
5317
  return ok(
5239
- [`Created entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, ...notes].join("\n"),
5318
+ // With an external key the write may have updated the entry, or left it unchanged.
5319
+ [`${args.externalKey ? "Stored" : "Created"} entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, ...notes].join("\n"),
5240
5320
  entry
5241
5321
  );
5242
5322
  })
@@ -5299,7 +5379,7 @@ ${notes.join("\n")}` : summary, created);
5299
5379
  name: "update_content_entry",
5300
5380
  config: {
5301
5381
  title: "Update a content entry's values",
5302
- description: "Update a content entry's `data` (field values) and/or status by id. `data` is keyed by field key; a nested 'array' (zone) value is an object { nonRepeatable: {\u2026}, repeatable: [{\u2026}] }, a primitive 'array' is a plain list; an image value may carry this placement's focal {x,y} and crop {top,right,bottom,left} (0\u20131). Use this to edit an existing entry; for a singleton page prefer set_page_content. \u{1F534} `data` REPLACES the entry's whole value object: an optional field you leave out is DELETED, and a required one you leave out fails the whole write. To change one field, pass merge:true, or read the entry (get_content_entry), change that key and send the whole object back. " + ENTRY_META_NOTE,
5382
+ description: "Update a content entry's `data` (field values) and/or status by id. `data` is keyed by field key; a nested 'array' (zone) value is an object { nonRepeatable: {\u2026}, repeatable: [{\u2026}] }, a primitive 'array' is a plain list; an image value may carry this placement's focal {x,y} and crop {top,right,bottom,left} (0\u20131). Use this to edit an existing entry; for a singleton page prefer set_page_content. \u{1F534} `data` REPLACES the entry's whole value object: an optional field you leave out is DELETED, and a required one you leave out fails the whole write. To change one field, pass merge:true, or read the entry (get_content_entry), change that key and send the whole object back. " + FIELD_LOCKED_NOTE + " " + ENTRY_META_NOTE,
5303
5383
  inputSchema: updateEntryInput.shape
5304
5384
  },
5305
5385
  handler: guard(
@@ -5678,14 +5758,17 @@ ${notes.join("\n")}` : summary, created);
5678
5758
  try {
5679
5759
  const written = await client.fetchJSON(
5680
5760
  client.url(`/management/pages/${pageId}/meta`),
5681
- { method: "PATCH", body: JSON.stringify({ blockJson: page.blockJson }) }
5761
+ { method: "PATCH", body: JSON.stringify({ blockJson: page.blockJson, ...args.dryRun ? { dryRun: true } : {} }) }
5682
5762
  );
5683
- results.push({ ok: true, pageId, slug: written.data?.slug ?? page.slug, path: written.data?.path, blocks: page.blockJson.length });
5763
+ results.push({ ok: true, ...args.dryRun ? { status: "would-update" } : {}, pageId, slug: written.data?.slug ?? page.slug, path: written.data?.path, blocks: page.blockJson.length });
5684
5764
  } catch (err) {
5685
5765
  results.push({ ok: false, pageId, slug: page.slug, error: errorText(err) });
5686
5766
  }
5687
5767
  }
5688
5768
  const failed = results.filter((r) => !r.ok).length;
5769
+ if (args.dryRun) {
5770
+ return ok(DRY_RUN_SUMMARY, { dryRun: true, results, wouldCompose: results.length - failed, failed });
5771
+ }
5689
5772
  return ok(
5690
5773
  `Composed ${results.length - failed} of ${args.pages.length} page(s)${failed ? `; ${failed} failed \u2014 see the per-item errors` : ""}. These are page DRAFTS: publish each with update_page status:'published'. Then call get_site_composition for the receipt.`,
5691
5774
  results
@@ -6801,13 +6884,13 @@ delivered value \u2014 they change editor chrome only.
6801
6884
  { "key": "testimonials", "label": "Testimonials", "type": "repeater",
6802
6885
  "ui": { "collapsed": true, "preview": { "title": "author", "media": "avatar" },
6803
6886
  "layout": "grid", "icon": "double-quote", "itemLabel": "Testimonial" },
6804
- "helpText": "Three to five. Shortest quotes read best.",
6887
+ "helpText": "Three to five.",
6805
6888
  "fields": [ { "key": "author", ... }, { "key": "avatar", "type": "image", ... }, ... ] }
6806
6889
  \`\`\`
6807
6890
 
6808
- - **\`helpText\`** \u2014 one line UNDER the field telling the author what to write here, not what the
6809
- field is. "Roughly 60 characters; it becomes the browser tab title" earns its place.
6810
- "The title field" does not.
6891
+ - **\`helpText\`** \u2014 the exception: most fields need none. Write it only when the author needs it to
6892
+ type the right value: a format or unit ("Price in cents."), a placement the canvas cannot show, a
6893
+ real constraint, a fallback. Never restate the label ("The title field"), never coach ("Keep it short").
6811
6894
  - **\`showIf\`** \u2014 hide a field until another has a value:
6812
6895
  \`{match:'all',rules:[{field:'link_type',op:'eq',value:'external'}]}\`. Rules name fields by
6813
6896
  BARE KEY, and the key namespace is FLAT across the model (\xA7 the duplicate-key rule), so the
@@ -7040,9 +7123,10 @@ Do not stop at the plan or at a partial receipt \u2014 finish the whole site, ne
7040
7123
  10. \`get_next_steps\`: if it lists \`upgrade-section-roots\` (section spacing and style handles), in the repo run
7041
7124
  \`npx @bettercms-ai/convert@latest --upgrade-sections --root . --dry-run\`, read the plan, then run it for real. For every
7042
7125
  root its receipt lists as \`skipped\`, pass the block down by hand: \`{...sectionRootAttrs(block)}\` on the section root, or
7043
- \`data-bcms-style=""\` plus \`style={...}\` from \`block.style\`. Run \`--verify-styles\` again and restore what it lists (the
7044
- marker and the inline style from \`block.style\` are allowed; changing a class or an existing style is not). Deploy,
7045
- poll, call \`get_next_steps\` again and tell me what it still lists. Not listed: say so and move on; invent no work.
7126
+ \`data-bcms-style=""\` plus \`style={...}\` from \`block.style\`. For \`upgrade-element-styles\` (field width, spacing, text handles)
7127
+ do the same with \`npx @bettercms-ai/convert@latest --upgrade-element-styles --root . --dry-run\`. Run \`--verify-styles\` again
7128
+ and restore what it lists (the marker, and the inline style from \`block.style\` or \`fieldAttrs\`, are allowed; changing a class
7129
+ or an existing style is not). Deploy, poll, call \`get_next_steps\` again, tell me what it still lists. Not listed: say so; invent no work.
7046
7130
  11. Ask me whether to commit and push to my repository, commit only, or leave it uncommitted (unpushed, the
7047
7131
  next rebuild from my repo drops it). Touch git only as I answer.`;
7048
7132
  var FIELD_UI_PROMPT = {
@@ -7155,8 +7239,9 @@ error-prone, so go slow and confirm. Never guess the layout.
7155
7239
  the same prop keys across a family or a swap drops content.
7156
7240
  4. **Props** \u2014 declare only what should really be editable:
7157
7241
  { key, label, target: { blockId, path },
7158
- type: text|richtext|image|url|boolean|number|select|group|table|slot|form }. 'select' needs
7159
- config.options; 'group'/'table' need config.fields; 'slot' holds ONE nested component;
7242
+ type: text|richtext|image|url|boolean|number|select|group|table|slot|form|link|button|date|color|file|icon|multiselect }.
7243
+ '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;
7160
7245
  'form' holds ONE Form document \u2014 point target.path at 'props' on an empty form block and set
7161
7246
  defaultValue to {formId}. Never re-declare a form's FIELDS as props: the Form document owns
7162
7247
  them, and a second copy is a second source of truth that drifts on the first edit.