@bettercms-ai/mcp 0.53.1 → 0.54.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 +111 -27
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2280,7 +2280,9 @@ var fieldShape = {
|
|
|
2280
2280
|
),
|
|
2281
2281
|
label: z.string().min(1).describe("human label shown in the editor"),
|
|
2282
2282
|
type: fieldType,
|
|
2283
|
-
required: z.boolean().optional()
|
|
2283
|
+
required: z.boolean().optional().describe(
|
|
2284
|
+
"an entry cannot be saved without a value \u2014 set it only where the site genuinely cannot render without one"
|
|
2285
|
+
),
|
|
2284
2286
|
richText: z.boolean().optional().describe(
|
|
2285
2287
|
"prose formatting. DEFAULTS TO TRUE for 'text': the field is created as rich text so editors can bold, link and format it on the canvas, and the API returns rich text (render with rich() from @bettercms-ai/sdk; plain() for titles and meta). Pass false for a value that is NOT prose and must stay a bare string \u2014 a URL/href, a slug, an id, an email, a phone number, a CSS class, an icon name. A link stored as rich text will not work as an href."
|
|
2286
2288
|
),
|
|
@@ -2288,7 +2290,7 @@ var fieldShape = {
|
|
|
2288
2290
|
"choices when type is 'select'. A plain string, or {label, value} when the stored value differs from what the editor shows (e.g. {label:'Sold out', value:'soldOut'})."
|
|
2289
2291
|
),
|
|
2290
2292
|
config: z.record(z.string(), z.unknown()).optional().describe(
|
|
2291
|
-
"per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime}, modular {blockSlugs: ['quote','gallery'], minItems?, maxItems?} \u2014 blockSlugs is REQUIRED, non-empty, and each slug must name an existing kind:'block' model"
|
|
2293
|
+
"per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime}, modular {blockSlugs: ['quote','gallery'], minItems?, maxItems?} \u2014 blockSlugs is REQUIRED, non-empty, and each slug must name an existing kind:'block' model; richtext {inline:true, hostTag:'h2', accept:['h2','p','b','i','u','link'], features:['bold','italic','underline','link','headings'], allowMultipleParagraphs:false} \u2014 for a field bound to ONE element on the page: `hostTag` is that element's tag and `accept` must name it, so the editor's block-style control opens already showing 'Heading 2' instead of 'Paragraph'. Name exactly ONE heading level: publish refuses an <h3> value bound to an <h2> rather than nesting one heading inside the other. Keep 'p' in `accept` or the sanitiser strips a block demoted to Paragraph"
|
|
2292
2294
|
),
|
|
2293
2295
|
fields: z.array(z.lazy(() => fieldObject)).optional().describe(
|
|
2294
2296
|
"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."
|
|
@@ -2314,7 +2316,7 @@ var fieldShape = {
|
|
|
2314
2316
|
"conditional visibility \u2014 hide this field until another field has a given value (e.g. show `external_url` only when `link_type` is 'external'). Admin-only: it changes what an author SEES, never what is stored or delivered. Rules reference fields by BARE KEY, and the key namespace is flat, so the key must exist on this model."
|
|
2315
2317
|
),
|
|
2316
2318
|
ui: uiObject.optional().describe(
|
|
2317
|
-
"AUTHORING CHROME \u2014 how this field presents in the editor
|
|
2319
|
+
"AUTHORING CHROME \u2014 how this field presents in the editor; never a delivery effect. `preview` slots name a CHILD FIELD KEY and must name real children, or the write is refused with the valid keys listed. `preview`/`layout` need a nesting field, `reorderable` needs a list, and an unknown key inside `ui` is a 400, not a silent strip. No `order`: field order is the array index \u2014 use `after`. Section 14 of bettercms://playbook/schema has each key and where it is legal."
|
|
2318
2320
|
),
|
|
2319
2321
|
// ── G5: the four keys the backend field schema accepts that this whitelist stripped. ──
|
|
2320
2322
|
fieldsetId: z.string().min(1).optional().describe(
|
|
@@ -2424,11 +2426,12 @@ function toField(f) {
|
|
|
2424
2426
|
...f.config ? { config: f.config } : {}
|
|
2425
2427
|
};
|
|
2426
2428
|
}
|
|
2427
|
-
var BRAND_KIT_NOTE = "`brandKit` is the project's brand kit
|
|
2429
|
+
var BRAND_KIT_NOTE = "`brandKit` is the project's brand kit: colors, typography, radius and any typeScale/spacing/elevation/motion/graphics. It is edited in the BetterCMS dashboard AND by set_brand_assets and set_brand_graphics below; every write is a version a person can restore. ASSETS: point `mark`/`favicon` at a media asset OF THIS PROJECT by id (another project's id is refused); null clears one; a slot a person chose needs `replace: true`. GRAPHICS: gradients are STRUCTURED \u2014 `kind`, optional `angle`, 2-8 stops naming kit colour keys (`colors.*` or an `extras` key) \u2014 never a CSS string and never a hex; a colour the kit lacks is added to `extras` first. CONSUME, DO NOT COPY: on a hosted page the kit is `--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) rather than copying its values into props. Never invent brand facts: read them here.";
|
|
2428
2430
|
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.";
|
|
2429
2431
|
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.";
|
|
2430
2432
|
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).";
|
|
2431
|
-
var STRUCTURE_NOTE = 'ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state
|
|
2433
|
+
var STRUCTURE_NOTE = 'ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state; URL folders (page folders) are set in the dashboard. Folders nest at most 4 levels deep. Icons are lucide icon names (a-z, 0-9, dashes); null clears one. SYSTEM FOLDERS: "home:pages" (Other pages), "home:collections" (Shared) and "home:globals" (Settings) always exist and can never be moved or deleted (400 NAV_SYSTEM_FOLDER); rename_folder and set_icon do work on them. TYPE PURITY (400 NAV_TYPE_MISMATCH): Other pages holds only pages at any depth, Shared only collections, and Settings neither \u2014 globals are not sidebar items. ACCESS: changing the structure needs Admin or Developer, the same as editing the schema; reading needs only content access. A 403 SCHEMA_ACCESS_REQUIRED means this connection is neither: stop and tell the user, do not retry. 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). set_content_structure never refuses a kind mismatch: it returns `warnings` and `kindMismatches` (the single writes do \u2014 see `kind`). PAGE-BOUND MODELS: a page with fields has a model bound to it. That model is NOT a collection: it is never listed or filed, and the writes refuse it with 400 NAV_PAGE_BOUND_MODEL naming its page. Organise the PAGE instead, by its page id. ';
|
|
2434
|
+
var STRUCTURE_FENCE = "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. Read the resource bettercms://playbook/structure before you organise anything: it holds the rules this refuses on \u2014 system folders, type purity, the Admin/Developer access check, root pins and page-bound models. A resource is never truncated; a long description is.";
|
|
2432
2435
|
function structureResult(d) {
|
|
2433
2436
|
const message = d?.message;
|
|
2434
2437
|
return ok(typeof message === "string" ? message : "Updated the Content structure.", d);
|
|
@@ -2522,7 +2525,7 @@ function authPrompt(err) {
|
|
|
2522
2525
|
}
|
|
2523
2526
|
var currentProject = new AsyncLocalStorage();
|
|
2524
2527
|
var projectIdArg = z.string().min(1).optional().describe(
|
|
2525
|
-
"
|
|
2528
|
+
"target project id (from list_projects); only for a workspace-wide grant."
|
|
2526
2529
|
);
|
|
2527
2530
|
var withProjectId = (def) => ({
|
|
2528
2531
|
...def,
|
|
@@ -2532,6 +2535,47 @@ var withProjectId = (def) => ({
|
|
|
2532
2535
|
return currentProject.run(project, () => def.handler(args));
|
|
2533
2536
|
}
|
|
2534
2537
|
});
|
|
2538
|
+
var LISTING_TAIL = /* @__PURE__ */ new Set([
|
|
2539
|
+
// The agent-job lane: a dashboard surface an agent rarely drives.
|
|
2540
|
+
"list_ai_jobs",
|
|
2541
|
+
"get_ai_job",
|
|
2542
|
+
"approve_ai_job",
|
|
2543
|
+
"reject_ai_job",
|
|
2544
|
+
"list_ai_reports",
|
|
2545
|
+
"connect_native_ai",
|
|
2546
|
+
"native_bridge_heartbeat",
|
|
2547
|
+
// Section evidence and component validation: a CI lane, not an authoring one.
|
|
2548
|
+
"claim_section_validation_request",
|
|
2549
|
+
"complete_section_validation_request",
|
|
2550
|
+
"fail_section_validation_request",
|
|
2551
|
+
"list_section_validation_requests",
|
|
2552
|
+
"submit_section_validation",
|
|
2553
|
+
"submit_section_manifest",
|
|
2554
|
+
"request_component_validation",
|
|
2555
|
+
"declare_component_route",
|
|
2556
|
+
"clear_component_source",
|
|
2557
|
+
"submit_componentize_receipt",
|
|
2558
|
+
// Reporting, history and workflow chrome.
|
|
2559
|
+
"get_analytics_overview",
|
|
2560
|
+
"get_analytics_top_pages",
|
|
2561
|
+
"list_activity",
|
|
2562
|
+
"get_workflow_board",
|
|
2563
|
+
"move_entry_stage",
|
|
2564
|
+
"move_page_stage",
|
|
2565
|
+
"list_entry_versions",
|
|
2566
|
+
"restore_entry_version",
|
|
2567
|
+
// One-off project operations and the extraction lane.
|
|
2568
|
+
"clone_project",
|
|
2569
|
+
"promote_project",
|
|
2570
|
+
"generate_pages_from_dataset",
|
|
2571
|
+
"extract_component",
|
|
2572
|
+
"list_extraction_candidates",
|
|
2573
|
+
"create_deploy_upload",
|
|
2574
|
+
"deploy_from_upload",
|
|
2575
|
+
"set_authoring_preference",
|
|
2576
|
+
"set_media_delivery",
|
|
2577
|
+
"delete_form_submission"
|
|
2578
|
+
]);
|
|
2535
2579
|
function buildToolDefs(deps) {
|
|
2536
2580
|
async function withClient(fn) {
|
|
2537
2581
|
const token = await deps.auth.getAccessToken();
|
|
@@ -2586,7 +2630,7 @@ function buildToolDefs(deps) {
|
|
|
2586
2630
|
]).describe("block type; section/slider/tabs/columns nest child blocks"),
|
|
2587
2631
|
id: z.string().min(1).describe("stable unique block id"),
|
|
2588
2632
|
props: z.record(z.string(), z.unknown()).describe(
|
|
2589
|
-
"per-type props: heading {text, level}; text/richtext {html} (NOT {text}); image {src, alt}; button {text, href}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may NOT hold columns/section/slider/tabs; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>"
|
|
2633
|
+
"per-type props: heading {text, level}; text/richtext {html} (NOT {text}), and both also take level 1-6, rendering the html AS that <hN> with inline marks kept (one line of inline copy only) \u2014 use it for a title that carries a styled span; image {src, alt}; button {text, href}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may NOT hold columns/section/slider/tabs; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>"
|
|
2590
2634
|
),
|
|
2591
2635
|
style: z.record(z.string(), z.unknown()).optional().describe(
|
|
2592
2636
|
"design tokens: theme, bg (none|surface|muted|accent|dark|custom), bgCustom hex, paddingTop/paddingBottom/paddingSides px, contentWidth (narrow|default|wide|full), align, corner, shadow, borderTop/borderBottom. A real marketing band is a `section` block carrying bg + padding + contentWidth."
|
|
@@ -2748,8 +2792,11 @@ function buildToolDefs(deps) {
|
|
|
2748
2792
|
]),
|
|
2749
2793
|
placeholder: z.string().optional(),
|
|
2750
2794
|
helpText: z.string().optional().describe("hint shown under the control, muted"),
|
|
2751
|
-
required: z.boolean().optional()
|
|
2795
|
+
required: z.boolean().optional().describe(
|
|
2796
|
+
"an entry cannot be saved without a value \u2014 set it only where the site genuinely cannot render without one"
|
|
2797
|
+
),
|
|
2752
2798
|
options: z.array(z.string()).optional().describe("choices when type is 'select', 'radio' or 'checkboxes'"),
|
|
2799
|
+
optionValues: z.array(z.string()).optional().describe("what each choice SUBMITS, positionally paired with `options` \u2014 only where it differs from the label"),
|
|
2753
2800
|
hidden: z.boolean().optional().describe("not rendered; pairs with defaultValue to capture context"),
|
|
2754
2801
|
defaultValue: z.string().optional(),
|
|
2755
2802
|
showIf: z.object({ field: z.string(), equals: z.string() }).optional().describe("show this field only when another field equals a value"),
|
|
@@ -3154,11 +3201,11 @@ function buildToolDefs(deps) {
|
|
|
3154
3201
|
z.object({ formId: z.string().min(1), submissionId: z.string().min(1) }).shape,
|
|
3155
3202
|
async (c, a) => ok("Deleted submission.", await data(c, "DELETE", `/management/forms/${s(a.formId)}/submissions/${s(a.submissionId)}`))
|
|
3156
3203
|
),
|
|
3157
|
-
// ── Content structure: the Content sidebar's folders and icons
|
|
3204
|
+
// ── Content structure: the Content sidebar's folders and icons ──
|
|
3158
3205
|
def(
|
|
3159
3206
|
"get_content_structure",
|
|
3160
3207
|
"Get the Content sidebar structure",
|
|
3161
|
-
`
|
|
3208
|
+
`Returns { doc, version, summary }: \`doc\` is the raw document ({ schema: 1, folders: [{ id, parentId, name, icon, sort }], items: [{ kind: 'page'|'collection', id, folderId, sort, icon }] }), \`version\` is what set_content_structure needs, and \`summary\` is the resolved tree with page and collection TITLES (\`outline\` is a readable version), ids that no longer resolve (\`missing\`), and the pages and collections not in any folder yet. Call it before changing the structure, and read the resource ${STRUCTURE_PLAYBOOK_URI} (the structure standard) before you organise anything. ${STRUCTURE_NOTE}`,
|
|
3162
3209
|
z.object({}).shape,
|
|
3163
3210
|
async (c) => {
|
|
3164
3211
|
const d = await data(c, "GET", `/management/content-structure`);
|
|
@@ -3182,7 +3229,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3182
3229
|
def(
|
|
3183
3230
|
"set_content_structure",
|
|
3184
3231
|
"Replace the Content sidebar structure",
|
|
3185
|
-
`Replace the whole Content sidebar document in one write. Follow the structure standard (read ${STRUCTURE_PLAYBOOK_URI} first; suggest_content_structure returns a ready doc).
|
|
3232
|
+
`Replace the whole Content sidebar document in one write. Follow the structure standard (read ${STRUCTURE_PLAYBOOK_URI} first; suggest_content_structure returns a ready doc). Send the complete \`doc\` and the \`version\` get_content_structure returned (0 when the project has none yet). Validated exactly like the dashboard: unique folder ids, every parentId/folderId a folder in the doc, no cycles, depth \u22644, names 1-80 chars, lucide icons. A 412 VERSION_CONFLICT means someone changed it since you read it: read again and re-apply. Returns the new version and a diff. ${STRUCTURE_FENCE}`,
|
|
3186
3233
|
z.object({
|
|
3187
3234
|
doc: z.object({
|
|
3188
3235
|
schema: z.literal(1),
|
|
@@ -3208,7 +3255,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3208
3255
|
def(
|
|
3209
3256
|
"create_folder",
|
|
3210
3257
|
"Create a Content sidebar folder",
|
|
3211
|
-
`Create a folder in the Content sidebar, at the top level or inside \`parentId\`, placed after what is already there. Returns the new \`folderId\`. Reads, applies and writes with the version check, retrying once on a conflict. ${
|
|
3258
|
+
`Create a folder in the Content sidebar, at the top level or inside \`parentId\`, placed after what is already there. Returns the new \`folderId\`. Reads, applies and writes with the version check, retrying once on a conflict. ${STRUCTURE_FENCE}`,
|
|
3212
3259
|
z.object({
|
|
3213
3260
|
name: z.string().min(1).describe("the folder's label, 1-80 chars"),
|
|
3214
3261
|
parentId: z.string().min(1).optional().describe("optional parent folder id (from get_content_structure); omit it, or send 'root', for the top level"),
|
|
@@ -3219,7 +3266,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3219
3266
|
def(
|
|
3220
3267
|
"rename_folder",
|
|
3221
3268
|
"Rename a Content sidebar folder",
|
|
3222
|
-
`Rename a Content sidebar folder. ${
|
|
3269
|
+
`Rename a Content sidebar folder. ${STRUCTURE_FENCE}`,
|
|
3223
3270
|
z.object({
|
|
3224
3271
|
folderId: z.string().min(1).describe("folder id (from get_content_structure)"),
|
|
3225
3272
|
name: z.string().min(1).describe("the new label, 1-80 chars")
|
|
@@ -3229,20 +3276,54 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3229
3276
|
def(
|
|
3230
3277
|
"move_to_folder",
|
|
3231
3278
|
"Move into a Content sidebar folder",
|
|
3232
|
-
`Move a page, a collection or a folder into a folder. folderId null moves it back to its home: Other pages for a page, the Shared list for a collection, the top level for a folder. It lands after what is already there. Moving a folder takes its contents with it and is refused if the result would nest deeper than 4 levels or put a folder inside itself. ${
|
|
3279
|
+
`Move a page, a collection or a folder into a folder. folderId null moves it back to its home: Other pages for a page, the Shared list for a collection, the top level for a folder. It lands after what is already there. Moving a folder takes its contents with it and is refused if the result would nest deeper than 4 levels or put a folder inside itself. ${STRUCTURE_FENCE}`,
|
|
3233
3280
|
z.object({
|
|
3234
|
-
kind: z.enum(["page", "collection", "folder"])
|
|
3281
|
+
kind: z.enum(["page", "collection", "folder"]).describe(
|
|
3282
|
+
"what the id IS on this branch \u2014 VERIFIED, not assumed: an id is either a page or a collection, never both, and a mismatch is refused with 400 NAV_KIND_MISMATCH naming the kind to use. Call it again with that kind, which also clears the wrong-kind entry."
|
|
3283
|
+
),
|
|
3235
3284
|
id: z.string().min(1).describe("the page id, collection (content model) id, or folder id"),
|
|
3236
3285
|
folderId: z.string().min(1).nullable().describe("target folder id; 'root' pins a page or collection at the top level (for a folder, 'root' is the top level); null moves it back to its home (Other pages, the Shared list, or the top level for a folder)")
|
|
3237
3286
|
}).shape,
|
|
3238
3287
|
async (c, a) => structureResult(await data(c, "POST", `/management/content-structure/move`, { kind: a.kind, id: a.id, folderId: a.folderId }))
|
|
3239
3288
|
),
|
|
3289
|
+
def(
|
|
3290
|
+
"set_brand_assets",
|
|
3291
|
+
"Point the brand's logo or favicon at a media asset",
|
|
3292
|
+
`Point the brand's logo (mark) or favicon at a media asset of this project. The asset must already be in this project's media library \u2014 upload it first if it is not. null clears a slot. A slot a PERSON chose is refused unless you pass replace: true, and the refusal says so. Every write is a brand version the dashboard can restore. ${BRAND_KIT_NOTE}`,
|
|
3293
|
+
z.object({
|
|
3294
|
+
mark: z.string().min(1).nullable().optional().describe("media asset id for the logo; null clears it"),
|
|
3295
|
+
favicon: z.string().min(1).nullable().optional().describe("media asset id for the favicon; null clears it"),
|
|
3296
|
+
replace: z.boolean().optional().describe("override a slot a person chose (default false)")
|
|
3297
|
+
}).shape,
|
|
3298
|
+
async (c, a) => ok("Brand assets.", await data(c, "POST", `/management/projects/current/brand-kit/assets`, { mark: a.mark, favicon: a.favicon, replace: a.replace }))
|
|
3299
|
+
),
|
|
3300
|
+
def(
|
|
3301
|
+
"set_brand_graphics",
|
|
3302
|
+
"Add, replace or remove the brand's gradients",
|
|
3303
|
+
`Add, replace or remove the brand's gradients \u2014 the blobs and washes a site uses as backgrounds. They are TOKENS, not media: a gradient is a value, so it is stored as a kind, an angle and stops that name the kit's own colour keys, never as CSS and never as a hex. A stop naming a colour the kit does not have is refused; add it to extras first. Upsert matches BY KEY. ${BRAND_KIT_NOTE}`,
|
|
3304
|
+
z.object({
|
|
3305
|
+
upsert: z.array(z.object({
|
|
3306
|
+
key: z.string().min(1).describe("token key: lowercase letters, digits and hyphens. Emitted as --brand-gradient-<key>"),
|
|
3307
|
+
label: z.string().optional(),
|
|
3308
|
+
kind: z.enum(["linear", "radial", "conic"]),
|
|
3309
|
+
angle: z.number().optional().describe("degrees 0-360; ignored for radial"),
|
|
3310
|
+
stops: z.array(z.object({
|
|
3311
|
+
color: z.string().min(1).describe("a kit colour KEY \u2014 a colors.* role name or an extras key. Never a hex."),
|
|
3312
|
+
at: z.number().describe("position 0-100")
|
|
3313
|
+
})).min(2).max(8)
|
|
3314
|
+
})).max(12).optional().describe("gradients to add or replace, matched BY KEY"),
|
|
3315
|
+
remove: z.array(z.string().min(1)).max(12).optional().describe("gradient keys to remove")
|
|
3316
|
+
}).shape,
|
|
3317
|
+
async (c, a) => ok("Brand graphics.", await data(c, "POST", `/management/projects/current/brand-kit/graphics`, { upsert: a.upsert, remove: a.remove }))
|
|
3318
|
+
),
|
|
3240
3319
|
def(
|
|
3241
3320
|
"set_icon",
|
|
3242
3321
|
"Set a Content sidebar icon",
|
|
3243
|
-
`Set or clear the sidebar icon of a page, a collection or a folder. ${
|
|
3322
|
+
`Set or clear the sidebar icon of a page, a collection or a folder. ${STRUCTURE_FENCE}`,
|
|
3244
3323
|
z.object({
|
|
3245
|
-
kind: z.enum(["page", "collection", "folder"])
|
|
3324
|
+
kind: z.enum(["page", "collection", "folder"]).describe(
|
|
3325
|
+
"what the id IS on this branch \u2014 VERIFIED, not assumed: an id is either a page or a collection, never both, and a mismatch is refused with 400 NAV_KIND_MISMATCH naming the kind to use. Call it again with that kind, which also clears the wrong-kind entry."
|
|
3326
|
+
),
|
|
3246
3327
|
id: z.string().min(1).describe("the page id, collection (content model) id, or folder id"),
|
|
3247
3328
|
icon: z.string().min(1).nullable().describe("lucide icon name, e.g. 'file-text'; null clears it")
|
|
3248
3329
|
}).shape,
|
|
@@ -3251,7 +3332,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3251
3332
|
def(
|
|
3252
3333
|
"delete_folder",
|
|
3253
3334
|
"Delete a folder (its contents move up; nothing is deleted)",
|
|
3254
|
-
`Delete a folder (its contents move up; nothing is deleted). NOTHING ELSE IS DELETED: its pages, collections and sub-folders move up to the folder's parent, in the same order. ${
|
|
3335
|
+
`Delete a folder (its contents move up; nothing is deleted). NOTHING ELSE IS DELETED: its pages, collections and sub-folders move up to the folder's parent, in the same order. ${STRUCTURE_FENCE}`,
|
|
3255
3336
|
z.object({ folderId: z.string().min(1).describe("folder id (from get_content_structure)") }).shape,
|
|
3256
3337
|
async (c, a) => structureResult(await data(c, "DELETE", `/management/content-structure/folders/${encodeURIComponent(s(a.folderId))}`))
|
|
3257
3338
|
),
|
|
@@ -3453,7 +3534,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3453
3534
|
def(
|
|
3454
3535
|
"submit_conversion_receipt",
|
|
3455
3536
|
"Record what the conversion codemod could and could not do",
|
|
3456
|
-
"Hand BetterCMS the codemod's own account of a
|
|
3537
|
+
"Hand BetterCMS the codemod's own account of a run, so the meter can say WHY a path is undeclared. Submit the receipt `npx @bettercms-ai/convert` wrote (`--receipt out.json`) for the SAME `briefDigest` get_conversion_brief { complete: true } returned: `{ briefDigest, receipt }`, where the receipt carries `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message, fix }] }`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length, and each pending `reason` is one of the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026) \u2014 a path with no receipt row simply reads `not-declared`. \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: The response answers `complete` and `pendingTotal`, and echoes the first 40 pending rows with their `fix` \u2014 `{ action, file, line, col?, snippet, why?, kind? }`. `action` is `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data` or `manual`; each row's `snippet` and `why` say what to change there. On a `declare-richtext` with `kind: document`, bind the ONE element wrapping every block of the Body, never the paragraph holding its first. Apply every fix, rerun, resubmit. `complete: true` is the only receipt that ends a conversion; do not report a site converted on less. It is a RECORD, not a release: it changes nothing about the site, and the meter picks it up on the next get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here. \u{1F534} SUBMIT THE `--forms` RECEIPT TOO, AS A SECOND CALL: A `npx @bettercms-ai/convert --forms` run writes a receipt whose `paths` are all zero and whose account is in a `forms` block. Write it to its own file (`--receipt forms-receipt.json`) and submit it under the same `briefDigest`: stored beside the binding receipt, never counted in coverage. Then read `forms.pending` and publish every form it names. Requires artifact:write, the same authority as set_binding_mode.",
|
|
3457
3538
|
z.object({
|
|
3458
3539
|
briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
|
|
3459
3540
|
receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim.")
|
|
@@ -3689,8 +3770,8 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3689
3770
|
def(
|
|
3690
3771
|
"get_binding_report",
|
|
3691
3772
|
"Check what on the live site is editable",
|
|
3692
|
-
"The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; this returns what that scan found, per slot: `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them),
|
|
3693
|
-
z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read
|
|
3773
|
+
"The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; this returns what that scan found, per slot: `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them), what was bound, and `unmatched` \u2014 each path with the reason it failed (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason` says which: `never-generated` \u2014 only a release BetterCMS builds and serves writes a report, so a HEADLESS site never gets one and redeploying will not change it (verify that site by fetching it); `outdated` \u2014 the next release rewrites it. It certifies one thing: every non-empty field has SOME element carrying its path. It cannot see copy that was never modelled, so diff each route's visible text against its entry values yourself before calling a page done. `builtRoutes` lists the routes the live build has HTML for, or null when it cannot say. `canvas.lane` names the live-preview lane this build gives the editor \u2014 `bridge`, `draft-route` or `none`, in which case get_next_steps carries the recipe (playbook section 11). `unaddressable` counts visible text on the live site that no field owns \u2014 the one thing `unmatched` structurally cannot see, because it only ever speaks about fields that already exist. EVERY release measures it server-side on every inspected page and the result names the routes, their countable characters and the buckets the unowned text sits in, biggest first. Anything over `count / visible > 0.02` is copy the CMS cannot see: fix it in the SOURCE (wrap the run in an element the codemod can bind, or declare `<BcmsField path=\u2026>`), deploy, re-read \u2014 playbook section 11 has the loop. `skipReasons` says why pages were not inspected; `coverage.error` = `nothing-bound` means not one element of this build carries a binding, so the percentage beside it describes a site this build is not.",
|
|
3774
|
+
z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read \u2014 everything here, `unaddressable` included, is measured PER SLOT, so on a promote-gated project pass 'current' to read the tree the editor frames. Defaults to the slot this project's releases land in.") }).shape,
|
|
3694
3775
|
async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
|
|
3695
3776
|
),
|
|
3696
3777
|
def(
|
|
@@ -3727,13 +3808,13 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3727
3808
|
def(
|
|
3728
3809
|
"componentize_sections",
|
|
3729
3810
|
"Turn this site's derived sections into components",
|
|
3730
|
-
"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
|
|
3811
|
+
"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 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. Read its `components.wouldDuplicate` before applying: each id is an existing component of the same family this run would sit a NEW one beside, because reuse is by identity and slug, never by family. 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`. The default `bind` is the above: the words stay in the page's field group. `copy: \"instance\"` moves the words onto each placement and marks the page's fields `origin: \"componentized\"` \u2014 kept, never deleted (see `copy`). 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.",
|
|
3731
3812
|
z.object({
|
|
3732
3813
|
digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
|
|
3733
3814
|
pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
|
|
3734
3815
|
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."),
|
|
3735
3816
|
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."),
|
|
3736
|
-
copy: z.enum(["bind", "instance"]).optional().describe("Who owns each section's copy
|
|
3817
|
+
copy: z.enum(["bind", "instance"]).optional().describe("Who owns each section's copy. 'bind' (default) leaves it in the page fields. 'instance' is the page-builder model: 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."),
|
|
3737
3818
|
dryRun: z.boolean().optional().describe("true = return the receipt without writing anything. Do this first."),
|
|
3738
3819
|
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.")
|
|
3739
3820
|
}).shape,
|
|
@@ -4447,7 +4528,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4447
4528
|
name: "create_components",
|
|
4448
4529
|
config: {
|
|
4449
4530
|
title: "Create many components in one call",
|
|
4450
|
-
description: "Create up to 50 components in ONE call \u2014 the same input as create_component, once per item. Prefer it over create_component whenever you are making more than three: a whole-site componentize run (playbook \xA712) is dozens, and one call each spends the turn on plumbing. Each item reports its own `{ ok, id, slug, error }`: a failure does NOT stop the run and the successful rows stay, so read the receipt and retry only the failures (a 409 on `slug` means that name is taken \u2014 change it, do not re-run the batch). Every component lands as a DRAFT, exactly as create_component does \u2014 it renders as NOTHING on the live site until it is published, which needs the owner's approval in the dashboard. Pass `group` on each item to file it into the folder editors browse.
|
|
4531
|
+
description: "Create up to 50 components in ONE call \u2014 the same input as create_component, once per item. Prefer it over create_component whenever you are making more than three: a whole-site componentize run (playbook \xA712) is dozens, and one call each spends the turn on plumbing. Each item reports its own `{ ok, id, slug, error }`: a failure does NOT stop the run and the successful rows stay, so read the receipt and retry only the failures (a 409 on `slug` means that name is taken \u2014 change it, do not re-run the batch). Every component lands as a DRAFT, exactly as create_component does \u2014 it renders as NOTHING on the live site until it is published, which needs the owner's approval in the dashboard. Pass `group` on each item to file it into the folder editors browse. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: inside a component only the leaves a declared prop TARGETS are click-to-edit, and copy no prop points at is reachable neither from the canvas nor from the dock. A page is composed of SECTIONS; the full doctrine is in the bettercms://playbook/schema resource, \xA712.",
|
|
4451
4532
|
inputSchema: createComponentsInput.shape
|
|
4452
4533
|
},
|
|
4453
4534
|
handler: guard(
|
|
@@ -4508,7 +4589,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4508
4589
|
name: "compose_pages",
|
|
4509
4590
|
config: {
|
|
4510
4591
|
title: "Set many pages' block composition in one call",
|
|
4511
|
-
description: "Give up to 50 pages their block composition in ONE call \u2014 the same write as update_page's `blockJson`, once per item, and it REPLACES each page's whole block array. Address a page by `pageId` or by `slug`. Each item reports `{ ok, pageId, slug, error }` and a failure does not stop the run. BEFORE writing anything it reads this project's component catalogue once and REFUSES any item placing a componentId the project does not have \u2014 that page would render the missing sections as empty strings with no error anywhere, which is the single hardest failure on this platform to trace back. Writes DRAFTS: publish each page with update_page status:'published' afterwards.
|
|
4592
|
+
description: "Give up to 50 pages their block composition in ONE call \u2014 the same write as update_page's `blockJson`, once per item, and it REPLACES each page's whole block array. Address a page by `pageId` or by `slug`. Each item reports `{ ok, pageId, slug, error }` and a failure does not stop the run. BEFORE writing anything it reads this project's component catalogue once and REFUSES any item placing a componentId the project does not have \u2014 that page would render the missing sections as empty strings with no error anywhere, which is the single hardest failure on this platform to trace back. Writes DRAFTS: publish each page with update_page status:'published' afterwards. A page is composed of SECTIONS: a recurring band is a component with a `sectionType`, a one-off band is a `section` block whose `props.children` hold its blocks \u2014 never loose top-level heading/text/image blocks, which no editor can move, name or swap as a unit. The full doctrine is in the bettercms://playbook/schema resource, \xA712.",
|
|
4512
4593
|
inputSchema: composePagesInput.shape
|
|
4513
4594
|
},
|
|
4514
4595
|
handler: guard(
|
|
@@ -4814,7 +4895,10 @@ ${lines.join("\n")}`, found);
|
|
|
4814
4895
|
// through the client's request plumbing — no bespoke SDK method per endpoint.
|
|
4815
4896
|
...lifecycleTools()
|
|
4816
4897
|
];
|
|
4817
|
-
return
|
|
4898
|
+
return [
|
|
4899
|
+
...defs.filter((d) => !LISTING_TAIL.has(d.name)),
|
|
4900
|
+
...defs.filter((d) => LISTING_TAIL.has(d.name))
|
|
4901
|
+
].map(withProjectId);
|
|
4818
4902
|
}
|
|
4819
4903
|
function registerTools(server, deps) {
|
|
4820
4904
|
const withElicit = {
|
|
@@ -6093,7 +6177,7 @@ in the dashboard \u2014 those render as an empty string until they are published
|
|
|
6093
6177
|
"convert_site",
|
|
6094
6178
|
{
|
|
6095
6179
|
title: "Convert a site so nothing is left pending (guided)",
|
|
6096
|
-
description: "Run the conversion end to end: pull the source, run @bettercms-ai/convert, apply every fix it lists as pending, rerun until nothing is pending, and finish only when submit_conversion_receipt returns complete and get_binding_report reads unmatched 0.",
|
|
6180
|
+
description: "Run the conversion end to end: pull the source, run @bettercms-ai/convert, apply every fix it lists as pending, rerun until nothing is pending, and finish only when submit_conversion_receipt returns complete and get_binding_report reads unmatched 0, coverage.pending empty and every route's unaddressable under 2%.",
|
|
6097
6181
|
argsSchema: {
|
|
6098
6182
|
projectId: z2.string().optional().describe("the project to convert; omit if this connection is scoped to one")
|
|
6099
6183
|
}
|