@bettercms-ai/mcp 0.78.1 → 0.79.1

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
@@ -2541,7 +2541,8 @@ var STRUCTURE_DEFAULT_INSTRUCTION = `After you create collections or pages, orga
2541
2541
  var SKILLS_ROUTING_INSTRUCTION = "Before writing code in a repo that uses BetterCMS, explore the repo, then read the installed `bettercms` skill (`npx @bettercms-ai/install --project -y` adds it if missing): its table turns what you found into the one reference the task needs \u2014 read only that. Without the skill installed, read the same files as resources: bettercms://skill/bettercms, then bettercms://skill/<reference>.";
2542
2542
  var DOCK_UI_ROUTING_INSTRUCTION = "How fields and props look in the editor (groups, tabs, rows, icons, item names) is playbook \xA714 or the `field-ui` prompt: write it with update_content_model field* or update_component prop* plus ifMatch, read it back, and finish when get_next_steps reports no dock-* step.";
2543
2543
  var PUBLISH_HOLD_INSTRUCTION = "An item an author held back from site publishes (`publishHeldAt` set) stays out of whole-site publishes: do not publish it unless the user names it; publish_entries `release` includes it again.";
2544
- var RECEIPT_FIRST_INSTRUCTION = "Start every project with get_next_steps: its `receipt` lists what is already done (`done`), what is still missing (`missing`, each item citing the count it reacted to) and the one `nextAction`. Work from that receipt instead of re-reading the site, call get_next_steps again after each batch of edits, and before you tell the user you are finished. On an imported site the gate is the definition of done: run `receipt.gate.nextAction`, read again, and repeat until `receipt.complete` is true. Record a gap you cannot close with editor_setup `exempt` only for an allowed reason that is true, with a note, never in prose.";
2544
+ var RECEIPT_FIRST_INSTRUCTION = "Start every project with get_next_steps: its `receipt` lists what is done (`done`), what is missing (`missing`, each citing its count) and the one `nextAction`. Work from it instead of re-reading the site, and call it again after each batch of edits and before you report. On an imported site the gate is the definition of done: run `receipt.gate.nextAction`, read again, and repeat until `receipt.complete` is true. Record a gap you cannot close with editor_setup `exempt` only for an allowed reason that is true, with a note, never in prose.";
2545
+ var NO_QUESTIONS_INSTRUCTION = "Never stop a setup to ask the user: authoring (components) and media (the CDN) are recorded for you, and your prompt says whether you may push. Report what was decided.";
2545
2546
  var NEXT_STEPS_DESCRIPTION = `Call this FIRST on a project. Returns \`receipt\` \u2014 \`done\` (live release, bound elements, conversion coverage, composed pages, published components and Layout), \`missing\`, \`nextAction\` and \`notes\` (unjudged: unknown, not done) \u2014 plus \`data\`, the unfinished items by \`ruleId\` (skill finish-site lists them). Each item cites its count and may carry \`payload\` {rows, total}, at most 20 rows. \`receipt.complete\` is the setup gate's verdict and \`receipt.gate\` its numbers per built route. Run \`receipt.gate.nextAction\`, call it again after each batch; you are finished only when \`complete\` is true. \`organise-content\`: pages and collections are in no folder \u2014 read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure and, once the user agrees, set_content_structure.`;
2546
2547
  var LAYOUT_CHROME_INSTRUCTION = 'When the user asks to make the navigation, footer or other site chrome editable, or get_next_steps lists a `layout-*` or `chrome-duplicated` item: this is playbook \xA711 item 3. Chrome is the project Layout, never page fields: in the source, wrap each Section in `data-bcms-layout-section` (the slot, its base slug) and mark each value `data-bcms-layout-field="layout:<sections[slug].id>:<fieldId>"`, never `data-bcms-field` inside it, then push; the strip removes the page copies, never delete them by hand. On a site BetterCMS serves, publish_layout writes Layout VALUES into the live pages; a structure change waits for a Rebuild. Never place nav, footer or modal blocks or fields on a page: the Layout (or the site\'s own layout) draws chrome, so a page copy either duplicates it or renders nowhere (get_componentize_plan reports one as LAYOUT_CHROME).';
2547
2548
  var CTA_BUTTON_INSTRUCTION = "A call to action is ONE `button` field (value {label, href, variant?, size?, arrow?, icon?, newTab?}), rendered with BcmsButton from @bettercms-ai/astro or @bettercms-ai/next: never a text field beside a link field. A page that already holds such a pair (primaryCtaText + primaryCtaHref) becomes one button with update_field `mergeHref`. Inside a component a CTA is a `button` block whose text and href two props target.";
@@ -3016,7 +3017,9 @@ var PARAM_TEXT = {
3016
3017
  runtimeBundle: "instead of pageId: the server-app bundle cleanup",
3017
3018
  pageRef: "page id or path ('/de/about'); a bare slug only when one page has it",
3018
3019
  editorSetupForce: "re-run a setup already ready for the live build",
3020
+ nextStepsFull: "true = every list uncut",
3019
3021
  editorSetupCheck: "true = only report the state; starts nothing",
3022
+ waitSeconds: "wait up to N seconds (max 50) for it to finish; call again while it runs",
3020
3023
  editorSetupExempt: "instead of running: record setup-gate exceptions [{ route, check, reason, note }]; reason null removes one. Reasons: get_next_steps `receipt.gate`",
3021
3024
  editorSetupDeclare: "instead of running: record binding decisions [{op, id, ...}], max 100, a result each; with `check: true` a dry run. Ops: skill bindings",
3022
3025
  pageBlockJson: "an ARRAY REPLACES the page's block composition (a `component` block per section). An OBJECT edits ONE section of the stored page instead: `insertSection` adds a component (its id, or a builtin:* id: copied into the project once) at index `at`, or `before` / `after` a section's block id (none = last), with its `style` and `overrides`; `sectionStyle` sets a section's block style (a key replaces the stored one, null unsets it). `section` in the answer names the block (skill publishing)",
@@ -3061,6 +3064,14 @@ var UPDATE_PAGE_IF_MATCH_NOTE = "get_page's `optimisticVersion`: the write appli
3061
3064
  var SLOT_MULTIPLE_NOTE = "'slot' with config.multiple holds a list [{componentId, overrides?}] (config.maxItems, default 12, at most 50): target.path 'props.children' on a section. V2 slots, 'table' mode 'types': playbook \xA75.";
3062
3065
  var PROP_V2_NOTE = "Props take the fields' V2 keys (\xA714): 'object' = ONE {child: value} from config.fields, \u226410 levels (field_depth_exceeded); datetime|slug|email|phone|json|location|tags|rating|code|reference|multi-reference as that field (a reference names contentModelId, or polymorphic + allowedModelSlugs; page reads hydrate its entries); helpMore, advanced, recommendedMax, openMode, config.scale, ui.layout rows|chips|gallery, ui.preview.fallback, showIf notEmpty|empty. Send helpText: null for a prop whose /kit schema has no description (omitted, the platform writes one).";
3063
3066
  var LAYOUT_LOCK_NOTE = "A Global field with `overridable:false` + `lockReason` (add-field `field`, update-field `patch`) refuses page overrides (LAYOUT_FIELD_NOT_OVERRIDABLE); get_layout scope page lists stored ones as `shadowed`.";
3067
+ var AUTHORING_DESCRIPTION = "Record which authoring architecture this site uses: 'components' (the default: reusable section components placed as blocks, which editors add, reorder and swap; on a site whose pages were DERIVED it keeps every field, value and binding, then run the componentize lane) or 'fields' (a typed field schema per page, for a blog, catalogue or directory). A deploy, a promote and every editor_setup record 'components' when nothing is set, so call this only to change it. Never ask the user: record it, say so in your final report, and tell them they can switch it in the dashboard.";
3068
+ var MEDIA_DEFAULT_NOTE = "Leave the default (the CDN) unless the owner asked otherwise; never ask.";
3069
+ var REBUILD_DESCRIPTION = "Rebuild from the connected repository, as the dashboard's Rebuild does. Use it when a get_next_steps fix names it, or after a push that started no build; then get_deploy_status { waitSeconds: 50 } until live, and editor_setup. 409 NOT_QUEUED: no repository (deploy_project the working tree) or publishing held.";
3070
+ var withWait = (path, waitSeconds) => typeof waitSeconds === "number" && waitSeconds > 0 ? `${path}?wait=${Math.min(Math.round(waitSeconds), 50)}` : path;
3071
+ var nextStepsPath = (path, a) => {
3072
+ const q = [a.importJunk === true ? "importJunk=true" : "", a.full === true ? "full=true" : ""].filter(Boolean).join("&");
3073
+ return q ? `${path}?${q}` : path;
3074
+ };
3064
3075
 
3065
3076
  // src/entry-query.ts
3066
3077
  var text = (v) => Array.isArray(v) ? v.map(String).join(",") : String(v);
@@ -3087,6 +3098,103 @@ function entryListSearch(args) {
3087
3098
  return s ? `?${s}` : "";
3088
3099
  }
3089
3100
 
3101
+ // src/kit.ts
3102
+ import { existsSync, readFileSync } from "fs";
3103
+ import { fileURLToPath } from "url";
3104
+ var KIT_URI = "bettercms://kit";
3105
+ var KIT_ENTRY_URI_PREFIX = "bettercms://kit/entry/";
3106
+ var KIT_ENTRY_URI_TEMPLATE = `${KIT_ENTRY_URI_PREFIX}{id}`;
3107
+ function loadKitCatalog(path = fileURLToPath(new URL("./kit-catalog.json", import.meta.url))) {
3108
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
3109
+ }
3110
+ var list = (xs) => xs.map((x) => `- ${x}`).join("\n");
3111
+ function entryNotes(e, h = "###") {
3112
+ const out = [`${h} ${e.name} (\`${e.id}\`)`, "", `Tags: ${e.tags.join(", ") || "none"}. Proves: ${e.proves}`];
3113
+ if (!e.why && !e.mcp) out.push("", "No model notes in the kit for this entry: read its props.");
3114
+ if (e.why) out.push("", `**Why this shape.** ${e.why}`);
3115
+ if (e.htmlSignals.length) out.push("", "**HTML signals.**", list(e.htmlSignals));
3116
+ if (e.mcp) out.push("", `**Do.** ${e.mcp}`);
3117
+ if (e.pitfalls.length) out.push("", "**Pitfalls.**", list(e.pitfalls));
3118
+ if (e.notCarried.length) out.push("", `Not carried by a stored component: ${e.notCarried.join(", ")}.`);
3119
+ return out.join("\n");
3120
+ }
3121
+ function kitIndexText(k) {
3122
+ const byId = new Map(k.entries.map((e) => [e.id, e]));
3123
+ return [
3124
+ "# BetterCMS kit catalogue",
3125
+ "",
3126
+ `${k.entries.length} modelled website sections. Before designing a section, find the closest entry by its edge tag or category, read its notes and follow its mcp + pitfalls; its create_component props are at ${KIT_ENTRY_URI_TEMPLATE}.`,
3127
+ "",
3128
+ "## Categories",
3129
+ "",
3130
+ ...k.categories.map((c) => `- ${c.name} (${c.entries.length}): ${KIT_URI}/${c.slug}`),
3131
+ "",
3132
+ "## Edge tags",
3133
+ "",
3134
+ ...k.tags.map((t) => `- \`${t.tag}\` (${t.hint}): ${t.entries.join(", ") || "none"}`),
3135
+ "",
3136
+ "## Entries",
3137
+ "",
3138
+ ...k.categories.flatMap((c) => [
3139
+ `### ${c.name}`,
3140
+ "",
3141
+ ...c.entries.map((id) => {
3142
+ const e = byId.get(id);
3143
+ return `- \`${e.id}\` ${e.name} [${e.tags.join(", ")}]: ${e.proves}`;
3144
+ }),
3145
+ ""
3146
+ ])
3147
+ ].join("\n");
3148
+ }
3149
+ function kitCategoryText(k, slug2) {
3150
+ const c = k.categories.find((x) => x.slug === slug2);
3151
+ const byId = new Map(k.entries.map((e) => [e.id, e]));
3152
+ return [
3153
+ `# Kit: ${c.name}`,
3154
+ "",
3155
+ `${c.entries.length} entries. Each one's create_component props: ${KIT_ENTRY_URI_TEMPLATE}. Index: ${KIT_URI}.`,
3156
+ "",
3157
+ ...c.entries.map((id) => entryNotes(byId.get(id), "##") + "\n")
3158
+ ].join("\n");
3159
+ }
3160
+ function kitEntryText(e) {
3161
+ return [
3162
+ entryNotes(e, "#"),
3163
+ "",
3164
+ "## create_component payload",
3165
+ "",
3166
+ "Props in the exact shape create_component accepts. Adapt keys and copy to the site; keep the types, `ui` and config.",
3167
+ "",
3168
+ "```json",
3169
+ JSON.stringify(e.component, null, 1),
3170
+ "```",
3171
+ ""
3172
+ ].join("\n");
3173
+ }
3174
+ function kitDocs(k = loadKitCatalog()) {
3175
+ if (!k) return [];
3176
+ const doc = (uri, name, title, description, text2) => ({ uri, name, title, description, mimeType: "text/markdown", text: text2 });
3177
+ return [
3178
+ doc(KIT_URI, "kit", "BetterCMS kit catalogue", "Index of modelled website sections by category and edge tag: find the closest one before designing a section.", kitIndexText(k)),
3179
+ ...k.categories.map(
3180
+ (c) => doc(`${KIT_URI}/${c.slug}`, `kit-${c.slug}`, `Kit: ${c.name}`, `Model notes for the ${c.entries.length} ${c.name} entries: why, HTML signals, mcp, pitfalls.`, kitCategoryText(k, c.slug))
3181
+ )
3182
+ ];
3183
+ }
3184
+ function kitEntryTexts(k = loadKitCatalog()) {
3185
+ return k ? Object.fromEntries(k.entries.map((e) => [`${KIT_ENTRY_URI_PREFIX}${e.id}`, kitEntryText(e)])) : {};
3186
+ }
3187
+
3188
+ // src/field-config.ts
3189
+ var FIELD_CONFIG = loadKitCatalog()?.fieldConfig ?? null;
3190
+ var SERVER = new Set(FIELD_CONFIG?.server ?? []);
3191
+ function withoutServerKeys(config) {
3192
+ if (!config || typeof config !== "object" || Array.isArray(config)) return config;
3193
+ const keys = Object.keys(config);
3194
+ return keys.some((k) => SERVER.has(k)) ? Object.fromEntries(Object.entries(config).filter(([k]) => !SERVER.has(k))) : config;
3195
+ }
3196
+ var withAgentConfig = (a) => a.config ? { ...a, config: withoutServerKeys(a.config) } : a;
3197
+
3090
3198
  // src/tools.ts
3091
3199
  async function askHuman(deps, q) {
3092
3200
  if (!deps.elicit) return { prompt: q.prompt };
@@ -3130,55 +3238,6 @@ function askFramework(deps) {
3130
3238
  });
3131
3239
  }
3132
3240
  var AUTHORING_CHOICES = ["components", "fields"];
3133
- var AUTHORING_LABELS = {
3134
- components: "Components (recommended) \u2014 reusable section components placed as blocks; editors add, reorder and swap sections without touching a schema. Recommend it for every marketing, landing, agency or product site",
3135
- fields: "Fields \u2014 a typed field schema per page. Only for a blog, catalogue or directory where many rows share one shape; editors can change a page's copy but cannot add, reorder or swap its sections"
3136
- };
3137
- var AUTHORING_PROMPT = [
3138
- "Ask the user which authoring architecture this site should use, then call set_authoring_preference again with their answer as `preference`:",
3139
- ...AUTHORING_CHOICES.map((c, i) => ` ${i + 1}. ${c} \u2014 ${AUTHORING_LABELS[c]}`),
3140
- "",
3141
- "On a site whose pages were DERIVED at import, answering 'components' redoes nothing: after set_authoring_preference, get_componentize_plan proposes one component per section (it reads the PUBLISHED pages \u2014 no deploy needed to reach it), you confirm it with the user, then componentize_sections places them with `props.bind` (dry run first \u2014 they land as drafts and the page keeps its fields, values and bindings, so click-to-edit and the coverage meter are unchanged), then publish_component each one, publish the pages, run `npx @bettercms-ai/convert --componentize` in the repo, and deploy. For a section group the plan lists as NO_GROUP_ROOT or NOT_A_SECTION there is nothing to componentize, so author that section with create_component and set_page_content.",
3142
- "",
3143
- "Recommend components unless the user says the site is schema-first.",
3144
- "Do not choose on their behalf. This is asked once per project."
3145
- ].join("\n");
3146
- function askAuthoring(deps) {
3147
- return askHuman(deps, {
3148
- key: "preference",
3149
- message: "Which authoring architecture should this site use? Components is recommended for a marketing site.",
3150
- property: {
3151
- type: "string",
3152
- title: "Authoring architecture",
3153
- description: "How this site's pages are composed. Components is recommended unless this is a schema-first site (a blog, catalogue or directory). Asked once per project.",
3154
- enum: [...AUTHORING_CHOICES],
3155
- enumNames: AUTHORING_CHOICES.map((c) => AUTHORING_LABELS[c])
3156
- },
3157
- accept: (v) => typeof v === "string" && AUTHORING_CHOICES.includes(v),
3158
- prompt: AUTHORING_PROMPT
3159
- });
3160
- }
3161
- var USE_CDN_PROMPT = [
3162
- "Ask the user where this site's media should be served from, then call set_media_delivery again with their answer as `useCdn`:",
3163
- " 1. true \u2014 the BetterCMS CDN (recommended, and the default). Media URLs are minted on the CDN host, or on the project's own CDN base if it stores media in its own bucket.",
3164
- " 2. false \u2014 the API origin. Media URLs are minted on the API host instead. Same bytes, same service; only the hostname differs.",
3165
- "",
3166
- "This applies to URLs minted FROM NOW ON. Already-published content keeps the URLs it has, and they keep working."
3167
- ].join("\n");
3168
- function askMediaDelivery(deps) {
3169
- return askHuman(deps, {
3170
- key: "useCdn",
3171
- message: "Serve this site's media from the BetterCMS CDN (recommended), or from the API origin?",
3172
- property: {
3173
- type: "boolean",
3174
- title: "Use the CDN",
3175
- description: "On (default): media URLs are minted on the CDN \u2014 or on this project's own CDN base if it uses its own bucket. Off: they are minted on the API origin instead. Same bytes either way; only the host changes, and only for URLs minted from now on.",
3176
- default: true
3177
- },
3178
- accept: (v) => typeof v === "boolean",
3179
- prompt: USE_CDN_PROMPT
3180
- });
3181
- }
3182
3241
  var fieldType = z.enum([
3183
3242
  "text",
3184
3243
  "richtext",
@@ -3315,7 +3374,7 @@ var fieldShape = {
3315
3374
  "choices when type is 'select'. A plain string, or {label, value, icon?, description?} when the stored value differs from what the editor shows (e.g. {label:'Sold out', value:'soldOut'}); icon a dock name, description one line \u2264140."
3316
3375
  ),
3317
3376
  config: z.record(z.string(), z.unknown()).optional().describe(
3318
- "per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, repeater {minItems, maxItems} (the list's item limits; the editor shows 'max N' and stops Add there), array {itemType: text|number|date|image|link|richtext}, date {includeTime, timezone}, modular|slot {blockSlugs: ['quote','gallery'], minItems?, maxItems?} (slot: playbook \xA75) \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; limits: text/longtext {minLength,maxLength,pattern,patternMessage}, richtext {minLength,maxLength}, number {validation:{min,max}}, array {minItems,maxItems}, tags {min,max}, rating {min,max,step}"
3377
+ "per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, repeater {minItems, maxItems} (the list's item limits; the editor shows 'max N' and stops Add there), array {itemType: text|number|date|image|link|richtext}, date {includeTime, timezone}, modular|slot {blockSlugs: ['quote','gallery'], minItems?, maxItems?} (slot: playbook \xA75) \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; limits: text/longtext {minLength,maxLength,pattern,patternMessage}, richtext {minLength,maxLength}, number {validation:{min,max}}, array {minItems,maxItems}, tags {min,max}, rating {min,max,step}. Undocumented keys open the classic editor."
3319
3378
  ),
3320
3379
  fields: z.array(z.lazy(() => fieldObject)).optional().describe(
3321
3380
  "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."
@@ -3512,7 +3571,7 @@ function toField(f) {
3512
3571
  ...base,
3513
3572
  type: proseType(f),
3514
3573
  ...f.options ? { options: f.options } : {},
3515
- ...f.config ? { config: f.config } : {}
3574
+ ...f.config ? { config: withoutServerKeys(f.config) } : {}
3516
3575
  };
3517
3576
  }
3518
3577
  var BRAND_KIT_NOTE = "`brandKit` (colors, typography, radius and any typeScale/spacing/elevation/motion/graphics) is edited in the BetterCMS dashboard AND by set_brand_assets and set_brand_graphics; every write is a version a person can restore. ASSETS: a media asset OF THIS PROJECT by id; null clears one; a slot a person chose needs `replace: true`. GRAPHICS: `kind`, optional `angle`, 2-8 stops naming `colors.*` or `extras` keys \u2014 never a CSS string and never a hex. CONSUME, DO NOT COPY: a hosted page exposes `--brand-color-<key>`, `--brand-shadow-<key>` and `--brand-gradient-<key>`; style sections with their `style` tokens (`nav.backgroundGradient` and `footer.backgroundGradient` take a gradient key), never copied values. Never invent brand facts: read them from get_project.";
@@ -4639,7 +4698,7 @@ ${d.outline}` : "Proposed Content structure.", d);
4639
4698
  "update_project",
4640
4699
  "Update the connected project",
4641
4700
  "Update the connected project's settings \u2014 rename it, change its slug/description, SEO defaults, visibility, or `presentation`. Only the provided fields change. `subdomainAlias` renames the staging URL: 'astronapp' serves the site at astronapp.bettercms.site (3\u201355 lowercase letters, numbers, hyphens; 409 when taken; null goes back to the original). The original <handle>.bettercms.site keeps working; a replaced name is released. `presentation` themes the platform's OWN renderer (the one serving a site built in the CMS with no deployed build, and previewing structural drafts everywhere) with the same closed knobs as bcms-presentation.json \u2014 DTCG { $type, $value } tokens keyed like 'page.maxWidth', 'nav.background'. Without it that renderer is a generic 768px document theme. An invalid manifest is 422 PRESENTATION_INVALID.",
4642
- z.object({ name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), visibility: z.string().optional(), seoDefaults: z.record(z.string(), z.unknown()).optional(), presentation: z.record(z.string(), z.unknown()).optional().describe("theme knobs for the platform renderer, keyed like bcms-presentation.json"), subdomainAlias: z.string().nullable().optional().describe("the staging name: 'astronapp' \u2192 astronapp.bettercms.site; null = back to the original") }).shape,
4701
+ z.object({ name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), visibility: z.string().optional(), seoDefaults: z.record(z.string(), z.unknown()).optional(), presentation: z.record(z.string(), z.unknown()).optional().describe("theme knobs for the platform renderer, keyed like bcms-presentation.json"), subdomainAlias: z.string().nullable().optional().describe("the staging name: 'astronapp' \u2192 astronapp.bettercms.site; null = back to the original"), structureInPlace: z.literal(true).optional().describe("true: in-place structure on (the gate fix enable-in-place-structure); never off") }).shape,
4643
4702
  async (c, a) => ok("Updated project.", await data(c, "PATCH", `/management/projects/current`, a))
4644
4703
  ),
4645
4704
  def(
@@ -4664,21 +4723,10 @@ ${d.outline}` : "Proposed Content structure.", d);
4664
4723
  def(
4665
4724
  "set_authoring_preference",
4666
4725
  "Set the site's authoring architecture",
4667
- "Record which authoring architecture this site uses \u2014 'components' (RECOMMENDED: reusable section components placed as blocks; editors add, reorder and swap sections without touching a schema \u2014 recommend it for every marketing, landing, agency or product site) or 'fields' (a typed field schema per page \u2014 only for a blog, catalogue or directory where many rows share one shape). ASK THE USER; do not pick for them. Called without `preference`, this tool asks them directly (or hands you the question to ask). deploy_project and promote_project both refuse with 409 AUTHORING_DECISION_REQUIRED until it is set, carrying this project's real page counts to show the user. Unset, editor_setup componentizes nothing; only `components` lets it place sections. On a site whose pages were DERIVED at import, answering 'components' redoes nothing: after set_authoring_preference, get_componentize_plan proposes one component per section (it reads the PUBLISHED pages \u2014 no deploy needed to reach it), you confirm it with the user, then componentize_sections places them with `props.bind` (dry run first \u2014 they land as drafts and the page keeps its fields, values and bindings, so click-to-edit and the coverage meter are unchanged), then publish_component each one, publish the pages, run `npx @bettercms-ai/convert --componentize` in the repo, and deploy. Asked once per project; re-callable if the user changes their mind.",
4668
- // Optional in the schema for exactly the reason `framework` is above: a required arg is
4669
- // rejected by the SDK before the handler runs, which would kill the elicitation below
4670
- // and leave the model guessing. Optional here, answered by a human there. The backend
4671
- // gate refuses the deploy regardless, so nothing ships on a guess.
4672
- z.object({ preference: z.enum(AUTHORING_CHOICES).optional().describe("REQUIRED in effect \u2014 the architecture the USER chose. Ask them; never default it.") }).shape,
4673
- async (c, a) => {
4674
- let preference = a.preference;
4675
- if (preference === void 0) {
4676
- const asked = await askAuthoring(deps);
4677
- if ("prompt" in asked) return fail(asked.prompt);
4678
- preference = asked.value;
4679
- }
4680
- return ok("Recorded the authoring architecture.", await data(c, "PATCH", `/management/projects/current/authoring-preference`, { preference }));
4681
- }
4726
+ AUTHORING_DESCRIPTION,
4727
+ // Required: an omitted answer must never overwrite one the owner gave (a deploy or setup records the default).
4728
+ z.object({ preference: z.enum(AUTHORING_CHOICES).describe("components or fields") }).shape,
4729
+ async (c, a) => ok("Recorded the authoring architecture.", await data(c, "PATCH", `/management/projects/current/authoring-preference`, { preference: a.preference }))
4682
4730
  ),
4683
4731
  def(
4684
4732
  "set_binding_mode",
@@ -4690,20 +4738,9 @@ ${d.outline}` : "Proposed Content structure.", d);
4690
4738
  def(
4691
4739
  "set_media_delivery",
4692
4740
  "Choose where this site's media is served from",
4693
- "Record whether this project's media (images, video, files) is served from the BetterCMS CDN or from the API origin. `useCdn: true` is the DEFAULT and the recommendation: URLs are minted on the CDN host, or on the project's own CDN base when it stores media in its own bucket (BYOK). `useCdn: false` mints them on the API origin instead \u2014 the same service serving the same bytes, only under a different hostname. ASK THE USER; do not pick for them. Called without `useCdn`, this tool asks them directly (or hands you the question to ask). \u{1F534} IT APPLIES TO URLs MINTED FROM NOW ON \u2014 new uploads, entry saves, get_media. It does NOT rewrite URLs already stored in published content, and it does not need to: the old URLs keep resolving. To move an existing image, re-save the field that holds it. \u{1F534} IT IS THE BACKEND HALF ONLY. A deployed frontend builds its own transform URLs with @bettercms-ai/image-url, which has its own media host \u2014 set `PUBLIC_BCMS_MEDIA_URL` (or the integration's `mediaUrl` option) to match, or that site's resized images stay on the CDN while everything else moves. Read the current answer from get_project: `useCdn` is the choice, `mediaBaseUrl` is the origin it produces \u2014 the value the frontend's `PUBLIC_BCMS_MEDIA_URL` must match. Re-callable; the last answer wins.",
4694
- // Optional in the schema for the same reason `framework` and `preference` are: a
4695
- // required arg is rejected by the SDK before the handler runs, which would kill the
4696
- // elicitation below and leave the model guessing.
4697
- z.object({ useCdn: z.boolean().optional().describe("REQUIRED in effect \u2014 the USER's answer. true = CDN (the default), false = API origin. Ask them; never default it.") }).shape,
4698
- async (c, a) => {
4699
- let useCdn = a.useCdn;
4700
- if (useCdn === void 0) {
4701
- const asked = await askMediaDelivery(deps);
4702
- if ("prompt" in asked) return fail(asked.prompt);
4703
- useCdn = asked.value;
4704
- }
4705
- return ok("Recorded the media delivery origin.", await data(c, "PATCH", `/management/projects/current/media-delivery`, { useCdn }));
4706
- }
4741
+ "Record whether this project's media (images, video, files) is served from the BetterCMS CDN or from the API origin. `useCdn: true` is the DEFAULT and the recommendation: URLs are minted on the CDN host, or on the project's own CDN base when it stores media in its own bucket (BYOK). `useCdn: false` mints them on the API origin instead \u2014 the same service serving the same bytes, only under a different hostname. " + MEDIA_DEFAULT_NOTE + " \u{1F534} IT APPLIES TO URLs MINTED FROM NOW ON \u2014 new uploads, entry saves, get_media. It does NOT rewrite URLs already stored in published content, and it does not need to: the old URLs keep resolving. To move an existing image, re-save the field that holds it. \u{1F534} IT IS THE BACKEND HALF ONLY. A deployed frontend builds its own transform URLs with @bettercms-ai/image-url, which has its own media host \u2014 set `PUBLIC_BCMS_MEDIA_URL` (or the integration's `mediaUrl` option) to match, or that site's resized images stay on the CDN while everything else moves. Read the current answer from get_project: `useCdn` is the choice, `mediaBaseUrl` is the origin it produces \u2014 the value the frontend's `PUBLIC_BCMS_MEDIA_URL` must match. Re-callable; the last answer wins.",
4742
+ z.object({ useCdn: z.boolean().describe("true = CDN (the default), false = API origin") }).shape,
4743
+ async (c, a) => ok("Recorded the media delivery origin.", await data(c, "PATCH", `/management/projects/current/media-delivery`, { useCdn: a.useCdn }))
4707
4744
  ),
4708
4745
  def(
4709
4746
  "submit_conversion_receipt",
@@ -5018,7 +5055,7 @@ ${d.outline}` : "Proposed Content structure.", d);
5018
5055
  if (!modelId) return fail("This page has no backing content model; address its schema by modelId.");
5019
5056
  }
5020
5057
  if (!modelId) return fail("Pass modelId (a content model) or pageId (a page).");
5021
- const plan = planFieldEdit(await data(c, "GET", `/management/content/models/${s(modelId)}`), a, a.ifMatch);
5058
+ const plan = planFieldEdit(await data(c, "GET", `/management/content/models/${s(modelId)}`), withAgentConfig(a), a.ifMatch);
5022
5059
  if (!plan.ok) return fail(plan.error);
5023
5060
  const path = a.pageId ? `/management/pages/${s(a.pageId)}/model` : `/management/content/models/${s(modelId)}`;
5024
5061
  const updated = (await c.fetchJSON(c.url(path), {
@@ -5226,10 +5263,11 @@ ${d.outline}` : "Proposed Content structure.", d);
5226
5263
  "Get what to do next",
5227
5264
  NEXT_STEPS_DESCRIPTION,
5228
5265
  z.object({
5229
- importJunk: z.boolean().optional().describe("true = add rule import-junk: what an old import left, with removal calls")
5266
+ importJunk: z.boolean().optional().describe("true = add rule import-junk: what an old import left, with removal calls"),
5267
+ full: z.boolean().optional().describe(PARAM_TEXT.nextStepsFull)
5230
5268
  }).shape,
5231
5269
  // The whole body: `receipt` rides beside `data`, and `data()` would drop it.
5232
- async (c, a) => ok("Next steps.", await c.fetchJSON(c.url(`/management/insights/next-steps${a.importJunk === true ? "?importJunk=true" : ""}`), { method: "GET" }))
5270
+ async (c, a) => ok("Next steps.", await c.fetchJSON(c.url(nextStepsPath(`/management/insights/next-steps`, a)), { method: "GET" }))
5233
5271
  ),
5234
5272
  def(
5235
5273
  "get_binding_report",
@@ -5506,9 +5544,16 @@ ${d.outline}` : "Proposed Content structure.", d);
5506
5544
  "get_deploy_status",
5507
5545
  "Get deploy/build status",
5508
5546
  "Get the connected project's deploy/build status: state (idle|queued|building|failed), whether it's publishing, the live commit sha, when it went live, and any build error. `editorSetup`: whether editing is set up for the live build (never or stale = call editor_setup). Poll this after deploy_project until state is idle with your sha live. `release`, `held` and `publishes` (each `live` or `partial`): skill publishing. `runtime` ({state: ok|crash-loop, restarts, oomKilled}) appears for a server app only: crash-loop means its container keeps restarting (often out of memory) though the build went live fine. Also returns `canvas.lane` (skill bindings) and `editorUrl`, the visual editor to hand the user.",
5509
- z.object({}).shape,
5547
+ z.object({ waitSeconds: z.number().optional().describe(PARAM_TEXT.waitSeconds) }).shape,
5510
5548
  // Flat body, no `data` envelope: `data()` read it as undefined.
5511
- async (c) => ok("Deploy status.", await c.fetchJSON(c.url(`/management/projects/deploy-status`), { method: "GET" }))
5549
+ async (c, a) => ok("Deploy status.", await c.fetchJSON(c.url(withWait(`/management/projects/deploy-status`, a.waitSeconds)), { method: "GET" }))
5550
+ ),
5551
+ def(
5552
+ "rebuild_project",
5553
+ "Rebuild from the connected repository",
5554
+ REBUILD_DESCRIPTION,
5555
+ z.object({}).shape,
5556
+ async (c) => ok("Rebuild queued.", await c.fetchJSON(c.url(`/management/projects/rebuild`), { method: "POST" }))
5512
5557
  ),
5513
5558
  def(
5514
5559
  "editor_setup",
@@ -5517,13 +5562,14 @@ ${d.outline}` : "Proposed Content structure.", d);
5517
5562
  z.object({
5518
5563
  force: z.boolean().optional().describe(PARAM_TEXT.editorSetupForce),
5519
5564
  check: z.boolean().optional().describe(PARAM_TEXT.editorSetupCheck),
5565
+ waitSeconds: z.number().optional().describe(PARAM_TEXT.waitSeconds),
5520
5566
  exempt: z.array(z.record(z.string(), z.unknown())).optional().describe(PARAM_TEXT.editorSetupExempt),
5521
5567
  declare: z.array(z.record(z.string(), z.unknown())).optional().describe(PARAM_TEXT.editorSetupDeclare)
5522
5568
  }).shape,
5523
5569
  // The route answers `{ state, … }` with no `data` envelope, so the whole body is the result.
5524
5570
  // `exempt` records setup-gate exceptions instead of running anything (the gate's one write).
5525
5571
  // `declare` records binding decisions; with `check: true` it is a dry run (the body's `dryRun`).
5526
- async (c, a) => a.exempt ? ok("Setup gate exceptions.", await c.fetchJSON(c.url(`/management/projects/setup-gate/exceptions`), { method: "POST", body: JSON.stringify({ exempt: a.exempt }) })) : a.declare ? ok("Binding decisions.", await c.fetchJSON(c.url(`/management/projects/binding-decisions`), { method: "POST", body: JSON.stringify({ declare: a.declare, ...a.check === true ? { dryRun: true } : {} }) })) : ok("Editor setup.", await c.fetchJSON(c.url(`/management/projects/editor-setup`), a.check === true ? { method: "GET" } : { method: "POST", body: JSON.stringify({ force: a.force }) }))
5572
+ async (c, a) => a.exempt ? ok("Setup gate exceptions.", await c.fetchJSON(c.url(`/management/projects/setup-gate/exceptions`), { method: "POST", body: JSON.stringify({ exempt: a.exempt }) })) : a.declare ? ok("Binding decisions.", await c.fetchJSON(c.url(`/management/projects/binding-decisions`), { method: "POST", body: JSON.stringify({ declare: a.declare, ...a.check === true ? { dryRun: true } : {} }) })) : ok("Editor setup.", await c.fetchJSON(c.url(a.check === true ? withWait(`/management/projects/editor-setup`, a.waitSeconds) : `/management/projects/editor-setup`), a.check === true ? { method: "GET" } : { method: "POST", body: JSON.stringify({ force: a.force }) }))
5527
5573
  ),
5528
5574
  // ── Deploy to the user's own host (parity with remote /mcp) ──
5529
5575
  // A person connects Vercel or Netlify in the dashboard; nothing here touches OAuth or a host
@@ -6819,13 +6865,11 @@ build proves every binding declared; get_next_steps lists only what it could not
6819
6865
  without CI checks (\`"build:bcms": "astro build"\` beside \`"build": "astro check && astro build"\`),
6820
6866
  keeping any step the build needs. The convert codemod adds it; never edit \`build\` for BetterCMS.
6821
6867
 
6822
- \`deploy_project\` and \`promote_project\` both answer **409
6823
- AUTHORING_DECISION_REQUIRED** until a human has chosen this project's architecture. This is
6824
- asked ONCE per project, ever. It is not an error to retry or route around: read the message
6825
- out, let the user pick, call \`set_authoring_preference\`, then deploy again.
6868
+ The authoring architecture is never asked: a deploy, a promote and every \`editor_setup\` record
6869
+ \`components\` when nothing is set, and the owner can switch to \`fields\` in the dashboard (or
6870
+ \`set_authoring_preference\`). Say in your final report which one is recorded.
6826
6871
 
6827
- Until someone answers, \`editor_setup\` derives the pages but componentizes nothing (the
6828
- preference is null). Once \`components\` is recorded, each \`editor_setup\` componentizes the
6872
+ With \`components\` recorded, each \`editor_setup\` componentizes the
6829
6873
  derived pages' NEW sections in bind mode; \`fields\` stops it for good. And a run over the WHOLE plan splits a section component an earlier run placed on
6830
6874
  2 pages into one per page (the first page keeps it, the others get a clone), so two pages' heroes
6831
6875
  stop editing each other; a run narrowed to one page or section never splits.
@@ -8286,8 +8330,8 @@ Read bettercms://playbook/schema section 11 first, then work this order:
8286
8330
  1. EDITOR SETUP \u2014 the build imported only SEO/meta, media, brand and the models the repo
8287
8331
  declares. Call editor_setup and poll editor_setup { check: true } until \`ready\`: it derives
8288
8332
  the pages, collections, forms and bindings from the live build.
8289
- 2. AUTHORING GATE \u2014 if get_next_steps or a deploy (409 AUTHORING_DECISION_REQUIRED) asks,
8290
- ask ME components-or-fields (do not choose), then set_authoring_preference.
8333
+ 2. AUTHORING \u2014 components is recorded by default (deploy, promote and editor_setup set it when
8334
+ nothing is); never ask me. Report which one is recorded.
8291
8335
  3. COMPONENTS: get_componentize_plan -> componentize_sections (dryRun first) -> publish the
8292
8336
  components and pages -> componentize_sections { codemod: true } (the section-order codemod in
8293
8337
  the repo). FIELDS: the convert_site loop (get_conversion_brief, the codemod, the receipt).
@@ -8326,16 +8370,16 @@ On 401/403, the MCP key needs (re)authorizing.`
8326
8370
  }
8327
8371
 
8328
8372
  // src/skill-docs.ts
8329
- import { existsSync, readdirSync, readFileSync } from "fs";
8373
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "fs";
8330
8374
  import { join } from "path";
8331
- import { fileURLToPath } from "url";
8375
+ import { fileURLToPath as fileURLToPath2 } from "url";
8332
8376
  var SKILL_URI_PREFIX = "bettercms://skill/";
8333
8377
  var CORE_SKILL = "bettercms";
8334
8378
  var CANDIDATES = ["./skills/bettercms/", "../../skills/bettercms/"];
8335
8379
  function defaultSkillDir() {
8336
8380
  for (const rel of CANDIDATES) {
8337
- const dir = fileURLToPath(new URL(rel, import.meta.url));
8338
- if (existsSync(join(dir, "SKILL.md"))) return dir;
8381
+ const dir = fileURLToPath2(new URL(rel, import.meta.url));
8382
+ if (existsSync2(join(dir, "SKILL.md"))) return dir;
8339
8383
  }
8340
8384
  return null;
8341
8385
  }
@@ -8375,104 +8419,17 @@ function skillDocOf(stem, text2) {
8375
8419
  }
8376
8420
  function loadSkillDocs(dir = defaultSkillDir()) {
8377
8421
  if (!dir) return [];
8378
- const docs = [skillDocOf(CORE_SKILL, readFileSync(join(dir, "SKILL.md"), "utf8"))];
8422
+ const docs = [skillDocOf(CORE_SKILL, readFileSync2(join(dir, "SKILL.md"), "utf8"))];
8379
8423
  const refs = join(dir, "references");
8380
- if (!existsSync(refs)) return docs;
8424
+ if (!existsSync2(refs)) return docs;
8381
8425
  for (const file of readdirSync(refs).filter((f) => f.endsWith(".md")).sort()) {
8382
8426
  const stem = file.slice(0, -3);
8383
8427
  if (stem === CORE_SKILL) continue;
8384
- docs.push(skillDocOf(stem, readFileSync(join(refs, file), "utf8")));
8428
+ docs.push(skillDocOf(stem, readFileSync2(join(refs, file), "utf8")));
8385
8429
  }
8386
8430
  return docs;
8387
8431
  }
8388
8432
 
8389
- // src/kit.ts
8390
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
8391
- import { fileURLToPath as fileURLToPath2 } from "url";
8392
- var KIT_URI = "bettercms://kit";
8393
- var KIT_ENTRY_URI_PREFIX = "bettercms://kit/entry/";
8394
- var KIT_ENTRY_URI_TEMPLATE = `${KIT_ENTRY_URI_PREFIX}{id}`;
8395
- function loadKitCatalog(path = fileURLToPath2(new URL("./kit-catalog.json", import.meta.url))) {
8396
- return existsSync2(path) ? JSON.parse(readFileSync2(path, "utf8")) : null;
8397
- }
8398
- var list = (xs) => xs.map((x) => `- ${x}`).join("\n");
8399
- function entryNotes(e, h = "###") {
8400
- const out = [`${h} ${e.name} (\`${e.id}\`)`, "", `Tags: ${e.tags.join(", ") || "none"}. Proves: ${e.proves}`];
8401
- if (!e.why && !e.mcp) out.push("", "No model notes in the kit for this entry: read its props.");
8402
- if (e.why) out.push("", `**Why this shape.** ${e.why}`);
8403
- if (e.htmlSignals.length) out.push("", "**HTML signals.**", list(e.htmlSignals));
8404
- if (e.mcp) out.push("", `**Do.** ${e.mcp}`);
8405
- if (e.pitfalls.length) out.push("", "**Pitfalls.**", list(e.pitfalls));
8406
- if (e.notCarried.length) out.push("", `Not carried by a stored component: ${e.notCarried.join(", ")}.`);
8407
- return out.join("\n");
8408
- }
8409
- function kitIndexText(k) {
8410
- const byId = new Map(k.entries.map((e) => [e.id, e]));
8411
- return [
8412
- "# BetterCMS kit catalogue",
8413
- "",
8414
- `${k.entries.length} modelled website sections. Before designing a section, find the closest entry by its edge tag or category, read its notes and follow its mcp + pitfalls; its create_component props are at ${KIT_ENTRY_URI_TEMPLATE}.`,
8415
- "",
8416
- "## Categories",
8417
- "",
8418
- ...k.categories.map((c) => `- ${c.name} (${c.entries.length}): ${KIT_URI}/${c.slug}`),
8419
- "",
8420
- "## Edge tags",
8421
- "",
8422
- ...k.tags.map((t) => `- \`${t.tag}\` (${t.hint}): ${t.entries.join(", ") || "none"}`),
8423
- "",
8424
- "## Entries",
8425
- "",
8426
- ...k.categories.flatMap((c) => [
8427
- `### ${c.name}`,
8428
- "",
8429
- ...c.entries.map((id) => {
8430
- const e = byId.get(id);
8431
- return `- \`${e.id}\` ${e.name} [${e.tags.join(", ")}]: ${e.proves}`;
8432
- }),
8433
- ""
8434
- ])
8435
- ].join("\n");
8436
- }
8437
- function kitCategoryText(k, slug2) {
8438
- const c = k.categories.find((x) => x.slug === slug2);
8439
- const byId = new Map(k.entries.map((e) => [e.id, e]));
8440
- return [
8441
- `# Kit: ${c.name}`,
8442
- "",
8443
- `${c.entries.length} entries. Each one's create_component props: ${KIT_ENTRY_URI_TEMPLATE}. Index: ${KIT_URI}.`,
8444
- "",
8445
- ...c.entries.map((id) => entryNotes(byId.get(id), "##") + "\n")
8446
- ].join("\n");
8447
- }
8448
- function kitEntryText(e) {
8449
- return [
8450
- entryNotes(e, "#"),
8451
- "",
8452
- "## create_component payload",
8453
- "",
8454
- "Props in the exact shape create_component accepts. Adapt keys and copy to the site; keep the types, `ui` and config.",
8455
- "",
8456
- "```json",
8457
- JSON.stringify(e.component, null, 1),
8458
- "```",
8459
- ""
8460
- ].join("\n");
8461
- }
8462
- function kitDocs(k = loadKitCatalog()) {
8463
- if (!k) return [];
8464
- const doc = (uri, name, title, description, text2) => ({ uri, name, title, description, mimeType: "text/markdown", text: text2 });
8465
- return [
8466
- doc(KIT_URI, "kit", "BetterCMS kit catalogue", "Index of modelled website sections by category and edge tag: find the closest one before designing a section.", kitIndexText(k)),
8467
- ...k.categories.map(
8468
- (c) => doc(`${KIT_URI}/${c.slug}`, `kit-${c.slug}`, `Kit: ${c.name}`, `Model notes for the ${c.entries.length} ${c.name} entries: why, HTML signals, mcp, pitfalls.`, kitCategoryText(k, c.slug))
8469
- )
8470
- ];
8471
- }
8472
- function kitEntryTexts(k = loadKitCatalog()) {
8473
- return k ? Object.fromEntries(k.entries.map((e) => [`${KIT_ENTRY_URI_PREFIX}${e.id}`, kitEntryText(e)])) : {};
8474
- }
8475
-
8476
8433
  // src/server.ts
8477
8434
  var SERVER_NAME = "bettercms";
8478
8435
  var SERVER_VERSION = "1.5.0";
@@ -8498,7 +8455,7 @@ function buildServer(deps) {
8498
8455
  // 🔴 The plain-language ask, routed — the stdio twin of the hosted connector's line
8499
8456
  // (src/routes/mcp/index.ts MCP_INSTRUCTIONS). "Make my site editable" has one recipe and
8500
8457
  // one definition of done; without this an agent converts the page it landed on and stops.
8501
- instructions: TOOL_LOADING_INSTRUCTION + " " + RECEIPT_FIRST_INSTRUCTION + " When the user asks to make a site or all of its pages editable and they were DERIVED at import: editor_setup, then get_next_steps until `receipt.gate.complete`. To convert its source or bind its fields: this is playbook \xA713. Read `bettercms://playbook/schema` \xA713, call get_binding_report and get_conversion_brief { complete: true }, convert EVERY route the brief lists, and finish only when get_binding_report shows coverage.pending empty on every route \u2014 not when the first page works. On a workspace-wide connection pass projectId on every call; never ask the user to re-scope the connection. " + LAYOUT_CHROME_INSTRUCTION + " " + CTA_BUTTON_INSTRUCTION + " When the user asks to componentize the whole site, to turn every section into a component, or to build a component library from the site: this is playbook \xA712. Read `bettercms://playbook/schema` \xA712, start with get_site_composition, and use the batch tools \u2014 componentize_sections, compose_pages, update_layout with `commands`, publish_components \u2014 rather than one call per component. Finish with get_site_composition and tell the user what the platform does not model (cookie banners, modals, breadcrumbs, pagination) and which components still need the owner's approval in the dashboard before they can be published. " + // The default after authoring (the structure standard): organise what you made. Same
8458
+ instructions: RECEIPT_FIRST_INSTRUCTION + " " + NO_QUESTIONS_INSTRUCTION + " " + TOOL_LOADING_INSTRUCTION + " When the user asks to make a site or all of its pages editable and they were DERIVED at import: editor_setup, then get_next_steps until `receipt.gate.complete`. To convert its source or bind its fields: this is playbook \xA713. Read `bettercms://playbook/schema` \xA713, call get_binding_report and get_conversion_brief { complete: true }, convert EVERY route the brief lists, and finish only when get_binding_report shows coverage.pending empty on every route \u2014 not when the first page works. On a workspace-wide connection pass projectId on every call; never ask the user to re-scope the connection. " + LAYOUT_CHROME_INSTRUCTION + " " + CTA_BUTTON_INSTRUCTION + " When the user asks to componentize the whole site, to turn every section into a component, or to build a component library from the site: this is playbook \xA712. Read `bettercms://playbook/schema` \xA712, start with get_site_composition, and use the batch tools \u2014 componentize_sections, compose_pages, update_layout with `commands`, publish_components \u2014 rather than one call per component. Finish with get_site_composition and tell the user what the platform does not model (cookie banners, modals, breadcrumbs, pagination) and which components still need the owner's approval in the dashboard before they can be published. " + // The default after authoring (the structure standard): organise what you made. Same
8502
8459
  // sentence as the hosted connector's MCP_INSTRUCTIONS.
8503
8460
  STRUCTURE_DEFAULT_INSTRUCTION + " " + SKILLS_ROUTING_INSTRUCTION + " " + DOCK_UI_ROUTING_INSTRUCTION + " " + PUBLISH_HOLD_INSTRUCTION
8504
8461
  }
@@ -8555,7 +8512,9 @@ function buildServer(deps) {
8555
8512
  // `project` is the per-call target of a workspace-wide grant; the SDK sends it as
8556
8513
  // X-BCMS-Project. Omitted entirely when absent so a project-scoped key's request is
8557
8514
  // byte-identical to before.
8558
- createClient: (apiKey, project, branch) => withBranch(BetterCMS.management({ apiKey, baseUrl: deps.managementBaseUrl, ...project ? { project } : {} }), branch)
8515
+ // 🔴 65 s, not the SDK's 10 s: get_deploy_status / editor_setup { waitSeconds } hold the request for up to 50 s
8516
+ // while a build or a setup settles (tool-text.ts withWait), and a 10 s client timed every one of them out.
8517
+ createClient: (apiKey, project, branch) => withBranch(BetterCMS.management({ apiKey, baseUrl: deps.managementBaseUrl, timeout: 65e3, ...project ? { project } : {} }), branch)
8559
8518
  });
8560
8519
  registerPrompts(server);
8561
8520
  return server;