@bettercms-ai/mcp 0.38.1 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +535 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2108,6 +2108,21 @@ var fieldType = z.enum([
|
|
|
2108
2108
|
]);
|
|
2109
2109
|
var slug = z.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only");
|
|
2110
2110
|
var fieldKey = z.string().regex(/^[a-zA-Z0-9_]+$/, "letters, numbers, and underscores only");
|
|
2111
|
+
var uiObject = z.object({
|
|
2112
|
+
collapsed: z.boolean().optional().describe("start this panel collapsed. Use it for long optional sections."),
|
|
2113
|
+
preview: z.object({
|
|
2114
|
+
title: z.string().min(1).optional().describe("CHILD KEY whose value titles a collapsed row"),
|
|
2115
|
+
media: z.string().min(1).optional().describe("CHILD KEY whose value is the row's thumbnail (image/file/url all work)")
|
|
2116
|
+
}).optional().describe(
|
|
2117
|
+
"how ONE ITEM summarises itself when collapsed. Both slots name a CHILD KEY of THIS field/prop \u2014 not a value, not a dotted path. Without it a repeater of testimonials reads 'Item 1, Item 2, Item 3'; with {title:'author', media:'avatar'} it reads the names with their faces."
|
|
2118
|
+
),
|
|
2119
|
+
layout: z.enum(["list", "grid"]).optional().describe(
|
|
2120
|
+
"how the ITEMS are arranged. Omit (\u2261 'list') for rows of text; 'grid' for items whose thumbnail is the thing you scan \u2014 a gallery, a logo wall, a team. Same placement rule as `preview`."
|
|
2121
|
+
),
|
|
2122
|
+
reorderable: z.boolean().optional().describe(
|
|
2123
|
+
"omit (\u2261 true) unless the order is PART OF THE MEANING. `false` LOCKS the list \u2014 a 'three steps' band that must stay three steps in that order, a nav whose order is semantic, a timeline. Valid only where there is a list to lock."
|
|
2124
|
+
)
|
|
2125
|
+
});
|
|
2111
2126
|
var fieldShape = {
|
|
2112
2127
|
key: fieldKey.describe(
|
|
2113
2128
|
"machine field key. \u{1F534} UNIQUE ACROSS THE WHOLE MODEL \u2014 API IDs share ONE FLAT NAMESPACE, so a field nested inside a group must NOT reuse a key used by another field or group. Every section's heading cannot be 'title'. Prefix with the section: 'hero_title', 'pricing_title', 'faq_title'. Reusing a key is refused, and if it slips through it leaves permanent errors in the editor and breaks conditional-visibility rules, which reference fields by bare key."
|
|
@@ -2118,15 +2133,65 @@ var fieldShape = {
|
|
|
2118
2133
|
richText: z.boolean().optional().describe(
|
|
2119
2134
|
"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."
|
|
2120
2135
|
),
|
|
2121
|
-
options: z.array(z.string()).optional().describe(
|
|
2136
|
+
options: z.array(z.union([z.string(), z.object({ label: z.string(), value: z.string() })])).optional().describe(
|
|
2137
|
+
"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'})."
|
|
2138
|
+
),
|
|
2122
2139
|
config: z.record(z.string(), z.unknown()).optional().describe(
|
|
2123
2140
|
"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"
|
|
2124
2141
|
),
|
|
2125
2142
|
fields: z.array(z.lazy(() => fieldObject)).optional().describe(
|
|
2126
2143
|
"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."
|
|
2144
|
+
),
|
|
2145
|
+
// ── Authoring chrome + editor behaviour ────────────────────────────────────
|
|
2146
|
+
// These three already persist end-to-end and already have a panel in the dashboard's schema
|
|
2147
|
+
// builder. They were unreachable from MCP for ONE reason: the OutField/toField whitelist
|
|
2148
|
+
// below did not model them, so they were stripped after the model had gone to the trouble of
|
|
2149
|
+
// sending them. Declaring them here is what makes the existing panel agent-writable.
|
|
2150
|
+
helpText: z.string().max(500).optional().describe(
|
|
2151
|
+
"one line of guidance shown UNDER this field in the editor \u2014 what to write here, not what the field is. Write it for the person typing ('Roughly 60 characters; it becomes the browser tab title'), not for a developer."
|
|
2152
|
+
),
|
|
2153
|
+
showIf: z.object({
|
|
2154
|
+
match: z.enum(["all", "any"]),
|
|
2155
|
+
rules: z.array(
|
|
2156
|
+
z.object({
|
|
2157
|
+
field: z.string().describe("the BARE KEY of another field on this same model"),
|
|
2158
|
+
op: z.enum(["eq", "neq", "in"]),
|
|
2159
|
+
value: z.union([z.string(), z.array(z.string())])
|
|
2160
|
+
})
|
|
2161
|
+
)
|
|
2162
|
+
}).optional().describe(
|
|
2163
|
+
"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."
|
|
2164
|
+
),
|
|
2165
|
+
ui: uiObject.optional().describe(
|
|
2166
|
+
"AUTHORING CHROME \u2014 how this field presents in the editor. Never a delivery effect. `preview` and `layout` are valid ONLY on a nesting field ('group', 'repeater', an 'array' with config.zones, or 'modular'); `reorderable` only where there is a LIST to lock ('repeater', an 'array' with config.zones.repeatable, or 'modular' \u2014 a 'group' holds one item). preview keys must name real children, or the write is refused with the valid keys listed. An unknown key inside `ui` is a 400, not a silent strip. Read it back with get_content_model / get_page to confirm it landed. There is deliberately no `order`: field order is the array index \u2014 use add_field's `after` to insert."
|
|
2167
|
+
),
|
|
2168
|
+
// ── G5: the four keys the backend field schema accepts that this whitelist stripped. ──
|
|
2169
|
+
fieldsetId: z.string().min(1).optional().describe(
|
|
2170
|
+
"id of one of this model's `fieldsets` (create_content_model / update_content_model). The field is shown inside that card in the editor. Editor-only: never changes what is stored or delivered."
|
|
2171
|
+
),
|
|
2172
|
+
placement: z.enum(["cover", "title", "excerpt", "body", "inline", "panel"]).optional().describe(
|
|
2173
|
+
"where the control sits in the entry editor: 'title' the entry's heading, 'cover' its lead image, 'excerpt' the summary, 'body' the document body, 'inline' or 'panel'. Omit to let the editor decide. Editor-only."
|
|
2174
|
+
),
|
|
2175
|
+
defaultValue: z.unknown().optional().describe("the value a new entry starts with"),
|
|
2176
|
+
searchable: z.boolean().optional().describe("omit (\u2261 true) to index this field for site search; false excludes it"),
|
|
2177
|
+
unique: z.boolean().optional().describe(
|
|
2178
|
+
"true: no two entries of this model may hold the same value (compared trimmed and case-insensitively). On a repeater child such as a variant SKU it also covers every row of every entry. Only on text, longtext, slug, email, phone, link and number. A clash is refused with 409 `unique_conflict`."
|
|
2127
2179
|
)
|
|
2128
2180
|
};
|
|
2129
2181
|
var fieldObject = z.object(fieldShape);
|
|
2182
|
+
var urlPatternArg = z.string().nullable().optional().describe(
|
|
2183
|
+
"the public URL of one entry, e.g. '/blog/:slug'. Must start with '/' and contain ':slug' exactly once ('{slug}' is refused). Drives RSS item links and the automatic 301 when an entry's slug changes. null clears it."
|
|
2184
|
+
);
|
|
2185
|
+
var fieldsetsArg = z.array(
|
|
2186
|
+
z.object({
|
|
2187
|
+
id: z.string().min(1),
|
|
2188
|
+
name: z.string().min(1).max(60),
|
|
2189
|
+
description: z.string().max(500).optional()
|
|
2190
|
+
})
|
|
2191
|
+
).max(30).optional().describe(
|
|
2192
|
+
"the model's editor cards: [{id, name, description?}], at most 30, ids unique, order = array index. `description` is one line under the card title. A field joins one with `fieldsetId`. Editor-only: never changes what is stored or delivered."
|
|
2193
|
+
);
|
|
2194
|
+
var RICH_TEXT_NOTE = "\u{1F534} A field of type 'text' is created as RICH TEXT unless you send `richText: false` on it. Send `richText: false` for every value that is not prose: a name used as a title, a URL, slug, id, SKU, email, phone, CSS class or icon name.";
|
|
2130
2195
|
function flatFieldKeys(fields) {
|
|
2131
2196
|
const out = [];
|
|
2132
2197
|
const childrenOf = (f) => {
|
|
@@ -2164,7 +2229,17 @@ function toField(f) {
|
|
|
2164
2229
|
const base = {
|
|
2165
2230
|
key: f.key,
|
|
2166
2231
|
label: f.label,
|
|
2167
|
-
...f.required !== void 0 ? { required: f.required } : {}
|
|
2232
|
+
...f.required !== void 0 ? { required: f.required } : {},
|
|
2233
|
+
...f.helpText !== void 0 ? { helpText: f.helpText } : {},
|
|
2234
|
+
...f.showIf !== void 0 ? { showIf: f.showIf } : {},
|
|
2235
|
+
...f.ui !== void 0 ? { ui: f.ui } : {},
|
|
2236
|
+
// G5: these four persist on the backend and were stripped here, like helpText once was.
|
|
2237
|
+
...f.fieldsetId !== void 0 ? { fieldsetId: f.fieldsetId } : {},
|
|
2238
|
+
...f.placement !== void 0 ? { placement: f.placement } : {},
|
|
2239
|
+
...f.defaultValue !== void 0 ? { defaultValue: f.defaultValue } : {},
|
|
2240
|
+
...f.searchable !== void 0 ? { searchable: f.searchable } : {},
|
|
2241
|
+
// BE-6: a leaf key like the four above, so it rides on `base` and survives every branch.
|
|
2242
|
+
...f.unique !== void 0 ? { unique: f.unique } : {}
|
|
2168
2243
|
};
|
|
2169
2244
|
if (f.type === "group") {
|
|
2170
2245
|
return { ...base, type: "array", config: { zones: { nonRepeatable: toFields(f.fields) } } };
|
|
@@ -2358,24 +2433,43 @@ function buildToolDefs(deps) {
|
|
|
2358
2433
|
text: z.string().min(1).describe("the content to derive SEO metadata from"),
|
|
2359
2434
|
context: z.string().optional().describe("optional surrounding context, e.g. the page slug")
|
|
2360
2435
|
});
|
|
2436
|
+
const connectNativeAiInput = z.object({
|
|
2437
|
+
capabilities: z.array(z.string().min(1)).min(1).optional().describe(
|
|
2438
|
+
"optional \u2014 any of inspect, propose, implement, cancel, question. Defaults to inspect+propose+cancel."
|
|
2439
|
+
)
|
|
2440
|
+
});
|
|
2441
|
+
const nativeBridgeHeartbeatInput = z.object({
|
|
2442
|
+
protocolVersion: z.string().min(1).describe("executor protocol version; currently '1'"),
|
|
2443
|
+
capabilities: z.array(z.string().min(1)).min(1).describe(
|
|
2444
|
+
"what this executor can do: any of inspect, propose, implement, cancel, question. Must include inspect, propose and cancel. An unknown value is refused, never ignored."
|
|
2445
|
+
),
|
|
2446
|
+
session: z.object({ sessionId: z.string(), sessionEpoch: z.number().int().positive() }).optional().describe("the `session` from the previous response; omit on first contact to reconnect")
|
|
2447
|
+
});
|
|
2361
2448
|
const createModelInput = z.object({
|
|
2362
2449
|
name: z.string().min(1).describe("human model name, e.g. 'Blog Post'"),
|
|
2363
2450
|
slug: slug.describe("url-safe unique slug, e.g. 'blog-post'"),
|
|
2364
2451
|
description: z.string().optional(),
|
|
2452
|
+
urlPattern: urlPatternArg,
|
|
2365
2453
|
kind: z.enum(["model", "block"]).optional().describe(
|
|
2366
2454
|
"'model' (default) = a collection with its own entries. 'block' = a type that exists only to be stacked inside another model's 'modular' field \u2014 it holds no entries, and create_content_entry against it is refused. Create blocks FIRST, then the model whose modular field lists their slugs. Cannot be changed later."
|
|
2367
2455
|
),
|
|
2368
2456
|
fields: z.array(fieldObject).optional().describe(
|
|
2369
2457
|
"the model's typed schema fields. 'group'/'repeater' NEST their child fields (any depth) \u2014 don't flatten zones into top-level fields."
|
|
2370
|
-
)
|
|
2458
|
+
),
|
|
2459
|
+
fieldsets: fieldsetsArg
|
|
2371
2460
|
});
|
|
2461
|
+
const afterArg = z.string().min(1).optional().describe(
|
|
2462
|
+
"OPTIONAL insertion point: the key of an existing TOP-LEVEL field to insert this one directly after. Omit to append at the end (the default, and the behaviour before this existed). Use it to put a field where an author expects it instead of at the bottom \u2014 e.g. after:'hero_title' for a subtitle. An unknown key is refused rather than silently appended."
|
|
2463
|
+
);
|
|
2372
2464
|
const addFieldInput = z.object({
|
|
2373
2465
|
modelId: z.string().min(1).describe("id of the content model to extend"),
|
|
2374
|
-
...fieldShape
|
|
2466
|
+
...fieldShape,
|
|
2467
|
+
after: afterArg
|
|
2375
2468
|
});
|
|
2376
2469
|
const addPageFieldInput = z.object({
|
|
2377
2470
|
pageId: z.string().min(1).describe("id of the page to extend (from list_pages / create_page)"),
|
|
2378
|
-
...fieldShape
|
|
2471
|
+
...fieldShape,
|
|
2472
|
+
after: afterArg
|
|
2379
2473
|
});
|
|
2380
2474
|
const uploadAssetInput = z.object({
|
|
2381
2475
|
localPath: z.string().min(1).optional().describe("absolute path to a local file (e.g. a repo image); provide this OR url"),
|
|
@@ -2383,6 +2477,7 @@ function buildToolDefs(deps) {
|
|
|
2383
2477
|
filename: z.string().optional().describe("override the stored filename"),
|
|
2384
2478
|
altText: z.string().optional().describe("accessibility alt text"),
|
|
2385
2479
|
caption: z.string().optional(),
|
|
2480
|
+
transcript: z.string().max(1e5).optional().describe("video/audio only: the spoken words (\u2264 100,000 chars), published wherever the file is used"),
|
|
2386
2481
|
folderId: z.string().optional().describe("target Media Library folder (defaults to project root)")
|
|
2387
2482
|
});
|
|
2388
2483
|
const createEntryInput = z.object({
|
|
@@ -2510,7 +2605,13 @@ function buildToolDefs(deps) {
|
|
|
2510
2605
|
// get_site_composition counts, never a refusal. Parity: componentPropDefSchema on the server.
|
|
2511
2606
|
required: z.boolean().optional().describe("mark the field required in the editor. An editor HINT: the API still accepts a placement that leaves it empty, and get_site_composition is what reports one."),
|
|
2512
2607
|
helpText: z.string().max(200).optional().describe("hint shown under the control, muted (\u2264 200 chars)"),
|
|
2513
|
-
placeholder: z.string().max(120).optional().describe("ghost text inside a text-ish control (\u2264 120 chars)")
|
|
2608
|
+
placeholder: z.string().max(120).optional().describe("ghost text inside a text-ish control (\u2264 120 chars)"),
|
|
2609
|
+
// ⚠ A STRIP SITE, exactly like OutField above: this is a `z.object`, so a key the model
|
|
2610
|
+
// sends and this shape does not declare is dropped BEFORE the request leaves — a 200 with
|
|
2611
|
+
// the declaration gone. `ui` rode through the backend's looseObject `config` and died here.
|
|
2612
|
+
ui: uiObject.optional().describe(
|
|
2613
|
+
"AUTHORING CHROME for this prop's control in the inspector \u2014 never a delivery effect, and the same object a FIELD's `ui` takes. `preview` and `layout` are valid on 'group', 'table' and 'slot' only; `reorderable` on 'table' ALONE, the one prop type whose value is a LIST (a 'group' is one object, a 'slot' one component instance). preview keys name a SUB-FIELD of this prop's config.fields and are checked against them (on a 'slot' the shape is checked but the keys cannot be \u2014 they belong to whichever component fills it). An unknown key inside `ui` is a 400, not a silent strip. Read it back with get_component: a prop whose `ui` is absent there was not stored."
|
|
2614
|
+
)
|
|
2514
2615
|
});
|
|
2515
2616
|
const getLayoutInput = z.object({
|
|
2516
2617
|
scope: z.enum(["global", "page"]).default("global"),
|
|
@@ -2585,7 +2686,9 @@ function buildToolDefs(deps) {
|
|
|
2585
2686
|
allowedOn: allowedOn.optional(),
|
|
2586
2687
|
description: z.string().optional(),
|
|
2587
2688
|
blockJson: z.array(blockObject).default([]).describe("the component's block tree"),
|
|
2588
|
-
props: z.array(componentPropObject).default([]).describe(
|
|
2689
|
+
props: z.array(componentPropObject).default([]).describe(
|
|
2690
|
+
"overridable fields. Each one may carry `ui` to say how its control presents in the inspector \u2014 a collapsed group, what titles and thumbnails a table's rows, whether that table's rows can be dragged at all."
|
|
2691
|
+
)
|
|
2589
2692
|
});
|
|
2590
2693
|
const updateComponentInput = z.object({
|
|
2591
2694
|
componentId: z.string().min(1).describe("component id (from list_components)"),
|
|
@@ -2598,7 +2701,21 @@ function buildToolDefs(deps) {
|
|
|
2598
2701
|
allowedOn: allowedOn.optional().describe("REPLACES the placement allowlist"),
|
|
2599
2702
|
description: z.string().optional(),
|
|
2600
2703
|
blockJson: z.array(blockObject).optional().describe("REPLACES the block tree"),
|
|
2601
|
-
props: z.array(componentPropObject).optional()
|
|
2704
|
+
props: z.array(componentPropObject).optional().describe(
|
|
2705
|
+
"REPLACES the prop array \u2014 include every prop you want to keep, WITH its stored `ui`, or that prop's authoring chrome is dropped along with the prop. Read get_component first."
|
|
2706
|
+
)
|
|
2707
|
+
});
|
|
2708
|
+
const setComponentSourceInput = z.object({
|
|
2709
|
+
componentId: z.string().min(1).describe("component id (from list_components)"),
|
|
2710
|
+
path: z.string().min(1).max(512).describe("file path relative to the app root, e.g. src/components/sections/Hero.astro"),
|
|
2711
|
+
export: z.string().min(1).max(128).optional().describe("named export that renders it; omit for a default export"),
|
|
2712
|
+
kind: z.enum(["file", "section"]).optional().describe("'file' (props are this component's fields; default) or 'section' (a codemod-extracted section: props { blockId, bind, overrides, page })")
|
|
2713
|
+
});
|
|
2714
|
+
const submitComponentizeReceiptInput = z.object({
|
|
2715
|
+
receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --componentize --receipt <file>` wrote, verbatim.")
|
|
2716
|
+
});
|
|
2717
|
+
const clearComponentSourceInput = z.object({
|
|
2718
|
+
componentId: z.string().min(1).describe("component id (from list_components)")
|
|
2602
2719
|
});
|
|
2603
2720
|
const getComponentInput = z.object({
|
|
2604
2721
|
componentId: z.string().min(1).describe("component id (from list_components)")
|
|
@@ -2751,9 +2868,22 @@ function buildToolDefs(deps) {
|
|
|
2751
2868
|
filename: z.string().min(1).describe("file name incl. extension"),
|
|
2752
2869
|
altText: z.string().optional(),
|
|
2753
2870
|
caption: z.string().optional(),
|
|
2871
|
+
transcript: z.string().max(1e5).optional().describe("video/audio only: the spoken words (\u2264 100,000 chars)"),
|
|
2754
2872
|
folderId: z.string().optional()
|
|
2755
2873
|
}).shape,
|
|
2756
|
-
async (c, a) => ok("Media asset.", await data(c, "POST", `/management/media/from-upload`, { assetId: a.assetId, uploadKey: a.uploadKey, filename: a.filename, altText: a.altText, caption: a.caption, folderId: a.folderId }))
|
|
2874
|
+
async (c, a) => ok("Media asset.", await data(c, "POST", `/management/media/from-upload`, { assetId: a.assetId, uploadKey: a.uploadKey, filename: a.filename, altText: a.altText, caption: a.caption, transcript: a.transcript, folderId: a.folderId }))
|
|
2875
|
+
),
|
|
2876
|
+
def(
|
|
2877
|
+
"update_media",
|
|
2878
|
+
"Update a media asset",
|
|
2879
|
+
"Describe an existing media asset: set its alt text, caption or transcript. A transcript (video/audio only, \u2264 100,000 chars) is stored ON the asset, so every page and entry using the file publishes it \u2014 hosted pages, the .md twin, llms-full.txt and the delivery API. Pass null to clear a field. Use this after your own speech-to-text; BetterCMS does not generate transcripts.",
|
|
2880
|
+
z.object({
|
|
2881
|
+
assetId: z.string().min(1).describe("media asset id (from list_media / upload_asset)"),
|
|
2882
|
+
altText: z.string().max(500).nullable().optional().describe("alt text; null clears"),
|
|
2883
|
+
caption: z.string().max(1e3).nullable().optional().describe("caption; null clears"),
|
|
2884
|
+
transcript: z.string().max(1e5).nullable().optional().describe("the spoken words; null clears. Video/audio only.")
|
|
2885
|
+
}).shape,
|
|
2886
|
+
async (c, a) => ok("Updated media asset.", await data(c, "PATCH", `/management/media/${encodeURIComponent(s(a.assetId))}`, { altText: a.altText, caption: a.caption, transcript: a.transcript }))
|
|
2757
2887
|
),
|
|
2758
2888
|
def(
|
|
2759
2889
|
"list_media",
|
|
@@ -3016,16 +3146,17 @@ function buildToolDefs(deps) {
|
|
|
3016
3146
|
def(
|
|
3017
3147
|
"get_content_model",
|
|
3018
3148
|
"Get a content model",
|
|
3019
|
-
"Get one content model by id INCLUDING its full field schema (keys, types, nested group/repeater children)
|
|
3149
|
+
"Get one content model by id INCLUDING its full field schema (keys, types, nested group/repeater children) AND each field's stored authoring props \u2014 `helpText`, `showIf`, `ui`. Read it before add_field so you know the existing keys, and AFTER declaring `ui`/`showIf`/`helpText` as the receipt that the declaration actually landed: a prop that is missing from this response was not stored, whatever the write returned.",
|
|
3020
3150
|
z.object({ modelId: z.string().min(1) }).shape,
|
|
3021
3151
|
async (c, a) => ok("Content model.", await data(c, "GET", `/management/content/models/${s(a.modelId)}`))
|
|
3022
3152
|
),
|
|
3023
3153
|
def(
|
|
3024
3154
|
"update_content_model",
|
|
3025
3155
|
"Update a content model's metadata",
|
|
3026
|
-
"Rename a content model
|
|
3027
|
-
z.object({ modelId: z.string().min(1), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional() }).shape,
|
|
3028
|
-
|
|
3156
|
+
"Rename a content model, edit its description/slug/urlPattern, or set its `fieldsets`. Never touches `fields`: use add_field to extend the schema. Provide modelId plus what to change. `fieldsets` REPLACES the list (send null to drop them all); then put a field in one with add_field's `fieldsetId`. `urlPattern` is the collection's entry URL, e.g. '/blog/:slug' (null clears it). A slug already used in this project and branch is refused with 409 `slug_taken`.",
|
|
3157
|
+
z.object({ modelId: z.string().min(1), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), urlPattern: urlPatternArg, fieldsets: fieldsetsArg.nullable() }).shape,
|
|
3158
|
+
// `fields` is deliberately not sent: the PATCH replaces `fields` wholesale when present.
|
|
3159
|
+
async (c, a) => ok("Updated content model.", await data(c, "PATCH", `/management/content/models/${s(a.modelId)}`, { name: a.name, slug: a.slug, description: a.description, urlPattern: a.urlPattern, fieldsets: a.fieldsets }))
|
|
3029
3160
|
),
|
|
3030
3161
|
def(
|
|
3031
3162
|
"get_content_types",
|
|
@@ -3098,7 +3229,7 @@ function buildToolDefs(deps) {
|
|
|
3098
3229
|
def(
|
|
3099
3230
|
"get_binding_report",
|
|
3100
3231
|
"Check what on the live site is editable",
|
|
3101
|
-
"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), `pagesInspected`, `bound` (elements carrying a binding), and `unmatched` \u2014 per page, each path with its kind and the reason it failed (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report and this answers pages 0, mode null, refreshRequired true. It certifies exactly one thing \u2014 that every non-empty field of every page 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 (null when unknown: runtime release, >500 routes, or a report older than this field). `canvas.lane` names the live-preview lane this build gives the editor \u2014 `bridge` (the build ships BcmsDraftBridge), `draft-route` (a node runtime rendering drafts server-side) or `none`, in which case get_next_steps carries the recipe (playbook section 11); null means the report predates the field. `unaddressable` counts visible text on the live site that no field owns
|
|
3232
|
+
"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), `pagesInspected`, `bound` (elements carrying a binding), and `unmatched` \u2014 per page, each path with its kind and the reason it failed (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report and this answers pages 0, mode null, refreshRequired true. It certifies exactly one thing \u2014 that every non-empty field of every page 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 (null when unknown: runtime release, >500 routes, or a report older than this field). `canvas.lane` names the live-preview lane this build gives the editor \u2014 `bridge` (the build ships BcmsDraftBridge), `draft-route` (a node runtime rendering drafts server-side) or `none`, in which case get_next_steps carries the recipe (playbook section 11); null means the report predates the field. `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: `{count, pages, measuredAt, routes:[{path, count, visible, buckets:[{tag, context, chars, nodes, samples}]}]}`, where `path` is the route (the empty string is the home page), `visible` its countable characters (ornaments, skip links and the platform badge excluded from both sides) and `buckets` where the unowned text is, 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. An author turning the editor's Unbound text control on posts a bare count of text NODES for the route they opened (no `visible`, no buckets); those rows are reported only for a build the release never measured, never summed with the release's characters. `skipReasons` says why pages were not inspected (`no-html`, `route-cap`, `authored-content` = the singleton lane declined the project, so NO route of it has a page), and `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. `unaddressable` is measured PER SLOT; on a promote-gated project pass slot:'current' to read the tree the editor frames. Pass `slot` ('current' or 'staging') to read the other tree; the default is the slot this project's releases land in.",
|
|
3102
3233
|
z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read; defaults to the one this project deploys to") }).shape,
|
|
3103
3234
|
async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
|
|
3104
3235
|
),
|
|
@@ -3379,6 +3510,8 @@ function buildToolDefs(deps) {
|
|
|
3379
3510
|
slug: args.slug,
|
|
3380
3511
|
pageType: args.pageType ?? "singleton",
|
|
3381
3512
|
...args.blockJson ? { blockJson: args.blockJson } : {},
|
|
3513
|
+
// Cast: the SDK's ContentModelField types `options` as string[], narrower than the API,
|
|
3514
|
+
// which also takes {label, value} (backend selectFieldSchema).
|
|
3382
3515
|
...args.fields ? { fields: args.fields.map(toField) } : {},
|
|
3383
3516
|
...args.metaTitle !== void 0 ? { metaTitle: args.metaTitle } : {},
|
|
3384
3517
|
...args.metaDescription !== void 0 ? { metaDescription: args.metaDescription } : {}
|
|
@@ -3394,7 +3527,7 @@ function buildToolDefs(deps) {
|
|
|
3394
3527
|
name: "create_content_model",
|
|
3395
3528
|
config: {
|
|
3396
3529
|
title: "Create a content model (reusable schema)",
|
|
3397
|
-
description: "Create a content model \u2014 a reusable schema for a dynamic collection (Blog, Products, Testimonials). `fields` may NEST: type 'group' = one nested object of child fields; type 'repeater' = a repeatable array of child objects. Put child fields in each group/repeater's own `fields` (any depth). Pass kind:'block' to create a BLOCK type instead \u2014 see that argument.",
|
|
3530
|
+
description: "Create a content model \u2014 a reusable schema for a dynamic collection (Blog, Products, Testimonials). `fields` may NEST: type 'group' = one nested object of child fields; type 'repeater' = a repeatable array of child objects. Put child fields in each group/repeater's own `fields` (any depth). Pass kind:'block' to create a BLOCK type instead \u2014 see that argument. `fieldsets` ([{id, name}]) are the model's editor cards; a field joins one with `fieldsetId`. The slug must be unique in this project and branch: a taken one is refused with 409 `slug_taken` and free `suggestions`. " + RICH_TEXT_NOTE,
|
|
3398
3531
|
inputSchema: createModelInput.shape
|
|
3399
3532
|
},
|
|
3400
3533
|
handler: guard(
|
|
@@ -3405,7 +3538,9 @@ function buildToolDefs(deps) {
|
|
|
3405
3538
|
name: args.name,
|
|
3406
3539
|
slug: args.slug,
|
|
3407
3540
|
...args.description !== void 0 ? { description: args.description } : {},
|
|
3541
|
+
...args.urlPattern !== void 0 ? { urlPattern: args.urlPattern } : {},
|
|
3408
3542
|
...args.kind !== void 0 ? { kind: args.kind } : {},
|
|
3543
|
+
...args.fieldsets !== void 0 ? { fieldsets: args.fieldsets } : {},
|
|
3409
3544
|
fields: toFields(args.fields)
|
|
3410
3545
|
});
|
|
3411
3546
|
return ok(
|
|
@@ -3419,7 +3554,7 @@ function buildToolDefs(deps) {
|
|
|
3419
3554
|
name: "add_field",
|
|
3420
3555
|
config: {
|
|
3421
3556
|
title: "Add a field to a content model",
|
|
3422
|
-
description: "Append a field to an existing content model. Reads the model's current fields and adds yours (read-modify-write) \u2014 never removes existing fields. For a section/zone, add ONE 'group' (fixed block) or 'repeater' (repeating list) field carrying its child `fields` \u2014 don't add the zone's inner fields as separate top-level fields.",
|
|
3557
|
+
description: "Append a field to an existing content model. Reads the model's current fields and adds yours (read-modify-write) \u2014 never removes existing fields. For a section/zone, add ONE 'group' (fixed block) or 'repeater' (repeating list) field carrying its child `fields` \u2014 don't add the zone's inner fields as separate top-level fields. `fieldsetId` puts it in one of the model's fieldsets (set them with update_content_model). " + RICH_TEXT_NOTE,
|
|
3423
3558
|
inputSchema: addFieldInput.shape
|
|
3424
3559
|
},
|
|
3425
3560
|
handler: guard(
|
|
@@ -3427,11 +3562,26 @@ function buildToolDefs(deps) {
|
|
|
3427
3562
|
const model = await client.getModel(args.modelId);
|
|
3428
3563
|
const dupes = duplicateKeys([...flatFieldKeys(model.fields), args.key]);
|
|
3429
3564
|
if (dupes.length > 0) return fail(duplicateKeyFailure(dupes));
|
|
3565
|
+
const existingFields = model.fields;
|
|
3566
|
+
let at = existingFields.length;
|
|
3567
|
+
if (args.after !== void 0) {
|
|
3568
|
+
const idx = existingFields.findIndex(
|
|
3569
|
+
(f) => f.key === args.after
|
|
3570
|
+
);
|
|
3571
|
+
if (idx === -1) {
|
|
3572
|
+
return fail(
|
|
3573
|
+
`after: '${args.after}' is not a top-level field on this model. Valid keys: ${existingFields.map((f) => `'${f.key}'`).join(", ")}. Omit \`after\` to append at the end.`
|
|
3574
|
+
);
|
|
3575
|
+
}
|
|
3576
|
+
at = idx + 1;
|
|
3577
|
+
}
|
|
3578
|
+
const nextFields = [...existingFields];
|
|
3579
|
+
nextFields.splice(at, 0, toField(args));
|
|
3430
3580
|
const updated = await client.updateModel(args.modelId, {
|
|
3431
|
-
fields:
|
|
3581
|
+
fields: nextFields
|
|
3432
3582
|
});
|
|
3433
3583
|
return ok(
|
|
3434
|
-
`Added field '${args.key}' to '${updated.name}'. Model now has ${updated.fields.length} field(s).`,
|
|
3584
|
+
`Added field '${args.key}' to '${updated.name}'${args.after ? ` after '${args.after}'` : ""}. Model now has ${updated.fields.length} field(s).`,
|
|
3435
3585
|
updated
|
|
3436
3586
|
);
|
|
3437
3587
|
})
|
|
@@ -3446,7 +3596,12 @@ function buildToolDefs(deps) {
|
|
|
3446
3596
|
},
|
|
3447
3597
|
handler: guard(
|
|
3448
3598
|
async (args) => withClient(async (client) => {
|
|
3449
|
-
const page = await client.addPageFields(args.pageId, {
|
|
3599
|
+
const page = await client.addPageFields(args.pageId, {
|
|
3600
|
+
// Cast: same as create_page. The SDK types `options` as string[], narrower than the
|
|
3601
|
+
// API, which also takes {label, value} (backend selectFieldSchema).
|
|
3602
|
+
addFields: [toField(args)],
|
|
3603
|
+
...args.after !== void 0 ? { after: args.after } : {}
|
|
3604
|
+
});
|
|
3450
3605
|
return ok(
|
|
3451
3606
|
`Added field '${args.key}' to page '${page.title}'. Page now has ${page.fields.length} field(s).`,
|
|
3452
3607
|
page
|
|
@@ -3764,7 +3919,7 @@ function buildToolDefs(deps) {
|
|
|
3764
3919
|
name: "get_component",
|
|
3765
3920
|
config: {
|
|
3766
3921
|
title: "Get a component (with its blockJson)",
|
|
3767
|
-
description: "Get one component by id INCLUDING its blockJson tree and props. Read this before update_component so you keep the existing blocks.",
|
|
3922
|
+
description: "Get one component by id INCLUDING its blockJson tree and props (with any stored `ui` authoring chrome on them). Read this before update_component so you keep the existing blocks, and after declaring field UI as the receipt that it landed \u2014 a prop absent here was not stored.",
|
|
3768
3923
|
inputSchema: getComponentInput.shape
|
|
3769
3924
|
},
|
|
3770
3925
|
handler: guard(
|
|
@@ -3967,6 +4122,61 @@ ${lines.join("\n")}`, found);
|
|
|
3967
4122
|
})
|
|
3968
4123
|
)
|
|
3969
4124
|
},
|
|
4125
|
+
{
|
|
4126
|
+
name: "set_component_source",
|
|
4127
|
+
config: {
|
|
4128
|
+
title: "Record which file implements a component",
|
|
4129
|
+
description: "Call this RIGHT AFTER you write or locate the code for a component in the app's repository. It tells BetterCMS which file renders the component, so the dashboard's Output button can build a live preview and validate it with no setup. Without it Output says no source is recorded and nothing is validated. Record ONLY a file that renders exactly this component from its own fields: a component whose props are this component's field keys (`kind: 'file'`, the default), or a section the codemod extracted with `npx @bettercms-ai/convert --componentize` \u2014 a file starting `// @bettercms-ai/convert section` whose props are `{ blockId, bind, overrides, page }` (`kind: 'section'`). Never record a different component that merely contains this markup (e.g. a card that needs a `post` object): it cannot render from these fields and validation fails. If the markup is inline in a page, extract it into a section first. `path` is relative to the app root (e.g. 'src/components/sections/Hero.astro'); `export` is the export name, omitted for a default export. Call it again if the file moves; call clear_component_source if a recorded file is wrong.",
|
|
4130
|
+
inputSchema: setComponentSourceInput.shape
|
|
4131
|
+
},
|
|
4132
|
+
handler: guard(
|
|
4133
|
+
async (args) => withClient(async (client) => {
|
|
4134
|
+
const res = await client.fetchJSON(
|
|
4135
|
+
client.url(`/management/components/${encodeURIComponent(args.componentId)}/preview-source`),
|
|
4136
|
+
{ method: "PUT", body: JSON.stringify({ path: args.path, ...args.export ? { export: args.export } : {}, ...args.kind ? { kind: args.kind } : {} }) }
|
|
4137
|
+
);
|
|
4138
|
+
return ok(`Recorded ${res.data.path}${res.data.export === "default" ? "" : ` (export ${res.data.export})`} as the source of component ${res.data.componentId}.`, res.data);
|
|
4139
|
+
})
|
|
4140
|
+
)
|
|
4141
|
+
},
|
|
4142
|
+
{
|
|
4143
|
+
name: "submit_componentize_receipt",
|
|
4144
|
+
config: {
|
|
4145
|
+
title: "Record the sources a componentize run wrote",
|
|
4146
|
+
description: "Record every section the componentize codemod extracted as its component's source. Call it right after `npx @bettercms-ai/convert --componentize --receipt componentize.json` (and after pushing the files): pass that receipt verbatim. Each extracted section is recorded as kind 'section' for the component with the same slug, so Output can build and validate it with no set_component_source per component. Answers which components were recorded, and any slug this project has no component for.",
|
|
4147
|
+
inputSchema: submitComponentizeReceiptInput.shape
|
|
4148
|
+
},
|
|
4149
|
+
handler: guard(
|
|
4150
|
+
async (args) => withClient(async (client) => {
|
|
4151
|
+
const res = await client.fetchJSON(
|
|
4152
|
+
client.url("/management/components/preview-sources/componentize-receipt"),
|
|
4153
|
+
{ method: "POST", body: JSON.stringify({ receipt: args.receipt }) }
|
|
4154
|
+
);
|
|
4155
|
+
const { recorded, unknown, invalid } = res.data;
|
|
4156
|
+
return ok(
|
|
4157
|
+
`Recorded ${recorded.length} section source${recorded.length === 1 ? "" : "s"}${unknown.length ? `; no component for ${unknown.join(", ")}` : ""}${invalid.length ? `; unimportable path for ${invalid.join(", ")}` : ""}.`,
|
|
4158
|
+
res.data
|
|
4159
|
+
);
|
|
4160
|
+
})
|
|
4161
|
+
)
|
|
4162
|
+
},
|
|
4163
|
+
{
|
|
4164
|
+
name: "clear_component_source",
|
|
4165
|
+
config: {
|
|
4166
|
+
title: "Remove a component's recorded source file",
|
|
4167
|
+
description: "Remove the file recorded as a component's source (by set_component_source or a conversion receipt). Use it when the recorded file is wrong \u2014 Output then shows no source until the right file is recorded. Safe to call when nothing is recorded.",
|
|
4168
|
+
inputSchema: clearComponentSourceInput.shape
|
|
4169
|
+
},
|
|
4170
|
+
handler: guard(
|
|
4171
|
+
async (args) => withClient(async (client) => {
|
|
4172
|
+
const res = await client.fetchJSON(
|
|
4173
|
+
client.url(`/management/components/${encodeURIComponent(args.componentId)}/preview-source`),
|
|
4174
|
+
{ method: "DELETE" }
|
|
4175
|
+
);
|
|
4176
|
+
return ok(res.data.cleared ? `Removed the recorded source of component ${res.data.componentId}.` : `Component ${res.data.componentId} had no recorded source.`, res.data);
|
|
4177
|
+
})
|
|
4178
|
+
)
|
|
4179
|
+
},
|
|
3970
4180
|
{
|
|
3971
4181
|
name: "publish_component",
|
|
3972
4182
|
config: {
|
|
@@ -4013,6 +4223,55 @@ ${lines.join("\n")}`, found);
|
|
|
4013
4223
|
})
|
|
4014
4224
|
)
|
|
4015
4225
|
},
|
|
4226
|
+
// ── Native bridge (executor presence) ──────────────────────────────────────
|
|
4227
|
+
{
|
|
4228
|
+
name: "connect_native_ai",
|
|
4229
|
+
config: {
|
|
4230
|
+
title: "Connect this client to the project's Native AI Connection",
|
|
4231
|
+
description: "Connect this client to the project's Native AI Connection, so the dashboard shows it as an available executor. Call it ONCE when the user asks to connect \u2014 after that, ordinary tool calls on the same project keep it listed, and nothing needs to run on a timer. Idempotent. Returns the project it connected to and `presenceWindowMs`: the executor stays listed for that long after the last tool call, then drops off until the next one. THIS GRANTS NOTHING \u2014 it is presence only, and starting a run is still a human act in the dashboard. A 403 BRIDGE_GRANT_REQUIRED means this client is using a bare API key rather than an approved connection; a 400 means the connection covers the whole workspace and you must pass `projectId`.",
|
|
4232
|
+
inputSchema: connectNativeAiInput.shape
|
|
4233
|
+
},
|
|
4234
|
+
handler: guard(
|
|
4235
|
+
async (args) => withClient(async (client) => {
|
|
4236
|
+
const result = await client.fetchJSON(client.url("/management/orchestration/bridge/connect"), {
|
|
4237
|
+
method: "POST",
|
|
4238
|
+
body: JSON.stringify({
|
|
4239
|
+
protocolVersion: "1",
|
|
4240
|
+
...args.capabilities ? { capabilities: args.capabilities } : {}
|
|
4241
|
+
})
|
|
4242
|
+
});
|
|
4243
|
+
const mins = result.presenceWindowMs ? Math.round(result.presenceWindowMs / 6e4) : null;
|
|
4244
|
+
return ok(
|
|
4245
|
+
`Connected to project ${result.projectId}. The dashboard now lists this client as an available executor${mins ? `; it stays listed for ${mins} minutes after the last tool call` : ""}.`,
|
|
4246
|
+
result
|
|
4247
|
+
);
|
|
4248
|
+
})
|
|
4249
|
+
)
|
|
4250
|
+
},
|
|
4251
|
+
{
|
|
4252
|
+
name: "native_bridge_heartbeat",
|
|
4253
|
+
config: {
|
|
4254
|
+
title: "Register as a native bridge and report liveness",
|
|
4255
|
+
description: "Register this client as a native-bridge executor for the connected project and report that it is alive. The dashboard's 'Native AI Connection' panel shows an executor as live ONLY while heartbeats keep arriving: call this again every `nextHeartbeatMs` (25s), because liveness lapses 90s after the last call and the panel then stops offering this executor. Idempotent \u2014 registration happens on the first call, so a restart or a reconnect is just another call. Echo back the `session` from the previous response so the same connection epoch is kept; omit it to reconnect, which fences the previous one. A 422 names a refused capability or protocol; it does not retry into success. THIS GRANTS NO REPOSITORY MUTATION \u2014 it is presence only, and starting a run still requires a human in the dashboard.",
|
|
4256
|
+
inputSchema: nativeBridgeHeartbeatInput.shape
|
|
4257
|
+
},
|
|
4258
|
+
handler: guard(
|
|
4259
|
+
async (args) => withClient(async (client) => {
|
|
4260
|
+
const result = await client.fetchJSON(client.url("/management/orchestration/bridge/heartbeat"), {
|
|
4261
|
+
method: "POST",
|
|
4262
|
+
body: JSON.stringify({
|
|
4263
|
+
protocolVersion: args.protocolVersion,
|
|
4264
|
+
capabilities: args.capabilities,
|
|
4265
|
+
...args.session ? { session: args.session } : {}
|
|
4266
|
+
})
|
|
4267
|
+
});
|
|
4268
|
+
return ok(
|
|
4269
|
+
`Bridge ${result.liveness}. Call again within ${Math.round(result.nextHeartbeatMs / 1e3)}s to stay live.`,
|
|
4270
|
+
result
|
|
4271
|
+
);
|
|
4272
|
+
})
|
|
4273
|
+
)
|
|
4274
|
+
},
|
|
4016
4275
|
// ── Lifecycle tools (parity with the remote /mcp surface) ──────────────────
|
|
4017
4276
|
// Media management, form submissions (leads), redirects, SEO, AEO site-files,
|
|
4018
4277
|
// promote, and entry version history. These call the management endpoints straight
|
|
@@ -4324,10 +4583,28 @@ lane, so every structural draft falls back to the platform renderer and its gene
|
|
|
4324
4583
|
\`null\` means the report predates the field; redeploy to learn. When it is \`none\`,
|
|
4325
4584
|
\`get_next_steps\` names the recipe for this project's framework (\`canvas-lane-missing\`), and
|
|
4326
4585
|
\xA713 step 5c has both. The same report carries \`unaddressable\`: how much VISIBLE text on the
|
|
4327
|
-
live site no field owns
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4586
|
+
live site no field owns \u2014 \`unmatched\` cannot see it, because it only ever speaks about fields
|
|
4587
|
+
that already exist. EVERY release measures it server-side, per route, with a denominator and the
|
|
4588
|
+
places it is (\`unaddressable.routes[]\`: \`path\`, \`count\`, \`visible\`, \`buckets[]\`). An author
|
|
4589
|
+
turning the editor's **"Unbound text"** control on posts a bare count of text NODES for the route
|
|
4590
|
+
they opened; it is reported only for a build the release never measured, and never added to
|
|
4591
|
+
the release's characters. Ornaments, skip links and the platform badge are excluded from both sides.
|
|
4592
|
+
|
|
4593
|
+
**THE LOOP, and it is not optional \u2014 a deploy is not a conversion.**
|
|
4594
|
+
|
|
4595
|
+
1. deploy, then \`get_binding_report\`;
|
|
4596
|
+
2. for each \`unaddressable.routes[]\` where \`count / visible > 0.02\`:
|
|
4597
|
+
read its \`buckets\` \u2014 each one is a TAG in a LANDMARK with a sample of the copy;
|
|
4598
|
+
3. fix it in the SOURCE: wrap that run of text in an element the codemod can bind (a
|
|
4599
|
+
\`<span>\`, a \`<p>\`, a \`<div>\` \u2014 never a bare text node between tags), or declare it
|
|
4600
|
+
yourself with \`<BcmsField path="\u2026">\`;
|
|
4601
|
+
4. deploy again and re-read. Repeat until every route is under 2%.
|
|
4602
|
+
|
|
4603
|
+
\`get_next_steps\` runs the same test and hands you the worst route with its buckets
|
|
4604
|
+
(\`unaddressable-copy\`). A \`coverage\` carrying \`error: "nothing-bound"\` means NOT ONE element
|
|
4605
|
+
of the build is bound \u2014 usually the singleton lane declined the project (\`skipReasons\`
|
|
4606
|
+
\`authored-content\`: its content models were authored by the repo), so the percentage beside it
|
|
4607
|
+
describes a site this build is not.
|
|
4331
4608
|
|
|
4332
4609
|
**A site built in the CMS with NO deployed build has only that renderer** \u2014 live and in the
|
|
4333
4610
|
editor \u2014 so the generic theme IS the site until you theme it. Theme it without a deploy:
|
|
@@ -4468,6 +4745,19 @@ cluster \u2192 \`group\`. Nesting is capped at TWO levels from the prop (a \`tab
|
|
|
4468
4745
|
utilities, aria attributes and alt text all survive. Never restyle while componentizing. Run
|
|
4469
4746
|
\`componentize_sections { dryRun: true }\` and the codemod's \`--dry-run\` first.
|
|
4470
4747
|
|
|
4748
|
+
**5b. Every component's Output must render \u2014 record its SOURCE.** The dashboard's Output builds and
|
|
4749
|
+
validates a component from the file in the repository that renders it, and only a file whose props
|
|
4750
|
+
match renders it. A section the codemod extracted is such a file: after \`npx @bettercms-ai/convert
|
|
4751
|
+
--componentize --receipt componentize.json\`, push the files and call \`submit_componentize_receipt
|
|
4752
|
+
{ receipt }\` \u2014 every extracted section is recorded for the component with the same slug. A section
|
|
4753
|
+
you write BY HAND follows the same contract: props exactly \`{ blockId, bind, overrides, page }\`, the
|
|
4754
|
+
root element carries \`data-bcms-block={blockId}\`, and copy is read through \`bcmsSection(page, bind,
|
|
4755
|
+
overrides)\` (or \`bcmsSectionPaths\` for a flat field family like \`wrap-h2-latest\`); then call
|
|
4756
|
+
\`set_component_source { kind: 'section' }\`. A component module whose props ARE its fields is
|
|
4757
|
+
\`set_component_source\` with the default kind. NEVER record a different component that merely
|
|
4758
|
+
contains the markup (a card needing a \`post\` object): it cannot render from these fields and its
|
|
4759
|
+
validation fails every time \u2014 \`clear_component_source\` removes a wrong one.
|
|
4760
|
+
|
|
4471
4761
|
**6. The final validation is \`get_site_composition\`.** It is the receipt for "is every page
|
|
4472
4762
|
assembled from registered components?": per page its blocks and every component placement with
|
|
4473
4763
|
that component's status, plus site totals \u2014 unpublished, awaiting evidence, awaiting approval,
|
|
@@ -4482,6 +4772,8 @@ bulk approve, and no tool here can grant that approval. Finish with \`get_next_s
|
|
|
4482
4772
|
1. get_site_composition the before picture
|
|
4483
4773
|
2. get_componentize_plan DERIVED site only \u2014 confirm it with the user
|
|
4484
4774
|
3. componentize_sections dryRun first, then for real
|
|
4775
|
+
3b. npx @bettercms-ai/convert --componentize --receipt componentize.json, push,
|
|
4776
|
+
then submit_componentize_receipt { receipt } every section's Output source, ONE call
|
|
4485
4777
|
4. create_components everything the plan did not cover, ONE call, each with a group
|
|
4486
4778
|
5. compose_pages every page's blockJson, ONE call
|
|
4487
4779
|
6. update_layout { commands } nav/footer chrome + add-component, ONE call
|
|
@@ -4694,6 +4986,123 @@ conversion is yours to write.
|
|
|
4694
4986
|
**Done means.** Whichever recipe you ran: \`get_binding_report\` reads \`mode "declared"\`,
|
|
4695
4987
|
\`unmatched\` empty, \`bound\` above 0, \`coverage.pending\` empty, and \`canvas.lane\` is \`bridge\` or
|
|
4696
4988
|
\`draft-route\` when the site should show structural drafts.
|
|
4989
|
+
|
|
4990
|
+
## 14. Declare how a field LOOKS in the editor \u2014 \`ui\`, \`helpText\`, \`showIf\`
|
|
4991
|
+
|
|
4992
|
+
You can create the schema. You can also say how it PRESENTS to the person filling it in. These
|
|
4993
|
+
three props ride on any field, on any field-creating tool (\`create_content_model\`,
|
|
4994
|
+
\`create_page\`, \`add_field\`, \`add_page_field\`), and none of them can move, reshape or hide a
|
|
4995
|
+
delivered value \u2014 they change editor chrome only.
|
|
4996
|
+
|
|
4997
|
+
\`\`\`jsonc
|
|
4998
|
+
{ "key": "testimonials", "label": "Testimonials", "type": "repeater",
|
|
4999
|
+
"ui": { "collapsed": true, "preview": { "title": "author", "media": "avatar" },
|
|
5000
|
+
"layout": "grid", "reorderable": true },
|
|
5001
|
+
"helpText": "Three to five. Shortest quotes read best.",
|
|
5002
|
+
"fields": [ { "key": "author", ... }, { "key": "avatar", "type": "image", ... }, ... ] }
|
|
5003
|
+
\`\`\`
|
|
5004
|
+
|
|
5005
|
+
- **\`helpText\`** \u2014 one line UNDER the field telling the author what to write here, not what the
|
|
5006
|
+
field is. "Roughly 60 characters; it becomes the browser tab title" earns its place.
|
|
5007
|
+
"The title field" does not.
|
|
5008
|
+
- **\`showIf\`** \u2014 hide a field until another has a value:
|
|
5009
|
+
\`{match:'all',rules:[{field:'link_type',op:'eq',value:'external'}]}\`. Rules name fields by
|
|
5010
|
+
BARE KEY, and the key namespace is FLAT across the model (\xA7 the duplicate-key rule), so the
|
|
5011
|
+
key you reference must exist.
|
|
5012
|
+
- **\`ui.collapsed\`** \u2014 start a nesting field's panel closed. For long optional sections.
|
|
5013
|
+
- **\`ui.preview\`** \u2014 how ONE ITEM of a nesting field summarises itself when collapsed. Both
|
|
5014
|
+
slots name a **CHILD FIELD KEY of that field** \u2014 not a value, not a dotted path.
|
|
5015
|
+
Without it a repeater of ten testimonials reads "Item 1 \u2026 Item 10" and the author has to open
|
|
5016
|
+
each one to find the one they meant. \`media\` may point at an \`image\`, \`file\` or url-ish
|
|
5017
|
+
\`text\` child; the type is not constrained.
|
|
5018
|
+
- **\`ui.layout\`** \u2014 \`'list'\` (the default; omit it) or \`'grid'\`. Grid when the THUMBNAIL is
|
|
5019
|
+
what the author scans: a gallery, a logo wall, a team. List when the words are.
|
|
5020
|
+
- **\`ui.reorderable\`** \u2014 omit it (\u2261 \`true\`) unless the ORDER IS PART OF THE MEANING. \`false\`
|
|
5021
|
+
LOCKS the list so its items cannot be dragged: a "three steps" band that must stay three steps
|
|
5022
|
+
in that order, a nav whose order is semantic, a timeline. Locking a list nobody should reorder
|
|
5023
|
+
is the declaration there was previously no way to make; locking one out of tidiness takes a
|
|
5024
|
+
capability away from the author, so do not set it "just in case".
|
|
5025
|
+
|
|
5026
|
+
**Five refusals, all deliberate, all 400 with the fix in the message.**
|
|
5027
|
+
1. An unknown key inside \`ui\` is REJECTED, not stripped. \`ui\` is a strict object precisely so
|
|
5028
|
+
that a typo is a refusal you can read instead of a 200 with your declaration gone.
|
|
5029
|
+
2. \`ui.preview\` on a LEAF field is refused \u2014 it summarises an item, and a leaf has no items.
|
|
5030
|
+
Valid on \`group\`, \`repeater\`, an \`array\` with \`config.zones\`, or \`modular\`.
|
|
5031
|
+
3. \`preview.title\` / \`preview.media\` naming a key that is not a child of that field is refused,
|
|
5032
|
+
and the error lists the valid child keys.
|
|
5033
|
+
4. \`ui.layout\` follows the same rule as \`preview\`: nesting fields only. A leaf has no items
|
|
5034
|
+
to arrange.
|
|
5035
|
+
5. \`ui.reorderable\` is STRICTER \u2014 it needs a LIST, not merely children. Valid on \`repeater\`,
|
|
5036
|
+
an \`array\` with \`config.zones.repeatable\`, or \`modular\`. A \`group\` (and a non-repeatable
|
|
5037
|
+
zone) holds exactly ONE item, so there is nothing there to drag and the write is refused.
|
|
5038
|
+
|
|
5039
|
+
**The same \`ui\` rides on COMPONENT PROPS.** \`create_component\` / \`update_component\` take it on
|
|
5040
|
+
each entry of \`props\`, with the placement rules the prop vocabulary implies: \`preview\` and
|
|
5041
|
+
\`layout\` on \`group\`, \`table\` and \`slot\`; \`reorderable\` on \`table\` ALONE, because a table is the
|
|
5042
|
+
only prop whose value is a list (a \`group\` is one object, a \`slot\` one component instance). A
|
|
5043
|
+
prop's \`preview\` keys name a SUB-FIELD from its own \`config.fields\` and are checked against
|
|
5044
|
+
them \u2014 except on a \`slot\`, whose children belong to whichever component fills it and therefore
|
|
5045
|
+
cannot be resolved at save time. \`update_component\` REPLACES the whole \`props\` array, so echo
|
|
5046
|
+
back each prop's stored \`ui\` or you delete it along with the prop.
|
|
5047
|
+
|
|
5048
|
+
\`\`\`jsonc
|
|
5049
|
+
{ "key": "slides", "label": "Slides", "type": "table",
|
|
5050
|
+
"target": { "blockId": "slider", "path": "props.slides" },
|
|
5051
|
+
"ui": { "preview": { "title": "caption", "media": "image" }, "layout": "grid" },
|
|
5052
|
+
"config": { "fields": [ { "key": "image", "type": "image", ... },
|
|
5053
|
+
{ "key": "caption", "type": "text", ... } ] } }
|
|
5054
|
+
\`\`\`
|
|
5055
|
+
|
|
5056
|
+
**THE RECEIPT \u2014 read it back.** There is no separate verification tool and no mode to flip.
|
|
5057
|
+
\`get_content_model\` (and \`get_page\` / \`get_component\`) return each field's \u2014 and each component
|
|
5058
|
+
prop's \u2014 stored \`ui\`, \`helpText\` and \`showIf\`. A prop that is ABSENT from that response was not
|
|
5059
|
+
stored, whatever the write returned \u2014 \xA712 rule 3, applied to this feature. Declare, then read
|
|
5060
|
+
back, then say it works.
|
|
5061
|
+
|
|
5062
|
+
**No \`order\` property. Ever.** A field's order IS its index in \`fields\`. To put a new field
|
|
5063
|
+
somewhere other than the end, pass \`after: '<existing top-level field key>'\` to \`add_field\` /
|
|
5064
|
+
\`add_page_field\`; omit it and the field appends, exactly as before. \`after\` is an argument
|
|
5065
|
+
applied once at the write \u2014 a stored ordering prop would be a second source of truth that
|
|
5066
|
+
disagrees with the array the moment anyone drags a row in the builder.
|
|
5067
|
+
|
|
5068
|
+
**Where the values come from \u2014 three tiers, in order. Stop at the first that answers.**
|
|
5069
|
+
|
|
5070
|
+
TIER 1 DERIVE FROM THE SITE'S SOURCE, when you have a checkout.
|
|
5071
|
+
<img src={item.image}> + <h3>{item.name}</h3> inside the map over \`team\`
|
|
5072
|
+
\u2192 ui.preview = { title: 'name', media: 'image' }
|
|
5073
|
+
A section rendered inside a <details> or behind a "Show advanced" toggle
|
|
5074
|
+
\u2192 ui.collapsed: true
|
|
5075
|
+
grid-cols-* / display:grid on the wrapper of the .map \u2192 ui.layout: 'grid'
|
|
5076
|
+
A FIXED-ARITY render \u2014 steps[0]/steps[1]/steps[2], or copy that names the
|
|
5077
|
+
positions ("Step 1", "then", "finally") \u2192 ui.reorderable: false
|
|
5078
|
+
A prop read only when another is set \u2192 showIf
|
|
5079
|
+
\u{1F534} THE SERVER NEVER READS YOUR SOURCE on the add_field path. \`pull_project_source\`
|
|
5080
|
+
and \`get_conversion_brief\` hand YOU the repo; this tier is work you do in your own
|
|
5081
|
+
checkout before you call the tool. On a greenfield, schema-first project there is
|
|
5082
|
+
no source at all \u2014 Tier 1 is vacuous and TIER 2 IS THE FLOOR.
|
|
5083
|
+
|
|
5084
|
+
TIER 2 DERIVE FROM THE FIELD SHAPE, when the code is silent.
|
|
5085
|
+
A repeater whose children include exactly one image-ish child \u2192 that is \`media\`.
|
|
5086
|
+
Its first required short-text child (name/title/heading/label) \u2192 that is \`title\`.
|
|
5087
|
+
A nesting field with more than ~6 children, or one whose label reads as optional
|
|
5088
|
+
("Advanced", "Extras", "SEO overrides") \u2192 \`collapsed: true\`.
|
|
5089
|
+
A repeatable whose item is MOSTLY its image (gallery, logos, team) \u2192 \`layout:
|
|
5090
|
+
'grid'\`; anything whose words are the point stays 'list' \u2014 so OMIT \`layout\`.
|
|
5091
|
+
\`reorderable\` HAS NO TIER-2 GUESS. The default (true) is the safe one, and
|
|
5092
|
+
locking a list the user did not ask to lock takes a capability away from them.
|
|
5093
|
+
Leave it absent unless Tier 1 showed you a fixed arity or the user said so.
|
|
5094
|
+
Guessing here is CORRECT. A wrong preview costs one edit; asking about every
|
|
5095
|
+
repeater on a 40-field schema costs the user the session.
|
|
5096
|
+
|
|
5097
|
+
TIER 3 ASK THE USER \u2014 only when 1 and 2 are both silent, and only for these three:
|
|
5098
|
+
a) MANY fields in one panel, where the grouping and the order depend on which
|
|
5099
|
+
content the user considers primary. You cannot know their priorities.
|
|
5100
|
+
b) CONDITIONAL VISIBILITY (\`showIf\`). It encodes business logic \u2014 when a field is
|
|
5101
|
+
irrelevant \u2014 and nothing in the code or the shape tells you that.
|
|
5102
|
+
c) VARIANTS SHARING A \`sectionType\`. Their prop keys MUST match (\xA74), so a preview
|
|
5103
|
+
or a rename that diverges between two variants makes a layout swap lose content.
|
|
5104
|
+
Confirm the shared key set before you declare per-variant chrome.
|
|
5105
|
+
Ask with AskUserQuestion, batched, once \u2014 never one field at a time.
|
|
4697
5106
|
`;
|
|
4698
5107
|
|
|
4699
5108
|
// src/prompts.ts
|
|
@@ -5199,6 +5608,108 @@ On 401/403, the MCP key needs (re)authorizing.`
|
|
|
5199
5608
|
]
|
|
5200
5609
|
})
|
|
5201
5610
|
);
|
|
5611
|
+
server.registerPrompt(
|
|
5612
|
+
"field-ui",
|
|
5613
|
+
{
|
|
5614
|
+
title: "Declare how fields LOOK in the editor (guided)",
|
|
5615
|
+
description: "Set the authoring chrome on an existing schema \u2014 collapsed panels, what titles and thumbnails a repeater's rows, help text, conditional visibility. Derives it from your site's source where there is source, from the field shapes where there isn't, and only asks you about the three things it genuinely cannot know.",
|
|
5616
|
+
argsSchema: {
|
|
5617
|
+
request: z2.string().optional().describe("scope, e.g. 'the Team and Testimonials models' or 'the whole project'")
|
|
5618
|
+
}
|
|
5619
|
+
},
|
|
5620
|
+
({ request }) => ({
|
|
5621
|
+
messages: [
|
|
5622
|
+
{
|
|
5623
|
+
role: "user",
|
|
5624
|
+
content: {
|
|
5625
|
+
type: "text",
|
|
5626
|
+
text: `Declare the editor UI for my BetterCMS fields.${request ? ` Scope: "${request}".` : ""}
|
|
5627
|
+
|
|
5628
|
+
Read bettercms://playbook/schema section 14 first \u2014 it has the exact prop shapes and the refusals.
|
|
5629
|
+
|
|
5630
|
+
**What you are setting** (all of these ride on any field, on create_content_model / create_page /
|
|
5631
|
+
add_field / add_page_field; none of them affect delivery):
|
|
5632
|
+
ui.collapsed start a nesting field's panel closed
|
|
5633
|
+
ui.preview {title,media} which CHILD FIELD KEY titles a collapsed row, and which is its thumb
|
|
5634
|
+
ui.layout 'list'|'grid' how its items are arranged; omit for list
|
|
5635
|
+
ui.reorderable omit (= true) unless the ORDER IS THE MEANING; false LOCKS the list
|
|
5636
|
+
helpText one line under the field: what to WRITE here
|
|
5637
|
+
showIf hide a field until another field has a value
|
|
5638
|
+
|
|
5639
|
+
**The same \`ui\` rides on COMPONENT PROPS** \u2014 create_component / update_component, one per entry
|
|
5640
|
+
of \`props\`. Placement differs because the vocabulary does: preview/layout on 'group', 'table'
|
|
5641
|
+
and 'slot'; reorderable on 'table' ALONE (the only prop whose value is a list). A prop's preview
|
|
5642
|
+
keys name a SUB-FIELD from its own config.fields. If this scope includes components, do them in
|
|
5643
|
+
the same pass \u2014 but note update_component REPLACES the whole props array, so read get_component
|
|
5644
|
+
first and echo every prop back WITH its stored ui.
|
|
5645
|
+
|
|
5646
|
+
**Work the three tiers in order. Stop at the first that answers \u2014 do not escalate what a tier
|
|
5647
|
+
below already settled.**
|
|
5648
|
+
|
|
5649
|
+
TIER 1 \u2014 DERIVE FROM SOURCE, if and only if you have a checkout of the site.
|
|
5650
|
+
Read the component that renders each field. \`<img src={item.photo}>\` next to
|
|
5651
|
+
\`<h3>{item.name}</h3>\` in the map over a list IS the answer: preview {title:'name',
|
|
5652
|
+
media:'photo'}. A block inside \`<details>\` or behind a "Show more" is collapsed:true.
|
|
5653
|
+
A \`grid-cols-*\` wrapper around the map is layout:'grid'. A FIXED-ARITY render \u2014
|
|
5654
|
+
steps[0]/steps[1]/steps[2], or copy that names positions ("Step 1", "finally") \u2014 is
|
|
5655
|
+
reorderable:false, and it is the ONLY evidence that earns that key.
|
|
5656
|
+
\u{1F534} THE SERVER NEVER READS YOUR SOURCE. \`pull_project_source\` / \`get_conversion_brief\` hand
|
|
5657
|
+
the repo to YOU; add_field does not look at it. This tier is your own reading, in your own
|
|
5658
|
+
checkout, before you call any tool. ON A GREENFIELD SCHEMA-FIRST PROJECT THERE IS NO SOURCE \u2014
|
|
5659
|
+
Tier 1 does not apply and TIER 2 IS THE FLOOR. Do not stall waiting for code that will
|
|
5660
|
+
never exist.
|
|
5661
|
+
|
|
5662
|
+
TIER 2 \u2014 DERIVE FROM THE FIELD SHAPE, when the code is silent or absent.
|
|
5663
|
+
One image-ish child in a repeater \u2192 that is \`media\`. The first required short-text child
|
|
5664
|
+
(name/title/heading/label) \u2192 that is \`title\`. More than ~6 children, or a label that reads
|
|
5665
|
+
optional ("Advanced", "Extras") \u2192 collapsed:true. A repeatable whose item is mostly its
|
|
5666
|
+
image (gallery, logos, team) \u2192 layout:'grid'; everything else stays list, so OMIT layout.
|
|
5667
|
+
\`reorderable\` has NO Tier-2 guess \u2014 leave it absent. The default is true, and locking a
|
|
5668
|
+
list the user did not ask to lock silently removes something they could do yesterday.
|
|
5669
|
+
GUESS HERE otherwise. A wrong preview costs one edit; a question per repeater costs the
|
|
5670
|
+
user the session.
|
|
5671
|
+
|
|
5672
|
+
TIER 3 \u2014 ASK ME, and ONLY for these three. Batch them into ONE AskUserQuestion at the end,
|
|
5673
|
+
never one field at a time:
|
|
5674
|
+
a) A panel with MANY fields, where grouping and order depend on which content I treat as
|
|
5675
|
+
primary \u2014 you cannot infer my priorities.
|
|
5676
|
+
b) CONDITIONAL VISIBILITY (showIf) \u2014 it is business logic about when a field is irrelevant.
|
|
5677
|
+
Never invent a rule.
|
|
5678
|
+
c) VARIANTS SHARING A sectionType \u2014 their prop keys must match, or swapping the layout loses
|
|
5679
|
+
content. Confirm the shared key set before declaring per-variant chrome.
|
|
5680
|
+
|
|
5681
|
+
**Order of work**
|
|
5682
|
+
1. \`list_content_models\` / \`list_pages\`, then \`get_content_model\` / \`get_page\` for each target.
|
|
5683
|
+
Note which fields ALREADY carry ui/helpText/showIf \u2014 do not overwrite a human's choice
|
|
5684
|
+
without asking.
|
|
5685
|
+
2. Decide each field's chrome by the tiers above. Write down which tier answered; you will
|
|
5686
|
+
report it.
|
|
5687
|
+
3. Apply. For a field that already exists, the declaration goes with the field: use the
|
|
5688
|
+
dashboard or a model update \u2014 \`add_field\` APPENDS and refuses an existing key, so it is
|
|
5689
|
+
the wrong tool for editing one. For a NEW field, pass ui/helpText/showIf in the same
|
|
5690
|
+
\`add_field\` / \`add_page_field\` call, and pass \`after: '<existing key>'\` if it belongs
|
|
5691
|
+
somewhere other than the bottom.
|
|
5692
|
+
4. **RECEIPT \u2014 non-negotiable.** Re-read every target with \`get_content_model\` / \`get_page\` /
|
|
5693
|
+
\`get_component\` and
|
|
5694
|
+
confirm the stored \`ui\` matches what you sent. A prop ABSENT from that response was NOT
|
|
5695
|
+
stored, whatever the write said. Report per field: the tier that decided it, what you set,
|
|
5696
|
+
and that the read-back confirmed it. Anything you could not confirm, say so plainly.
|
|
5697
|
+
|
|
5698
|
+
**Refusals you should expect and must not work around:** an unknown key inside \`ui\` is a 400
|
|
5699
|
+
(it is strict on purpose \u2014 that is your typo, named); \`ui.preview\` or \`ui.layout\` on a leaf
|
|
5700
|
+
field is a 400 (both describe ITEMS); \`ui.reorderable\` anywhere without a LIST is a 400 \u2014 a
|
|
5701
|
+
\`group\` and a non-repeatable zone hold one item, so only \`repeater\`, an \`array\` with
|
|
5702
|
+
config.zones.repeatable, and \`modular\` take it; a preview key that is not a child of that field
|
|
5703
|
+
is a 400 listing the valid child keys. Fix the declaration; never retry without it and call that
|
|
5704
|
+
success.
|
|
5705
|
+
|
|
5706
|
+
There is deliberately NO \`order\` property and no mode to turn on \u2014 order is the array index,
|
|
5707
|
+
and \`ui\` only changes editor chrome, so there is nothing to flip.`
|
|
5708
|
+
}
|
|
5709
|
+
}
|
|
5710
|
+
]
|
|
5711
|
+
})
|
|
5712
|
+
);
|
|
5202
5713
|
}
|
|
5203
5714
|
|
|
5204
5715
|
// src/server.ts
|