@bettercms-ai/mcp 0.49.0 → 0.50.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
@@ -2410,7 +2410,11 @@ function toField(f) {
2410
2410
  ...f.config ? { config: f.config } : {}
2411
2411
  };
2412
2412
  }
2413
- var STRUCTURE_NOTE = 'ORGANISATION ONLY: folders never change a URL, slug, content or publish state, and nothing is rebuilt or delivered. Folders nest at most 4 levels deep (a page or collection may sit in a level-4 folder). Icons are lucide icon names, e.g. "folder", "file-text", "rows-3", "house" (a-z, 0-9 and dashes); null clears one. SYSTEM FOLDERS (CPO-129): "home:pages" (Other pages), "home:collections" (Shared) and "home:globals" (Settings) always exist, even when the document has no record for them. They close the sidebar, below the folders you make, and can never be moved or deleted (400 NAV_SYSTEM_FOLDER), but rename_folder and set_icon work on them and create their record on first use. TYPE PURITY: anything inside Other pages, at any depth, must be a page, and anything inside Shared must be a collection, and Settings holds NO pages or collections at all, because globals are not sidebar items (subfolders under Settings are allowed but stay empty). Any of these is 400 NAV_TYPE_MISMATCH. Your own folders hold either kind. ACCESS (CPO-131): Requires Admin or Developer access to CHANGE the structure, the same access as editing the schema; anyone who can read content can call get_content_structure. A 403 SCHEMA_ACCESS_REQUIRED means the person behind this connection is not an Admin or Developer: stop and tell the user, do not retry. ROOT PINS (CPO-132): folderId \'root\' pins a page or collection at the top level; null returns it to its home (Other pages / Shared). Pins sit above the folders, in their own order, and may be pages or collections. In a document a pin is folderId "home:root", a virtual id with no folder record (a folder record with that id is 400 NAV_SYSTEM_FOLDER). KIND CHECK: an id is either a page or a collection, never both, and a custom folder holds either \u2014 so move_to_folder and set_icon VERIFY that the id really is the `kind` you named on this branch, and refuse a mismatch with 400 NAV_KIND_MISMATCH naming the kind to use; call it again with that kind, which also clears the wrong-kind entry. An id that resolves to nothing is allowed (it may exist on another branch) and the result says so. set_content_structure never refuses: it returns `warnings` and `kindMismatches`. get_content_structure marks such items `kindMismatch: true` and lists them in `summary.kindMismatches`. PAGE-BOUND MODELS (CPO-135): a page with fields has a content model bound to it. That model is NOT a collection \u2014 get_content_structure never lists it among collections or in the tree, and move_to_folder / set_icon / set_content_structure refuse it with 400 NAV_PAGE_BOUND_MODEL naming the owning page (\'This model belongs to the page "\u2026"; organise the page.\'). Organise the PAGE instead, by its page id. If a stored document still references one it appears in `summary.hiddenItems` with `boundToPageId`, and the next single write drops it.';
2413
+ var BRAND_KIT_NOTE = "`brandKit` is the project's brand kit, read-only: colors, typography, radius and any typeScale/spacing/elevation/motion. It is edited in the BetterCMS dashboard; style sections with their `style` tokens rather than copying its values into props.";
2414
+ var MODEL_IF_MATCH_NOTE = "optimisticVersion from get_content_model. When sent, the update applies only if the model is still at that version; a 409 means someone changed it since \u2014 read it again and re-apply, never retry blindly. Omitted, the last write wins.";
2415
+ var ENTRY_META_NOTE = "SEO is native: set this entry's metaTitle, metaDescription, noindex, canonical, og, twitter, schemaType or schema in `meta` (never as model fields). `meta` MERGES into what is stored: a key you leave out is kept, null or an empty string clears it.";
2416
+ var PAGE_HEAD_NOTE = "noindex, canonical, og and twitter set the page's head extras; each MERGES into what is stored (a key you leave out is kept, null or an empty string clears it).";
2417
+ var STRUCTURE_NOTE = 'ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state, and nothing is rebuilt or delivered; URL folders (page folders) are set in the dashboard. Folders nest at most 4 levels deep (a page or collection may sit in a level-4 folder). Icons are lucide icon names, e.g. "folder", "file-text", "rows-3", "house" (a-z, 0-9 and dashes); null clears one. SYSTEM FOLDERS (CPO-129): "home:pages" (Other pages), "home:collections" (Shared) and "home:globals" (Settings) always exist, even when the document has no record for them. They close the sidebar, below the folders you make, and can never be moved or deleted (400 NAV_SYSTEM_FOLDER), but rename_folder and set_icon work on them and create their record on first use. TYPE PURITY: anything inside Other pages, at any depth, must be a page, and anything inside Shared must be a collection, and Settings holds NO pages or collections at all, because globals are not sidebar items (subfolders under Settings are allowed but stay empty). Any of these is 400 NAV_TYPE_MISMATCH. Your own folders hold either kind. ACCESS (CPO-131): Requires Admin or Developer access to CHANGE the structure, the same access as editing the schema; anyone who can read content can call get_content_structure. A 403 SCHEMA_ACCESS_REQUIRED means the person behind this connection is not an Admin or Developer: stop and tell the user, do not retry. ROOT PINS (CPO-132): folderId \'root\' pins a page or collection at the top level; null returns it to its home (Other pages / Shared). Pins sit above the folders, in their own order, and may be pages or collections. In a document a pin is folderId "home:root", a virtual id with no folder record (a folder record with that id is 400 NAV_SYSTEM_FOLDER). KIND CHECK: an id is either a page or a collection, never both, and a custom folder holds either \u2014 so move_to_folder and set_icon VERIFY that the id really is the `kind` you named on this branch, and refuse a mismatch with 400 NAV_KIND_MISMATCH naming the kind to use; call it again with that kind, which also clears the wrong-kind entry. An id that resolves to nothing is allowed (it may exist on another branch) and the result says so. set_content_structure never refuses: it returns `warnings` and `kindMismatches`. get_content_structure marks such items `kindMismatch: true` and lists them in `summary.kindMismatches`. PAGE-BOUND MODELS (CPO-135): a page with fields has a content model bound to it. That model is NOT a collection \u2014 get_content_structure never lists it among collections or in the tree, and move_to_folder / set_icon / set_content_structure refuse it with 400 NAV_PAGE_BOUND_MODEL naming the owning page (\'This model belongs to the page "\u2026"; organise the page.\'). Organise the PAGE instead, by its page id. If a stored document still references one it appears in `summary.hiddenItems` with `boundToPageId`, and the next single write drops it.';
2414
2418
  function structureResult(d) {
2415
2419
  const message = d?.message;
2416
2420
  return ok(typeof message === "string" ? message : "Updated the Content structure.", d);
@@ -2469,6 +2473,19 @@ function errorText(err) {
2469
2473
  }
2470
2474
  return err instanceof Error ? err.message : String(err);
2471
2475
  }
2476
+ function refusalDetail(err) {
2477
+ const api = apiError(err);
2478
+ if (!api) return "";
2479
+ const body = api.body && typeof api.body === "object" ? api.body : {};
2480
+ const code = api.bodyCode ?? (typeof body.error === "string" && /^[A-Z][A-Z0-9_]+$/.test(body.error) ? body.error : void 0);
2481
+ const lines = [
2482
+ code ? `code: ${code}` : "",
2483
+ body.readiness !== void 0 ? `readiness: ${JSON.stringify(body.readiness)}` : "",
2484
+ body.issues !== void 0 ? `issues: ${JSON.stringify(body.issues)}` : ""
2485
+ ].filter(Boolean);
2486
+ return lines.length ? `
2487
+ ${lines.join("\n")}` : "";
2488
+ }
2472
2489
  function authPrompt(err) {
2473
2490
  const text = [
2474
2491
  "\u{1F510} BetterCMS authorization required \u2014 you're not signed in yet.",
@@ -2518,7 +2535,7 @@ function buildToolDefs(deps) {
2518
2535
  return authPrompt(err);
2519
2536
  }
2520
2537
  if (err instanceof BetterCMSError) {
2521
- return fail(`BetterCMS error (${err.status} ${err.code}): ${err.message}`);
2538
+ return fail(`BetterCMS error (${err.status} ${err.code}): ${err.message}${refusalDetail(err)}`);
2522
2539
  }
2523
2540
  return fail(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
2524
2541
  }
@@ -2646,11 +2663,26 @@ function buildToolDefs(deps) {
2646
2663
  pageId: z.string().optional().describe("filter by page id (a singleton page has one entry)"),
2647
2664
  status: z.enum(["draft", "published"]).optional()
2648
2665
  });
2666
+ const clearableText = (max) => z.string().max(max).nullable().optional();
2667
+ const headPatchShape = {
2668
+ canonical: clearableText(2048).describe("canonical URL; null clears"),
2669
+ og: z.object({ title: clearableText(255), description: clearableText(500), image: clearableText(2048), type: clearableText(50) }).nullable().optional().describe("Open Graph; each key merges, null clears that key"),
2670
+ twitter: z.object({ card: clearableText(50), title: clearableText(255), description: clearableText(500), image: clearableText(2048) }).nullable().optional().describe("Twitter card; each key merges, null clears that key")
2671
+ };
2672
+ const entryMetaInput = z.object({
2673
+ metaTitle: clearableText(255),
2674
+ metaDescription: clearableText(500),
2675
+ noindex: z.boolean().optional().describe("hide this entry from search engines and site search"),
2676
+ ...headPatchShape,
2677
+ schemaType: z.enum(["WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness"]).nullable().optional(),
2678
+ schema: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).nullable().optional()
2679
+ });
2649
2680
  const updateEntryInput = z.object({
2650
2681
  entryId: z.string().min(1).describe("content entry id"),
2651
2682
  data: z.record(z.string(), z.unknown()).optional().describe("field values keyed by field key"),
2652
2683
  status: z.enum(["draft", "published"]).optional(),
2653
- slug: slug.optional()
2684
+ slug: slug.optional(),
2685
+ meta: entryMetaInput.optional().describe("the entry's native SEO; merges into what is stored")
2654
2686
  });
2655
2687
  const deletePageInput = z.object({
2656
2688
  pageId: z.string().min(1).describe("id of the page to delete (from list_pages)")
@@ -2704,7 +2736,15 @@ function buildToolDefs(deps) {
2704
2736
  description: z.string().optional(),
2705
2737
  submitLabel: z.string().optional().describe("submit button label (default 'Submit')"),
2706
2738
  successMessage: z.string().optional(),
2707
- redirectUrl: z.string().url().optional().describe("URL to redirect to on success")
2739
+ redirectUrl: z.string().url().optional().describe("URL to redirect to on success"),
2740
+ // Spam + delivery settings. Each MUST parse exactly like its key in the route's
2741
+ // createFormSchema (this package cannot import it); src/__tests__/mcp/mcp-parity.test.ts
2742
+ // checks the two agree. Never add turnstileSecretKey: a secret does not belong in an
2743
+ // agent's context.
2744
+ turnstileEnabled: z.boolean().optional().describe("require a Cloudflare Turnstile check on submit"),
2745
+ honeypotField: z.string().max(100).nullable().optional().describe("name of a hidden honeypot input; a submission that fills it is marked spam. null clears"),
2746
+ notifyEmails: z.array(z.string().email()).optional().describe("addresses emailed on each submission (REPLACES the list)"),
2747
+ webhookUrl: z.string().url().nullable().optional().describe("absolute URL that receives each submission as a POST. null clears")
2708
2748
  };
2709
2749
  const createFormInput = z.object({
2710
2750
  name: z.string().min(1).describe("human form name (used by getForm('Name'))"),
@@ -3284,7 +3324,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3284
3324
  def(
3285
3325
  "get_project",
3286
3326
  "Get the connected project",
3287
- "Get the connected project's info \u2014 id, name, slug, subdomain, and its live URL (https://<handle>.bettercms.site). Use it to tell the user where their site is published / link the result.",
3327
+ "Get the connected project's info \u2014 id, name, slug, subdomain, and its live URL (https://<handle>.bettercms.site). Use it to tell the user where their site is published / link the result. " + BRAND_KIT_NOTE,
3288
3328
  z.object({}).shape,
3289
3329
  async (c) => ok("Project.", await data(c, "GET", `/management/projects/current`))
3290
3330
  ),
@@ -3415,9 +3455,16 @@ ${d.outline}` : "Proposed Content structure.", d);
3415
3455
  "update_content_model",
3416
3456
  "Update a content model's metadata",
3417
3457
  "Rename a content model, edit its description/slug/urlPattern, or set its `fieldsets`. Never touches `fields`: use add_field to extend the schema. Provide modelId plus what to change. `fieldsets` REPLACES the list (send null to drop them all); then put a field in one with add_field's `fieldsetId`. `urlPattern` is the collection's entry URL, e.g. '/blog/:slug' (null clears it). A slug already used in this project and branch is refused with 409 `slug_taken`.",
3418
- z.object({ modelId: z.string().min(1), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), urlPattern: urlPatternArg, fieldsets: fieldsetsArg.nullable() }).shape,
3458
+ z.object({ modelId: z.string().min(1), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), urlPattern: urlPatternArg, fieldsets: fieldsetsArg.nullable(), ifMatch: z.number().int().min(0).optional().describe(MODEL_IF_MATCH_NOTE) }).shape,
3419
3459
  // `fields` is deliberately not sent: the PATCH replaces `fields` wholesale when present.
3420
- async (c, a) => ok("Updated content model.", await data(c, "PATCH", `/management/content/models/${s(a.modelId)}`, { name: a.name, slug: a.slug, description: a.description, urlPattern: a.urlPattern, fieldsets: a.fieldsets }))
3460
+ async (c, a) => ok(
3461
+ "Updated content model.",
3462
+ (await c.fetchJSON(c.url(`/management/content/models/${s(a.modelId)}`), {
3463
+ method: "PATCH",
3464
+ body: JSON.stringify({ name: a.name, slug: a.slug, description: a.description, urlPattern: a.urlPattern, fieldsets: a.fieldsets }),
3465
+ ...a.ifMatch !== void 0 ? { headers: { "if-match": `W/"${a.ifMatch}"` } } : {}
3466
+ })).data
3467
+ )
3421
3468
  ),
3422
3469
  def(
3423
3470
  "get_content_types",
@@ -3430,14 +3477,14 @@ ${d.outline}` : "Proposed Content structure.", d);
3430
3477
  def(
3431
3478
  "update_page",
3432
3479
  "Edit a page",
3433
- "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). It does NOT change the field SCHEMA \u2014 use add_page_field / set_page_content for that. Renaming the slug keeps content intact. Publishing copies the draft blocks live in the same call. 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). " + SECTION_DOCTRINE,
3434
- z.object({ pageId: z.string().min(1), 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() }).shape,
3480
+ "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). It does NOT change the field SCHEMA \u2014 use add_page_field / set_page_content for that. Renaming the slug keeps content intact. Publishing copies the draft blocks live in the same call. 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,
3481
+ z.object({ ...headPatchShape, noindex: z.boolean().optional().describe("hide this page from search engines and site search"), pageId: z.string().min(1), 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() }).shape,
3435
3482
  async (c, a) => {
3436
3483
  const res = await c.fetchJSON(
3437
3484
  c.url(`/management/pages/${s(a.pageId)}/meta`),
3438
3485
  {
3439
3486
  method: "PATCH",
3440
- body: JSON.stringify({ title: a.title, slug: a.slug, blockJson: a.blockJson, metaTitle: a.metaTitle, metaDescription: a.metaDescription, schemaType: a.schemaType, schema: a.schema, status: a.status })
3487
+ body: JSON.stringify({ title: a.title, slug: a.slug, blockJson: a.blockJson, metaTitle: a.metaTitle, metaDescription: a.metaDescription, schemaType: a.schemaType, schema: a.schema, status: a.status, noindex: a.noindex, canonical: a.canonical, og: a.og, twitter: a.twitter })
3441
3488
  }
3442
3489
  );
3443
3490
  const note = res.redirectNote;
@@ -3541,16 +3588,17 @@ ${d.outline}` : "Proposed Content structure.", d);
3541
3588
  def(
3542
3589
  "componentize_sections",
3543
3590
  "Turn this site's derived sections into components",
3544
- "Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections and say how many components it will create and which pages it will rewrite. It creates each proposed component as a DRAFT (its `sectionType` family, category 'section', placeable on any page) and replaces each page's DRAFT blocks with an ordered list of `component` instances \u2014 one per group, each carrying `props.bind: \"<groupKey>\"`, which points at the page field group that already holds the copy. So nothing is copied and nothing moves: the page keeps its `fields`, its values and its bindings, click-to-edit keeps working and the coverage meter does not change. Pass the plan's `digest`; a 409 `stale-plan` means the site changed since you read that plan, so read it again, show the user what changed and confirm again. Call it with `dryRun: true` first \u2014 same receipt, nothing written. Running it twice is safe: a group that already has a placement comes back in `sections.pending` as ALREADY_COMPONENTIZED and no second component is created. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages before this reaches a visitor. Then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. `copy` says WHO OWNS THE COPY. The default `bind` is the above: the words stay in the page's field group. `copy: \"instance\"` is the page-builder model \u2014 each placement takes that group's CURRENT draft values into its own `props.overrides` (a repeater becomes one table prop holding every row), records which page path each came from in `props.source`, and drops `props.bind`; the page's fields are KEPT and marked `origin: \"componentized\"`, so nothing is lost and the editor stops showing a second place to type the same words. Run it again and it converts nothing and duplicates nothing. `sections` narrows a run to named groupKeys (`hero`, `group-fast`); a key the plan does not list on the pages you named is refused as `unknown-section` rather than quietly doing nothing.",
3591
+ "Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections and say how many components it will create and which pages it will rewrite. It creates each proposed component as a DRAFT (its `sectionType` family, category 'section', placeable on any page) and replaces each page's DRAFT blocks with an ordered list of `component` instances \u2014 one per group, each carrying `props.bind: \"<groupKey>\"`, which points at the page field group that already holds the copy. So nothing is copied and nothing moves: the page keeps its `fields`, its values and its bindings, click-to-edit keeps working and the coverage meter does not change. Pass the plan's `digest`; a 409 `stale-plan` means the site changed since you read that plan, so read it again, show the user what changed and confirm again. Call it with `dryRun: true` first \u2014 same receipt, nothing written. Running it twice is safe: a group that already has a placement comes back in `sections.pending` as ALREADY_COMPONENTIZED and no second component is created. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages before this reaches a visitor. Then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. `copy` says WHO OWNS THE COPY. The default `bind` is the above: the words stay in the page's field group. `copy: \"instance\"` is the page-builder model \u2014 each placement takes that group's CURRENT draft values into its own `props.overrides` (a repeater becomes one table prop holding every row), records which page path each came from in `props.source`, and drops `props.bind`; the page's fields are KEPT and marked `origin: \"componentized\"`, so nothing is lost and the editor stops showing a second place to type the same words. Run it again and it converts nothing and duplicates nothing. `sections` narrows a run to named groupKeys (`hero`, `group-fast`); a key the plan does not list on the pages you named is refused as `unknown-section` rather than quietly doing nothing. Every component it CREATES is filed into a Component Group (the folders of the dashboard's Components tab): the one you name in `group` (found or created), else its section family \u2014 nav, header, footer or menu \u2192 Layout, an unnamed section \u2192 Sections, otherwise the family name (Hero, FAQ). A component it reuses keeps the Group it has; the receipt's `components.groups` lists the Groups it filed into.",
3545
3592
  z.object({
3546
3593
  digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
3547
3594
  pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
3548
3595
  pages: z.array(z.string().min(1)).optional().describe("The same page filter under the name the dashboard uses. Unioned with pageIds; an EMPTY list is refused rather than treated as every page."),
3549
3596
  sections: z.array(z.string().min(1)).optional().describe("Act only on these section groupKeys (from the plan). Every other section on the page keeps the placement it already has."),
3550
3597
  copy: z.enum(["bind", "instance"]).optional().describe("Who owns each section's copy: 'bind' (default) leaves it in the page fields; 'instance' moves it onto the placement and marks those fields origin: componentized."),
3551
- dryRun: z.boolean().optional().describe("true = return the receipt without writing anything. Do this first.")
3598
+ dryRun: z.boolean().optional().describe("true = return the receipt without writing anything. Do this first."),
3599
+ group: z.string().min(1).max(100).optional().describe("File the components this run creates into this Component Group (by NAME; created when missing). Omit to file each under its section family.")
3552
3600
  }).shape,
3553
- async (c, a) => ok("Componentized the sections.", await data(c, "POST", `/management/projects/current/componentize`, { digest: a.digest, pageIds: a.pageIds, pages: a.pages, sections: a.sections, copy: a.copy, dryRun: a.dryRun }))
3601
+ async (c, a) => ok("Componentized the sections.", await data(c, "POST", `/management/projects/current/componentize`, { digest: a.digest, pageIds: a.pageIds, pages: a.pages, sections: a.sections, copy: a.copy, dryRun: a.dryRun, group: a.group }))
3554
3602
  ),
3555
3603
  def(
3556
3604
  "get_analytics_overview",
@@ -3961,7 +4009,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3961
4009
  name: "update_content_entry",
3962
4010
  config: {
3963
4011
  title: "Update a content entry's values",
3964
- 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. Use this to edit an existing entry; for a singleton page prefer set_page_content.",
4012
+ 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. Use this to edit an existing entry; for a singleton page prefer set_page_content. " + ENTRY_META_NOTE,
3965
4013
  inputSchema: updateEntryInput.shape
3966
4014
  },
3967
4015
  handler: guard(
@@ -3969,7 +4017,8 @@ ${d.outline}` : "Proposed Content structure.", d);
3969
4017
  const entry = await client.updateEntry(args.entryId, {
3970
4018
  ...args.data !== void 0 ? { data: args.data } : {},
3971
4019
  ...args.status !== void 0 ? { status: args.status } : {},
3972
- ...args.slug !== void 0 ? { slug: args.slug } : {}
4020
+ ...args.slug !== void 0 ? { slug: args.slug } : {},
4021
+ ...args.meta !== void 0 ? { meta: args.meta } : {}
3973
4022
  });
3974
4023
  return ok(`Updated entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, entry);
3975
4024
  })
@@ -4070,7 +4119,7 @@ ${d.outline}` : "Proposed Content structure.", d);
4070
4119
  name: "create_form",
4071
4120
  config: {
4072
4121
  title: "Create a form",
4073
- description: "Create a form (fields + settings) in the bound project. CONFIRM the fields with the user first. Returns the new form's id \u2014 then embed it with `<BcmsForm form={getForm('Name')} />` from @bettercms-ai/next. Field types: text,email,textarea,select(needs options),radio(needs options),checkboxes(needs options),checkbox,number,phone,date,url,consent,hidden.",
4122
+ 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.",
4074
4123
  inputSchema: createFormInput.shape
4075
4124
  },
4076
4125
  handler: guard(
@@ -4095,6 +4144,23 @@ ${d.outline}` : "Proposed Content structure.", d);
4095
4144
  })
4096
4145
  )
4097
4146
  },
4147
+ {
4148
+ name: "publish_form",
4149
+ config: {
4150
+ title: "Publish a form",
4151
+ description: "Publish a form by id so it accepts submissions. create_form and update_form only ever write a DRAFT, and a draft form answers every submit with 403 \u2014 call this before embedding it with `<BcmsForm form={getForm('Name')} />`. It re-bakes the project's site; `meta.rebuildQueued` says whether a rebuild was actually queued (false for a project with no connected source). 403 PUBLISH_NOT_GRANTED means this connection may author but not publish: say so and let the user publish from the dashboard Forms tab. 404 means the form is not in this connection's project.",
4152
+ inputSchema: getFormInput.shape
4153
+ },
4154
+ handler: guard(
4155
+ async (args) => withClient(async (client) => {
4156
+ const res = await client.fetchJSON(
4157
+ client.url(`/management/forms/${args.formId}/publish`),
4158
+ { method: "POST" }
4159
+ );
4160
+ return ok(`Published form '${res.data?.name ?? args.formId}'.`, res);
4161
+ })
4162
+ )
4163
+ },
4098
4164
  // ── Project/page Layout (draft commands + Global Layout publish; page overrides publish with the page) ──
4099
4165
  {
4100
4166
  name: "get_layout",
@@ -4207,7 +4273,7 @@ ${d.outline}` : "Proposed Content structure.", d);
4207
4273
  name: "create_component",
4208
4274
  config: {
4209
4275
  title: "Create a reusable component",
4210
- description: "Create a reusable component from a blockJson tree. THIS IS ALSO HOW A PAGE GETS ITS SECTIONS: set `sectionType` (e.g. 'Hero') and the component becomes a placeable section, selectable in the editor's 'Add a section' picker. Components sharing a `sectionType` are its layout VARIANTS \u2014 one Hero with a 'Centered' and a 'Two-column' variant, same prop keys, so a swap keeps the content. CONFIRM the structure with the user first. blockJson is an array of blocks \u2014 the same set create_page accepts (heading, text/richtext, image, button, spacer, video, columns, section, slider, tabs, navbar, footer, form, component, collection), NOT a narrower one; `section` nests child blocks in props.children and `columns` in props.columns. `props` declares overridable fields. Returns the new id \u2014 render with `<BcmsBlocks>`. Always lands as a DRAFT: it is not on the live site until someone publishes it from the dashboard. " + SECTION_DOCTRINE,
4276
+ description: "Create a reusable component from a blockJson tree. THIS IS ALSO HOW A PAGE GETS ITS SECTIONS: set `sectionType` (e.g. 'Hero') and the component becomes a placeable section, selectable in the editor's 'Add a section' picker. Components sharing a `sectionType` are its layout VARIANTS \u2014 one Hero with a 'Centered' and a 'Two-column' variant, same prop keys, so a swap keeps the content. CONFIRM the structure with the user first. blockJson is an array of blocks \u2014 the same set create_page accepts (heading, text/richtext, image, button, spacer, video, columns, section, slider, tabs, navbar, footer, form, component, collection), NOT a narrower one; `section` nests child blocks in props.children and `columns` in props.columns. `props` declares overridable fields. Returns the new id \u2014 render with `<BcmsBlocks>`. Always lands as a DRAFT \u2014 call publish_component to put it on the live site; until then it renders as NOTHING there, with no error. " + SECTION_DOCTRINE,
4211
4277
  inputSchema: createComponentInput.shape
4212
4278
  },
4213
4279
  handler: guard(
@@ -4385,7 +4451,7 @@ ${lines.join("\n")}`, found);
4385
4451
  name: "update_component",
4386
4452
  config: {
4387
4453
  title: "Update a reusable component",
4388
- description: "Update a component by id \u2014 blockJson, props, name, or category. Read get_component first. Passing `blockJson`/`props` REPLACES them. This writes the DRAFT: the change does NOT appear on the live site until someone publishes the component from the dashboard.",
4454
+ description: "Update a component by id \u2014 blockJson, props, name, or category. Read get_component first. Passing `blockJson`/`props` REPLACES them. This writes the DRAFT: call publish_component to push the change live.",
4389
4455
  inputSchema: updateComponentInput.shape
4390
4456
  },
4391
4457
  handler: guard(
@@ -4455,7 +4521,7 @@ ${lines.join("\n")}`, found);
4455
4521
  name: "publish_component",
4456
4522
  config: {
4457
4523
  title: "Publish a component",
4458
- description: "Publish a component: 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 \u2014 create_component and update_component write drafts, and an unpublished component renders as NOTHING on the live site, 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. 422 means publishing would break a Layout that uses it \u2014 the response names which.",
4524
+ 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. create_component and update_component write drafts, and an unpublished component renders 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: this exact variant still needs passing implementation evidence and a HUMAN's approval in the BetterCMS dashboard, which no tool on this connection can grant. The response's `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.",
4459
4525
  inputSchema: getComponentInput.shape
4460
4526
  },
4461
4527
  handler: guard(
@@ -4487,7 +4553,7 @@ ${lines.join("\n")}`, found);
4487
4553
  name: "generate_seo_meta",
4488
4554
  config: {
4489
4555
  title: "Generate SEO metadata",
4490
- description: "Generate SEO metadata (metaTitle, metaDescription, keywords, optional JSON-LD) for a piece of content. Pass the page/entry content as `text`. Returns a suggestion to apply via update_page or the entry SEO fields.",
4556
+ description: "Generate SEO metadata (metaTitle, metaDescription, keywords, optional JSON-LD) for a piece of content. Pass the page/entry content as `text`. Returns a suggestion: apply a page's with update_page (metaTitle, metaDescription, schemaType, schema) and an entry's with update_content_entry `meta`.",
4491
4557
  inputSchema: generateSeoMetaInput.shape
4492
4558
  },
4493
4559
  handler: guard(
@@ -4679,7 +4745,10 @@ Do not create \`seoTitle\`, \`metaTitle\`, \`metaDescription\`, \`ogImage\`, \`c
4679
4745
  \`noindex\` or an \`seo\` group on ANY model, page or component. Every one of those already
4680
4746
  exists on the platform:
4681
4747
 
4682
- per page / entry metaTitle, metaDescription \u2014 set with update_page / update_content_entry
4748
+ per page metaTitle, metaDescription, schemaType, schema \u2014 set with update_page
4749
+ per entry metaTitle, metaDescription, noindex, canonical, og, twitter, schemaType,
4750
+ schema \u2014 set with update_content_entry \`meta\` (merges; null clears a key)
4751
+ page head extras noindex, canonical, og, twitter \u2014 set with update_page (merges; null clears)
4683
4752
  site-wide defaults get_seo / update_seo \u2014 { metaTitle, metaDescription, ogImage, twitterHandle }
4684
4753
  generated for you generate_seo_meta
4685
4754
  audited for you list_seo_issues
@@ -4726,6 +4795,9 @@ is interpolated per entry with \`{{fieldKey}}\` placeholders \u2014 \`{{title}}\
4726
4795
  2. collections create_content_model
4727
4796
  3. components create_component (they land as DRAFTS)
4728
4797
  4. publish publish_component <- every component you intend to place
4798
+ (409 COMPONENT_IMPLEMENTATION_NOT_READY: this exact variant still needs passing
4799
+ implementation evidence and a HUMAN's approval in the dashboard, which no tool
4800
+ can grant; relay the response's readiness to the user, never retry)
4729
4801
  5. pages create_page with blockJson placing those components
4730
4802
  6. content create_content_entry / set_page_content
4731
4803
  7. publish update_page status:'published'
@@ -5197,9 +5269,11 @@ conversion is yours to write.
5197
5269
  \`create_content_entry\`). Prose goes in as Portable Text arrays (\`_type: "block"\`); never
5198
5270
  markup inside a \`text\` field.
5199
5271
  **Prose MIGRATED from another CMS needs one extra check.** Every block's \`_type\` must be one
5200
- this platform stores \u2014 \`block\`, or \`bcmsBlock\` carrying a \`schemaKey\` such as
5201
- \`builtin:table\` \u2014 and a foreign node (a Sanity-shaped \`{_type:"table", rows:[{cells}]}\` is
5202
- the one that has actually happened) is not merely unrendered: it has no component here, so it
5272
+ this platform stores \u2014 \`block\`, \`table\` (\`{_type:"table", rows:[{_key, cells:[{_key,
5273
+ content:[...blocks], header?}]}]}\`; Sanity's row of plain strings, \`rows:[{cells:["a","b"]}]\`,
5274
+ is admitted too), \`bcmsCode\`, \`bcmsDivider\`, \`bcmsMention\`, or \`bcmsBlock\` carrying a
5275
+ \`schemaKey\` \u2014 and a foreign node (a Sanity image, \`{_type:"image", asset:{_ref}}\`, for one)
5276
+ is not merely unrendered: it has no component here, so it
5203
5277
  vanishes from the HTML every delivery surface reads while the stored value still looks whole.
5204
5278
  Verify each \`_type\` against the schema before you write, not after.
5205
5279
  5. Codemod the templates. Read each value from the CMS (\`@bettercms-ai/astro\` /
@@ -5346,7 +5420,7 @@ back each prop's stored \`ui\` or you delete it along with the prop.
5346
5420
  **THE RECEIPT \u2014 read it back.** There is no separate verification tool and no mode to flip.
5347
5421
  \`get_content_model\` (and \`get_page\` / \`get_component\`) return each field's \u2014 and each component
5348
5422
  prop's \u2014 stored \`ui\`, \`helpText\` and \`showIf\`. A prop that is ABSENT from that response was not
5349
- stored, whatever the write returned \u2014 \xA712 rule 3, applied to this feature. Declare, then read
5423
+ stored, whatever the write returned \u2014 \xA711 RECEIPTS rule 3, applied to this feature. Declare, then read
5350
5424
  back, then say it works.
5351
5425
 
5352
5426
  **No \`order\` property. Ever.** A field's order IS its index in \`fields\`. To put a new field
@@ -5475,13 +5549,19 @@ Author a form (then the user embeds it with \`<BcmsForm form={getForm('Name')} /
5475
5549
  @bettercms-ai/next). Confirm the fields with the user BEFORE creating. Never guess fields.
5476
5550
  1. **Discover** \u2014 \`list_forms\` to see existing forms; \`get_form\` to read one before editing.
5477
5551
  2. **Collect fields (loop)** \u2014 for each: key (machine key for the value), label, type. Types:
5478
- text, email, textarea, select (needs \`options: string[]\`), checkbox, number, phone, date,
5479
- url, consent, hidden. Optional per field: required?, placeholder?, defaultValue?, and
5552
+ text, email, textarea, select (needs \`options: string[]\`), checkbox, checkboxes (needs
5553
+ \`options\`), radio (needs \`options\`), number, phone, date, url, consent, hidden. Optional per
5554
+ field: required?, placeholder?, helpText? (shown under the input), defaultValue?,
5555
+ \`validation\` (\`emailPolicy\` on email, \`min\`/\`max\` on number, \`phoneFormat\` on phone,
5556
+ \`pattern\` on text/textarea/url \u2014 a rule on any other type is a 400), and
5480
5557
  \`showIf: { field, equals }\` for conditional display.
5481
5558
  3. **Settings** \u2014 name (used by getForm('Name')), submitLabel?, successMessage?, redirectUrl?.
5482
5559
  4. **Confirm**, then \`create_form\` { name, fields, ... } (returns the new id), or
5483
5560
  \`update_form\` { formId, ... } to edit (passing \`fields\` REPLACES the array \u2014 include all).
5484
- 5. Offer to wire \`<BcmsForm>\` into the page/component where the user wants it.`;
5561
+ 5. The form lands as a DRAFT, and a draft refuses every submission (403): call
5562
+ \`publish_form\` { formId } (on 403 PUBLISH_NOT_GRANTED, tell the user to publish it in the
5563
+ dashboard Forms tab), then offer to wire \`<BcmsForm>\` into the page/component where the
5564
+ user wants it.`;
5485
5565
  var COMPONENT_FLOW = `### Component authoring \u2192 \`create_component\` / \`publish_component\`
5486
5566
  Author a reusable section. blockJson is the visual definition; authoring it blind is
5487
5567
  error-prone, so go slow and confirm. Never guess the layout.
@@ -5493,7 +5573,9 @@ error-prone, so go slow and confirm. Never guess the layout.
5493
5573
  "Add a section" picker. Components sharing a sectionType are swappable VARIANTS, so reuse
5494
5574
  the same prop keys across a family or a swap drops content.
5495
5575
  4. **Props** \u2014 declare only what should really be editable:
5496
- { key, label, target: { blockId, path }, type: text|richtext|image|url|boolean|slot }.
5576
+ { key, label, target: { blockId, path },
5577
+ type: text|richtext|image|url|boolean|number|select|group|table|slot }. 'select' needs
5578
+ config.options; 'group'/'table' need config.fields; 'slot' holds ONE nested component.
5497
5579
  5. **Confirm the structure** (AskUserQuestion: show the block tree), then \`create_component\`.
5498
5580
  6. **\`publish_component\`.** It lands as a DRAFT, and a draft component renders as NOTHING on
5499
5581
  the live site \u2014 no error, no placeholder. This step is not optional.