@bettercms-ai/mcp 0.63.2 → 0.64.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/README.md +1 -0
- package/SKILL.md +1 -1
- package/dist/index.js +912 -489
- package/dist/index.js.map +1 -1
- package/dist/skills/bettercms/SKILL.md +175 -0
- package/dist/skills/bettercms/references/astro.md +67 -0
- package/dist/skills/bettercms/references/bindings.md +201 -0
- package/dist/skills/bettercms/references/componentize.md +106 -0
- package/dist/skills/bettercms/references/components.md +228 -0
- package/dist/skills/bettercms/references/content-experiments.md +62 -0
- package/dist/skills/bettercms/references/content-modeling.md +185 -0
- package/dist/skills/bettercms/references/editor-chrome.md +88 -0
- package/dist/skills/bettercms/references/external-content.md +78 -0
- package/dist/skills/bettercms/references/finish-site.md +123 -0
- package/dist/skills/bettercms/references/forms-and-interactions.md +203 -0
- package/dist/skills/bettercms/references/frameworks.md +149 -0
- package/dist/skills/bettercms/references/getting-started.md +106 -0
- package/dist/skills/bettercms/references/localization.md +81 -0
- package/dist/skills/bettercms/references/media-and-brand.md +142 -0
- package/dist/skills/bettercms/references/next.md +93 -0
- package/dist/skills/bettercms/references/other-frameworks.md +66 -0
- package/dist/skills/bettercms/references/portable-text.md +206 -0
- package/dist/skills/bettercms/references/programmatic-seo.md +98 -0
- package/dist/skills/bettercms/references/publishing.md +146 -0
- package/dist/skills/bettercms/references/seo.md +156 -0
- package/dist/skills/bettercms/references/shopify-headless.md +52 -0
- package/dist/skills/bettercms/references/site-blueprints.md +250 -0
- package/dist/skills/bettercms/references/site-review.md +73 -0
- package/dist/skills/bettercms/references/write-limits.md +84 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { realpathSync } from "fs";
|
|
5
|
-
import { fileURLToPath } from "url";
|
|
5
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
6
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
7
|
import { loadConfig, FileTokenStore, DeviceAuthClient } from "@bettercms-ai/device-auth";
|
|
8
8
|
|
|
@@ -2266,7 +2266,7 @@ import { DeviceAuthPendingError } from "@bettercms-ai/device-auth";
|
|
|
2266
2266
|
// src/structure-playbook.ts
|
|
2267
2267
|
var STRUCTURE_PLAYBOOK_URI = "bettercms://playbook/structure";
|
|
2268
2268
|
var STRUCTURE_DEFAULT_INSTRUCTION = `After you create collections or pages, organise them per the structure playbook: read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure (read-only), show the user its outline, then apply it with set_content_structure and the version it returned (the If-Match). A project that already has a structure keeps it: file only what you created, with move_to_folder, unless the user asks for a full re-organisation.`;
|
|
2269
|
-
var SKILLS_ROUTING_INSTRUCTION = "Before writing code in a repo that uses BetterCMS, explore the repo, then read the installed `bettercms` skill (`npx @bettercms-ai/install --project -y` adds it if missing): its table turns what you found into the one reference the task needs \u2014 read only that.";
|
|
2269
|
+
var SKILLS_ROUTING_INSTRUCTION = "Before writing code in a repo that uses BetterCMS, explore the repo, then read the installed `bettercms` skill (`npx @bettercms-ai/install --project -y` adds it if missing): its table turns what you found into the one reference the task needs \u2014 read only that. Without the skill installed, read the same files as resources: bettercms://skill/bettercms, then bettercms://skill/<reference>.";
|
|
2270
2270
|
var DOCK_UI_ROUTING_INSTRUCTION = "How fields and props look in the editor (groups, tabs, rows, icons, item names) is playbook \xA714 or the `field-ui` prompt: write it with update_content_model field* or update_component prop* plus ifMatch, read it back, and finish when get_next_steps reports no dock-* step.";
|
|
2271
2271
|
var RECEIPT_FIRST_INSTRUCTION = "Start every project with get_next_steps: its `receipt` lists what is already done (`done`), what is still missing (`missing`, each item citing the count it reacted to) and the one `nextAction`. Work from that receipt instead of re-reading the site, call get_next_steps again after each batch of edits, and before you tell the user you are finished. Fix what it lists, or say why you are leaving it.";
|
|
2272
2272
|
var NEXT_STEPS_DESCRIPTION = `Call this FIRST on a project. Returns \`receipt\` \u2014 \`done\` (live release, bound elements, conversion coverage, composed pages, published components and Layout), \`missing\`, \`nextAction\` and \`notes\` (unjudged: unknown, not done) \u2014 plus \`data\`, the unfinished items: missing meta descriptions, drafts, empty collections, unnotified forms, writes awaiting approval, unstamped bindings or sections, \`authoring-undecided\` and the Layout lane (\`layout-write-blocked\`, \`layout-field-unread\`, \`layout-locator-stale\`, \`layout-rebuild-required\`, \`chrome-duplicated\`). Each item cites its count and may carry \`payload\` {rows, total}, at most 20 rows. Call it again after a batch of edits and before telling the user you are done. \`organise-content\`: pages and collections are in no folder \u2014 read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure and, once the user agrees, set_content_structure.`;
|
|
@@ -2348,6 +2348,29 @@ ever changes.
|
|
|
2348
2348
|
reading needs only content access. A 403 \`SCHEMA_ACCESS_REQUIRED\` means this connection is
|
|
2349
2349
|
neither: stop and tell the user, do not retry.
|
|
2350
2350
|
|
|
2351
|
+
## Refusals
|
|
2352
|
+
|
|
2353
|
+
What the structure writes refuse, and the code each refusal carries:
|
|
2354
|
+
|
|
2355
|
+
- **System folders.** \`home:pages\` (Other pages), \`home:collections\` (Shared) and
|
|
2356
|
+
\`home:globals\` (Settings) always exist and can never be moved or deleted (400
|
|
2357
|
+
\`NAV_SYSTEM_FOLDER\`); \`rename_folder\` and \`set_icon\` work on them.
|
|
2358
|
+
- **Type purity** (400 \`NAV_TYPE_MISMATCH\`): Other pages holds only pages at any depth, Shared
|
|
2359
|
+
only collections, Settings neither \u2014 globals are not sidebar items.
|
|
2360
|
+
- **Pins** sit above the folders in their own order, pages or collections. In a document a pin is
|
|
2361
|
+
\`folderId: "home:root"\`, a virtual id with no folder record; a folder record with that id is
|
|
2362
|
+
400 \`NAV_SYSTEM_FOLDER\`. In \`move_to_folder\`, \`folderId: 'root'\` pins and \`null\` returns
|
|
2363
|
+
the item to its home (Other pages / Shared).
|
|
2364
|
+
- **Kind.** \`move_to_folder\` and \`set_icon\` VERIFY what an id is: a page id sent as a
|
|
2365
|
+
collection is 400 \`NAV_KIND_MISMATCH\` naming the kind to use. \`set_content_structure\` never
|
|
2366
|
+
refuses a kind mismatch: it returns \`warnings\` and \`kindMismatches\` instead.
|
|
2367
|
+
- **Page-bound models** are refused with 400 \`NAV_PAGE_BOUND_MODEL\` naming their page; organise
|
|
2368
|
+
the page, by its page id.
|
|
2369
|
+
- **Shape:** folders nest at most 4 levels deep; names are 1-80 characters; icons are lucide
|
|
2370
|
+
names (a-z, 0-9, dashes) and null clears one.
|
|
2371
|
+
- **Access:** 403 \`SCHEMA_ACCESS_REQUIRED\` (rule 12).
|
|
2372
|
+
- **Version:** 412 \`VERSION_CONFLICT\` (workflow step 4).
|
|
2373
|
+
|
|
2351
2374
|
## The workflow
|
|
2352
2375
|
|
|
2353
2376
|
1. \`get_content_structure\` \u2014 note \`version\` and whether a structure exists.
|
|
@@ -2396,6 +2419,316 @@ ${JSON.stringify(STRUCTURE_EXAMPLE_PAYLOAD, null, 2)}
|
|
|
2396
2419
|
\`\`\`
|
|
2397
2420
|
`;
|
|
2398
2421
|
|
|
2422
|
+
// src/entry-merge.ts
|
|
2423
|
+
function planEntryMerge(stored, patch, callerIfMatch) {
|
|
2424
|
+
const data = { ...stored.data ?? {}, ...patch };
|
|
2425
|
+
const ifMatch = callerIfMatch ?? (typeof stored.optimisticVersion === "number" ? stored.optimisticVersion : void 0);
|
|
2426
|
+
return ifMatch === void 0 ? { data } : { data, ifMatch };
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// src/whoami.ts
|
|
2430
|
+
var WHOAMI_DESCRIPTION = "Who this connection acts as. Call it first when a call is refused for access. Returns the principal (`device_grant` from the connect flow, or `api_key`), the workspace, `scope` ('project' = bound to one project; 'workspace' = pass projectId on every project tool) and, where the server can see them, `grants`: content:publish takes content live, artifact:write runs the code and deploy tools, schema:manage changes the Content sidebar. `grants: null` means this server cannot see them, not that there are none. Reads nothing else and changes nothing.";
|
|
2431
|
+
|
|
2432
|
+
// src/model-field-edit.ts
|
|
2433
|
+
function fieldRemovalRequest(args) {
|
|
2434
|
+
const { removeFields, allowFieldRemoval, pageId } = args;
|
|
2435
|
+
if (removeFields === void 0 && allowFieldRemoval === void 0) return { kind: "none" };
|
|
2436
|
+
if (removeFields === void 0) {
|
|
2437
|
+
return { kind: "refuse", error: "allowFieldRemoval names no field: pass removeFields with the top-level keys to delete." };
|
|
2438
|
+
}
|
|
2439
|
+
if (!Array.isArray(removeFields) || removeFields.length === 0 || !removeFields.every((k) => typeof k === "string" && k)) {
|
|
2440
|
+
return { kind: "refuse", error: "removeFields must be a non-empty list of top-level field keys (from get_content_model)." };
|
|
2441
|
+
}
|
|
2442
|
+
if (pageId !== void 0) {
|
|
2443
|
+
return { kind: "refuse", error: "A page's fields are removed with remove_page_fields, which has a dry run; removeFields takes modelId." };
|
|
2444
|
+
}
|
|
2445
|
+
if (allowFieldRemoval !== true) {
|
|
2446
|
+
return {
|
|
2447
|
+
kind: "refuse",
|
|
2448
|
+
error: "removeFields deletes those fields and DISCARDS their values in every entry: send allowFieldRemoval: true with it, once the user has agreed."
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2451
|
+
return { kind: "remove", keys: [...new Set(removeFields)] };
|
|
2452
|
+
}
|
|
2453
|
+
function planFieldRemoval(model, keys, callerIfMatch) {
|
|
2454
|
+
const fields = model.fields ?? [];
|
|
2455
|
+
const present = new Set(fields.map((f) => f.key));
|
|
2456
|
+
const unknown = keys.filter((k) => !present.has(k));
|
|
2457
|
+
if (unknown.length > 0) {
|
|
2458
|
+
return {
|
|
2459
|
+
ok: false,
|
|
2460
|
+
error: `Not top-level fields of this model: ${unknown.join(", ")}. Nothing was removed; read get_content_model for its keys (a group's child goes with its group, or is edited in the dashboard).`
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
2463
|
+
const drop = new Set(keys);
|
|
2464
|
+
const ifMatch = callerIfMatch ?? (typeof model.optimisticVersion === "number" ? model.optimisticVersion : void 0);
|
|
2465
|
+
const kept = fields.filter((f) => !drop.has(f.key));
|
|
2466
|
+
return ifMatch === void 0 ? { ok: true, fields: kept } : { ok: true, fields: kept, ifMatch };
|
|
2467
|
+
}
|
|
2468
|
+
var CHILD_LISTS = [
|
|
2469
|
+
["fields"],
|
|
2470
|
+
["config", "zones", "nonRepeatable"],
|
|
2471
|
+
["config", "zones", "repeatable", "fields"],
|
|
2472
|
+
["config", "fields"],
|
|
2473
|
+
["config", "itemFields"]
|
|
2474
|
+
];
|
|
2475
|
+
var getIn = (node, at) => at.reduce((n, k) => n && typeof n === "object" ? n[k] : void 0, node);
|
|
2476
|
+
var setIn = (node, [head, ...rest], value) => ({ ...node, [head]: rest.length === 0 ? value : setIn(node[head] ?? {}, rest, value) });
|
|
2477
|
+
function patchAt(fields, [key, ...rest], edit) {
|
|
2478
|
+
const at = fields.findIndex((f) => f.key === key);
|
|
2479
|
+
if (at === -1) return null;
|
|
2480
|
+
const field = fields[at];
|
|
2481
|
+
let next = null;
|
|
2482
|
+
if (rest.length === 0) next = edit(field);
|
|
2483
|
+
else {
|
|
2484
|
+
for (const list of CHILD_LISTS) {
|
|
2485
|
+
const kids = getIn(field, list);
|
|
2486
|
+
const patched = Array.isArray(kids) ? patchAt(kids, rest, edit) : null;
|
|
2487
|
+
if (patched) {
|
|
2488
|
+
next = setIn(field, list, patched);
|
|
2489
|
+
break;
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
return next && fields.map((f, i) => i === at ? next : f);
|
|
2494
|
+
}
|
|
2495
|
+
function fieldAt(fields, keys) {
|
|
2496
|
+
let found;
|
|
2497
|
+
patchAt(fields, keys, (f) => (found = f, f));
|
|
2498
|
+
return found;
|
|
2499
|
+
}
|
|
2500
|
+
function allKeys(fields) {
|
|
2501
|
+
return fields.flatMap((f) => [
|
|
2502
|
+
String(f.key),
|
|
2503
|
+
...CHILD_LISTS.flatMap((list) => {
|
|
2504
|
+
const kids = getIn(f, list);
|
|
2505
|
+
return Array.isArray(kids) ? allKeys(kids) : [];
|
|
2506
|
+
})
|
|
2507
|
+
]);
|
|
2508
|
+
}
|
|
2509
|
+
var CLEARABLE = ["helpText", "showIf", "ui", "placement"];
|
|
2510
|
+
var SETTABLE = ["label", "required", ...CLEARABLE];
|
|
2511
|
+
function mergeConfig(current, patch) {
|
|
2512
|
+
const out = { ...current ?? {} };
|
|
2513
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
2514
|
+
if (v === null) delete out[k];
|
|
2515
|
+
else out[k] = v;
|
|
2516
|
+
}
|
|
2517
|
+
return out;
|
|
2518
|
+
}
|
|
2519
|
+
function planFieldEdit(model, edit, callerIfMatch) {
|
|
2520
|
+
const fields = model.fields ?? [];
|
|
2521
|
+
const keys = typeof edit.path === "string" && edit.path ? edit.path.split(".") : [];
|
|
2522
|
+
const current = keys.length ? fieldAt(fields, keys) : void 0;
|
|
2523
|
+
if (!current) {
|
|
2524
|
+
return { ok: false, error: `No field at path '${String(edit.path ?? "")}'. Paths are dot-separated keys from get_content_model or get_page, e.g. 'hero.title'.` };
|
|
2525
|
+
}
|
|
2526
|
+
const retype = edit.type !== void 0 && edit.type !== current.type;
|
|
2527
|
+
if (retype && edit.allowRetype !== true) {
|
|
2528
|
+
return {
|
|
2529
|
+
ok: false,
|
|
2530
|
+
error: `Changing '${keys.join(".")}' from ${String(current.type)} to ${String(edit.type)} changes how every stored value is read, and values that do not fit are lost. Send allowRetype: true with it, once the user has agreed.`
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
const rename = typeof edit.key === "string" && edit.key !== current.key ? edit.key : void 0;
|
|
2534
|
+
if (rename !== void 0 && allKeys(fields).includes(rename)) {
|
|
2535
|
+
return { ok: false, error: `The key '${rename}' is already used in this schema; keys share one flat namespace, nested fields included.` };
|
|
2536
|
+
}
|
|
2537
|
+
const changes = SETTABLE.filter((k) => edit[k] !== void 0);
|
|
2538
|
+
if (!retype && rename === void 0 && edit.config === void 0 && changes.length === 0) {
|
|
2539
|
+
return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, ui, placement, type or key." };
|
|
2540
|
+
}
|
|
2541
|
+
const next = patchAt(fields, keys, (f) => {
|
|
2542
|
+
let out = { ...f };
|
|
2543
|
+
for (const k of changes) {
|
|
2544
|
+
if (edit[k] === null && CLEARABLE.includes(k)) delete out[k];
|
|
2545
|
+
else out[k] = edit[k];
|
|
2546
|
+
}
|
|
2547
|
+
if (edit.config && typeof edit.config === "object") out = { ...out, config: mergeConfig(f.config, edit.config) };
|
|
2548
|
+
if (retype) out.type = edit.type;
|
|
2549
|
+
if (rename !== void 0) out.key = rename;
|
|
2550
|
+
return out;
|
|
2551
|
+
});
|
|
2552
|
+
const ifMatch = callerIfMatch ?? (typeof model.optimisticVersion === "number" ? model.optimisticVersion : void 0);
|
|
2553
|
+
return {
|
|
2554
|
+
ok: true,
|
|
2555
|
+
fields: next,
|
|
2556
|
+
...ifMatch === void 0 ? {} : { ifMatch },
|
|
2557
|
+
...rename === void 0 ? {} : { renamed: { from: String(current.key), to: rename } }
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
// src/toolsets.ts
|
|
2562
|
+
var LISTING_TAIL = /* @__PURE__ */ new Set([
|
|
2563
|
+
// The agent-job lane: a dashboard surface an agent rarely drives.
|
|
2564
|
+
"list_ai_jobs",
|
|
2565
|
+
"get_ai_job",
|
|
2566
|
+
"approve_ai_job",
|
|
2567
|
+
"reject_ai_job",
|
|
2568
|
+
"list_ai_reports",
|
|
2569
|
+
"connect_native_ai",
|
|
2570
|
+
// Component validation: a CI lane, not an authoring one.
|
|
2571
|
+
"request_component_validation",
|
|
2572
|
+
"declare_component_route",
|
|
2573
|
+
"clear_component_source",
|
|
2574
|
+
"submit_componentize_receipt",
|
|
2575
|
+
// Reporting, history and workflow chrome.
|
|
2576
|
+
"get_analytics_overview",
|
|
2577
|
+
"get_analytics_top_pages",
|
|
2578
|
+
"list_activity",
|
|
2579
|
+
"get_workflow_board",
|
|
2580
|
+
"move_stage",
|
|
2581
|
+
"list_entry_versions",
|
|
2582
|
+
"restore_entry_version",
|
|
2583
|
+
// One-off project operations and the extraction lane.
|
|
2584
|
+
"clone_project",
|
|
2585
|
+
"promote_project",
|
|
2586
|
+
"generate_pages_from_dataset",
|
|
2587
|
+
"extract_component",
|
|
2588
|
+
"list_extraction_candidates",
|
|
2589
|
+
"create_deploy_upload",
|
|
2590
|
+
"set_authoring_preference",
|
|
2591
|
+
"set_media_delivery",
|
|
2592
|
+
"delete_form_submission",
|
|
2593
|
+
"remove_page_fields",
|
|
2594
|
+
// Access troubleshooting and platform feedback: called when something is refused or missing.
|
|
2595
|
+
"whoami",
|
|
2596
|
+
"give_feedback",
|
|
2597
|
+
"search_docs",
|
|
2598
|
+
// Search, batch and localisation: reached for once the site exists.
|
|
2599
|
+
"search_content",
|
|
2600
|
+
"discard_draft",
|
|
2601
|
+
"publish_entries",
|
|
2602
|
+
"create_content_entries",
|
|
2603
|
+
"translate_entry",
|
|
2604
|
+
"update_field",
|
|
2605
|
+
"refresh_search_index",
|
|
2606
|
+
// Automation, branches and AI images.
|
|
2607
|
+
"list_webhooks",
|
|
2608
|
+
"create_webhook",
|
|
2609
|
+
"list_branches",
|
|
2610
|
+
"create_branch",
|
|
2611
|
+
"apply_branch",
|
|
2612
|
+
"generate_image",
|
|
2613
|
+
"transform_image",
|
|
2614
|
+
// The ABM campaign lane — also hidden from the default listing (ABM_TOOLS below).
|
|
2615
|
+
"research_account",
|
|
2616
|
+
"build_abm_page",
|
|
2617
|
+
"claim_abm_task",
|
|
2618
|
+
"complete_abm_task",
|
|
2619
|
+
"fail_abm_task",
|
|
2620
|
+
"get_campaign_review",
|
|
2621
|
+
"regenerate_entry",
|
|
2622
|
+
"approve_campaign_entries",
|
|
2623
|
+
"publish_campaign",
|
|
2624
|
+
"get_campaign_share_link",
|
|
2625
|
+
"revoke_campaign_share_link"
|
|
2626
|
+
]);
|
|
2627
|
+
var ABM_TOOLS = /* @__PURE__ */ new Set([
|
|
2628
|
+
"research_account",
|
|
2629
|
+
"build_abm_page",
|
|
2630
|
+
"claim_abm_task",
|
|
2631
|
+
"complete_abm_task",
|
|
2632
|
+
"fail_abm_task",
|
|
2633
|
+
"get_campaign_review",
|
|
2634
|
+
"regenerate_entry",
|
|
2635
|
+
"approve_campaign_entries",
|
|
2636
|
+
"publish_campaign",
|
|
2637
|
+
"get_campaign_share_link",
|
|
2638
|
+
"revoke_campaign_share_link"
|
|
2639
|
+
]);
|
|
2640
|
+
var TOOLSETS = ["core", "abm", "all"];
|
|
2641
|
+
function toolsetsOf(raw) {
|
|
2642
|
+
const names = (raw ?? "").toLowerCase().split(",").map((s) => s.trim());
|
|
2643
|
+
return new Set(names.filter((n) => TOOLSETS.includes(n)));
|
|
2644
|
+
}
|
|
2645
|
+
function listsTool(name, sets) {
|
|
2646
|
+
if (sets.has("all")) return true;
|
|
2647
|
+
if (ABM_TOOLS.has(name)) return sets.has("abm");
|
|
2648
|
+
return !sets.has("core") || !LISTING_TAIL.has(name);
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
// src/tool-text.ts
|
|
2652
|
+
var ADD_FIELD_DESCRIPTION = "Append ONE field to a content model (`modelId`) or to a page's schema (`pageId`) \u2014 additive, never overwrites; a key already in use is refused. For a section/zone add ONE 'group' (fixed block) or 'repeater' (repeating list) carrying its child `fields`, not the zone's inner fields as separate top-level fields. Pass `after` to insert it after an existing top-level field instead of at the end; `ui`, `helpText`, `showIf`, `placement` and `fieldsetId` declare how it presents in the editor \u2014 read them back with get_content_model or get_page. On a page a 'group' is stored as type 'array' with `config.zones.nonRepeatable`: entry and delivery data carry `<key>.nonRepeatable.<sub>`, while data-bcms-field paths are `<key>.<sub>`. Schema only \u2014 never places a section; use componentize_sections to turn a group into a section. To change a field that exists, use update_field.";
|
|
2653
|
+
var MOVE_STAGE_DESCRIPTION = "Move a content entry (`entryId`) or a page (`pageId`) to another editorial stage \u2014 this is how you take it OUT of review so it can be published. `workflowStage` is a stage key from get_workflow_board; pass `fromStage` to 409 if someone moved it first. Moving into the publish-gate stage is an approval and needs a publish-enabled connection. Publish afterwards with update_content_entry or update_page status:'published' \u2014 approving and publishing are deliberately two separate calls.";
|
|
2654
|
+
var WEBHOOK_EVENTS = [
|
|
2655
|
+
"content.publish",
|
|
2656
|
+
"content.unpublish",
|
|
2657
|
+
"content.stage_changed",
|
|
2658
|
+
"content.created",
|
|
2659
|
+
"content.updated",
|
|
2660
|
+
"content.deleted",
|
|
2661
|
+
"deployment.triggered"
|
|
2662
|
+
];
|
|
2663
|
+
var NEW_TOOL_TEXT = {
|
|
2664
|
+
search_content: "Full-text search over the project's PUBLISHED pages and entries (the site-search index). Returns id, type ('page'|'entry'), title, slug and a snippet per hit; `type` narrows to one kind. Drafts are not indexed: find those with list_content_entries `where`. `meta.unavailable` means site search is off for this project.",
|
|
2665
|
+
discard_draft: "Throw away unpublished edits to an entry (`entryId`) or a page (`pageId`) \u2014 exactly one: the draft returns to exactly what is live and `pendingChanges` becomes false. A page resets its blockJson and its published Layout overrides. 409 no_published_version means it was never published (delete it instead). Pass ifMatch (optimisticVersion) to refuse if it changed since you read it.",
|
|
2666
|
+
give_feedback: "Tell the BetterCMS team about a tool that failed, a capability you needed and did not have, or docs that misled you. Opens a ticket for the team; nothing in the project changes. Name the `tool`, and paste the request and response into `context`. Never include secrets or personal data.",
|
|
2667
|
+
publish_entries: "Publish or unpublish many entries and pages in ONE call (up to 100 ids of each; pages publish only). Needs a publish-enabled connection, and each item still passes its own gates (editorial stage, required fields). Nothing stops the run: the result lists what changed (`published` or `unpublished`) and every refusal in `failed` with its reason. A publish answers 202 with the routes being rebuilt.",
|
|
2668
|
+
create_content_entries: "Create up to 200 DRAFT entries of one collection in ONE call, each {slug?, data} as create_content_entry takes it. Every row reports `created` (with id and slug) or `failed` (with the error), a failure does not stop the others, and `meta` counts both. Publish afterwards with publish_entries.",
|
|
2669
|
+
translate_entry: "Create an entry's `targetLocale` translation with AI (uses AI credits: 402/403 when out or not on the plan). Text and longtext fields are translated; rich text and documents are copied for you to translate; the rest is copied. The new entry is a DRAFT with `locale` set and `translationOf` = the source; `meta` lists translated and copied keys. `targetLocale` must be one of the project's locales; 409 locale_conflict means that translation exists, so edit it instead.",
|
|
2670
|
+
list_webhooks: "List the project's outgoing webhooks: name, url, events, whether active, and delivery health (total, failed, last attempt). Signing secrets are never returned.",
|
|
2671
|
+
create_webhook: "Create an outgoing webhook: BetterCMS POSTs each subscribed event to your https `url`, signed with HMAC-SHA256 in X-BetterCMS-Signature. The response carries the signing `secret` ONCE: store it now. `test: true` also sends a sample delivery. Payloads carry ids and metadata, never the content: fetch it from the API. A receiver that writes back on content.updated loops unless it skips its own writes.",
|
|
2672
|
+
list_branches: "List the project's content branches (id, name, slug, parent, status, whether main) and the `currentBranchId` this connection is on. To work inside a branch, pass its id as `branchId` on any tool call; apply_branch merges it into its parent.",
|
|
2673
|
+
create_branch: "Create a content branch: a copy of the project's content to edit without touching the live site. Returns the branch; pass its id as `branchId` on later calls to work in it, then apply_branch. 409 means the name or slug is taken.",
|
|
2674
|
+
apply_branch: "Merge a branch into its parent. dryRun: true returns the diff (changes, summary, conflicts) and writes nothing: ALWAYS read it and show the user first. `include` limits the merge to those change keys; `fields` resolves a conflicting field per change key ('keep_branch' or 'keep_parent'). 409 names unresolved conflicts, missing dependencies, or a merge already running.",
|
|
2675
|
+
search_docs: "Search the BetterCMS documentation. Returns the best pages (title, url, breadcrumbs, snippet). Use it before guessing how a feature works; `meta.unavailable` means docs search is down right now.",
|
|
2676
|
+
generate_image: "Generate an image with AI and add it to the Media Library (5 AI credits; free with the workspace's own Together key). `width`/`height` are 256-1440 in multiples of 16 (default 1024x768). Returns the new asset (id, url, altText): use the url as an 'image' field value. Always set `altText`.",
|
|
2677
|
+
refresh_search_index: "Tell search engines and site search about published changes; costs nothing. action 'indexnow' submits `urls` (this site's own, up to 1000) to IndexNow, or every change since the last submission when omitted. action 'reindex' rebuilds the project's site-search index. 503 means the index is unreachable: try again later.",
|
|
2678
|
+
transform_image: "Edit an existing Media Library image with an instruction (e.g. 'remove the background', 'make it dusk'); the result is saved as a NEW asset and the original is kept (5 AI credits). Returns the new asset (id, url, altText)."
|
|
2679
|
+
};
|
|
2680
|
+
var PARAM_TEXT = {
|
|
2681
|
+
where: "field filters: {field: value} is equality, {field: {op: value}} with op eq|ne|gt|gte|lt|lte|contains|in, and an array means `in`. System fields slug, status, publishedAt, locale work anywhere; a data field needs modelId. Compares the served copy (live for published, working for draft)",
|
|
2682
|
+
sort: "comma list, a leading - sorts descending, e.g. '-publishedAt,title'",
|
|
2683
|
+
page: "1-based page of `limit` rows; the response's meta says totalPages",
|
|
2684
|
+
locale: "BCP-47 locale, one of the project's locales; null = the default language",
|
|
2685
|
+
translationOf: "the SOURCE's id this translates (never another translation); null unlinks. One per locale: set locale on the translation and translationOf to the source",
|
|
2686
|
+
pageScheduledAt: "ISO datetime to publish this page's draft at (needs publish permission); null cancels. An edit that omits scheduledAt CANCELS a pending schedule",
|
|
2687
|
+
pageUnpublishAt: "ISO datetime at which the published page returns to draft; null cancels; omitting it never cancels it",
|
|
2688
|
+
sourceUrl: "instead of data: a public http(s) URL the server fetches and stores (private or internal addresses are refused)",
|
|
2689
|
+
focalX: "focal point, 0-1 across from the left edge; null resets to the centre",
|
|
2690
|
+
focalY: "focal point, 0-1 down from the top edge; null resets to the centre"
|
|
2691
|
+
};
|
|
2692
|
+
var UPDATE_FIELD_DESCRIPTION = "Change ONE existing field of a content model (`modelId`) or of a page's schema (`pageId`), addressed by `path`: its key, or dot-separated keys through groups and repeaters ('hero.title'). Set its label, helpText, required, validation `config` (merged by key; null deletes one), showIf, ui or placement (null clears those). The rest of the schema is written back unchanged at the version read; a 409 means it changed since, so read again. `type` changes how every stored value is read and needs allowRetype: true. `key` renames it: stored values stay under the old key, so the route answers 409 with the entry count unless allowFieldRemoval: true discards them \u2014 migrate the values first. Ask the user before a retype or a rename.";
|
|
2693
|
+
var GET_SEO_DESCRIPTION = "Get the connected project's site-wide SEO settings \u2014 every key update_seo writes: seoDefaults (metaTitle, metaDescription, ogImage, twitterHandle, og, twitter, collectionSchemaTypes), siteSchema (the site's JSON-LD), robotsConfig, sitemapConfig, rssConfig, indexnowConfig and searchConfig.";
|
|
2694
|
+
var UPDATE_SEO_DESCRIPTION = "Set the connected project's site-wide SEO defaults and config. Each key you send is REPLACED whole (not deep-merged), so read get_seo and send the complete object. `seoDefaults` applies to every page that does not override it, and its `collectionSchemaTypes` maps a collection's model id to the schema.org type of its entries. `siteSchema` is the site's own JSON-LD (Organization/WebSite, emitted on the home page). `robotsConfig`, `sitemapConfig` and `rssConfig` shape robots.txt, sitemap.xml and the feed; `indexnowConfig` pings search engines on publish; `searchConfig` is site search. A key the plan does not include answers 403 FEATURE_LOCKED. Rebuilds the site.";
|
|
2695
|
+
var SEO_KEY_TEXT = {
|
|
2696
|
+
seoDefaults: "site-wide meta defaults every page inherits",
|
|
2697
|
+
siteSchema: "the site's JSON-LD: an object or an array of objects",
|
|
2698
|
+
robotsConfig: "robots.txt: 'auto', or 'custom' with its text",
|
|
2699
|
+
sitemapConfig: "sitemap.xml: generated ('auto') or 'custom', defaults and extra URLs",
|
|
2700
|
+
rssConfig: "the RSS feed on or off",
|
|
2701
|
+
indexnowConfig: "IndexNow on publish, minus URL patterns to leave out",
|
|
2702
|
+
searchConfig: "site search: on/off, the box's placeholder, AI answers"
|
|
2703
|
+
};
|
|
2704
|
+
var VARIANT_GROUP_NOTE = "When the component belongs to a family of looks, `variantGroup` names the family and lists its looks.";
|
|
2705
|
+
var UNPROPPED_COPY_NOTE = "A `warnings` entry UNPROPPED_COPY names copy no prop targets, which nobody can edit: add a prop with a `target` for each leaf it lists.";
|
|
2706
|
+
var UPDATE_PAGE_IF_MATCH_NOTE = "get_page's `optimisticVersion`: the write applies only at that version, and a 409 means the page changed since, so read it again. Send it whenever you pass blockJson, which REPLACES the whole composition.";
|
|
2707
|
+
|
|
2708
|
+
// src/entry-query.ts
|
|
2709
|
+
var text = (v) => Array.isArray(v) ? v.map(String).join(",") : String(v);
|
|
2710
|
+
function whereToQuery(where) {
|
|
2711
|
+
const out = [];
|
|
2712
|
+
for (const [field, cond] of Object.entries(where ?? {})) {
|
|
2713
|
+
if (cond === void 0 || cond === null) continue;
|
|
2714
|
+
if (Array.isArray(cond)) out.push([`filter[${field}][in]`, text(cond)]);
|
|
2715
|
+
else if (typeof cond === "object") {
|
|
2716
|
+
for (const [op, v] of Object.entries(cond)) {
|
|
2717
|
+
if (v !== void 0 && v !== null) out.push([`filter[${field}][${op}]`, text(v)]);
|
|
2718
|
+
}
|
|
2719
|
+
} else out.push([`filter[${field}]`, text(cond)]);
|
|
2720
|
+
}
|
|
2721
|
+
return out;
|
|
2722
|
+
}
|
|
2723
|
+
function entryListSearch(args) {
|
|
2724
|
+
const q = new URLSearchParams();
|
|
2725
|
+
for (const key of ["modelId", "pageId", "status"]) if (args[key]) q.set(key, String(args[key]));
|
|
2726
|
+
for (const [k, v] of whereToQuery(args.where)) q.append(k, v);
|
|
2727
|
+
for (const key of ["sort", "page", "limit"]) if (args[key] !== void 0 && args[key] !== null) q.set(key, String(args[key]));
|
|
2728
|
+
const s = q.toString();
|
|
2729
|
+
return s ? `?${s}` : "";
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2399
2732
|
// src/tools.ts
|
|
2400
2733
|
async function askHuman(deps, q) {
|
|
2401
2734
|
if (!deps.elicit) return { prompt: q.prompt };
|
|
@@ -2647,7 +2980,34 @@ var fieldsetsArg = z.array(
|
|
|
2647
2980
|
);
|
|
2648
2981
|
var dropProvenance = (sets) => sets?.map(({ uiOrigin: _origin, uiBefore: _before, ...set }) => set) ?? sets;
|
|
2649
2982
|
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.";
|
|
2650
|
-
var
|
|
2983
|
+
var SCHEMA_ORG_TYPES = ["WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness"];
|
|
2984
|
+
var changefreq = z.enum(["auto", "always", "hourly", "daily", "weekly", "monthly", "yearly", "never"]);
|
|
2985
|
+
var jsonObject = z.record(z.string(), z.unknown());
|
|
2986
|
+
var seoPatchShape = {
|
|
2987
|
+
seoDefaults: z.object({
|
|
2988
|
+
metaTitle: z.string().max(255).optional(),
|
|
2989
|
+
metaDescription: z.string().max(500).optional(),
|
|
2990
|
+
ogImage: z.string().url().max(2e3).nullable().optional(),
|
|
2991
|
+
twitterHandle: z.string().max(255).nullable().optional(),
|
|
2992
|
+
og: z.object({ image: z.string().max(2048).optional(), type: z.string().max(50).optional() }).optional(),
|
|
2993
|
+
twitter: z.object({ card: z.string().max(50).optional() }).optional(),
|
|
2994
|
+
collectionSchemaTypes: z.record(z.string().min(1).max(64), z.enum(SCHEMA_ORG_TYPES)).optional()
|
|
2995
|
+
}).optional().describe(SEO_KEY_TEXT.seoDefaults),
|
|
2996
|
+
siteSchema: z.union([jsonObject, z.array(jsonObject)]).optional().describe(SEO_KEY_TEXT.siteSchema),
|
|
2997
|
+
robotsConfig: z.object({ mode: z.enum(["auto", "custom"]), custom: z.string().max(1e4).optional() }).optional().describe(SEO_KEY_TEXT.robotsConfig),
|
|
2998
|
+
sitemapConfig: z.object({
|
|
2999
|
+
enabled: z.boolean(),
|
|
3000
|
+
mode: z.enum(["auto", "custom"]).optional(),
|
|
3001
|
+
custom: z.string().max(5e5).optional(),
|
|
3002
|
+
defaultChangefreq: changefreq.optional(),
|
|
3003
|
+
defaultPriority: z.number().min(0).max(1).optional(),
|
|
3004
|
+
extraUrls: z.array(z.object({ loc: z.string().min(1).max(2e3), changefreq: changefreq.optional(), priority: z.number().min(0).max(1).optional() })).max(1e3).optional()
|
|
3005
|
+
}).optional().describe(SEO_KEY_TEXT.sitemapConfig),
|
|
3006
|
+
rssConfig: z.object({ enabled: z.boolean() }).optional().describe(SEO_KEY_TEXT.rssConfig),
|
|
3007
|
+
indexnowConfig: z.object({ enabled: z.boolean(), excludePatterns: z.array(z.string().max(2e3)).max(200).optional() }).optional().describe(SEO_KEY_TEXT.indexnowConfig),
|
|
3008
|
+
searchConfig: z.object({ enabled: z.boolean(), placeholder: z.string().max(120), aiEnabled: z.boolean().optional() }).optional().describe(SEO_KEY_TEXT.searchConfig)
|
|
3009
|
+
};
|
|
3010
|
+
var PAGES_NOT_COLLECTIONS_NOTE = "A collection is only for items one template route repeats (/blog/:slug: set urlPattern). A single page's copy (home, about, contact, pricing, legal) is page fields in sections (add_field with pageId / set_page_content), never a one-entry collection.";
|
|
2651
3011
|
function flatFieldKeys(fields) {
|
|
2652
3012
|
const out = [];
|
|
2653
3013
|
const childrenOf = (f) => {
|
|
@@ -2730,7 +3090,9 @@ function toField(f) {
|
|
|
2730
3090
|
};
|
|
2731
3091
|
}
|
|
2732
3092
|
var BRAND_KIT_NOTE = "`brandKit` (colors, typography, radius and any typeScale/spacing/elevation/motion/graphics) is edited in the BetterCMS dashboard AND by set_brand_assets and set_brand_graphics; every write is a version a person can restore. ASSETS: a media asset OF THIS PROJECT by id; null clears one; a slot a person chose needs `replace: true`. GRAPHICS: `kind`, optional `angle`, 2-8 stops naming `colors.*` or `extras` keys \u2014 never a CSS string and never a hex. CONSUME, DO NOT COPY: a hosted page exposes `--brand-color-<key>`, `--brand-shadow-<key>` and `--brand-gradient-<key>`; style sections with their `style` tokens (`nav.backgroundGradient` and `footer.backgroundGradient` take a gradient key), never copied values. Never invent brand facts: read them from get_project.";
|
|
2733
|
-
var UPDATE_MODEL_DESCRIPTION = "Edit a content model: rename it, change description/slug/urlPattern, set its `fieldsets`, or reshape how its EXISTING fields look in the editor. Never adds
|
|
3093
|
+
var UPDATE_MODEL_DESCRIPTION = "Edit a content model: rename it, change description/slug/urlPattern, set its `fieldsets`, or reshape how its EXISTING fields look in the editor. Never adds or retypes a field (use add_field). It removes one only with `removeFields` plus `allowFieldRemoval: true`, which DISCARDS its data in every entry and has no dry run; a page's fields go through remove_page_fields. Send modelId, or pageId for the model a page's schema lives in. `fieldsets` REPLACES the list (null drops all); a field joins one with fieldFieldset or add_field's `fieldsetId`. The field* args address fields by dotted key path ('hero.title'). Read with get_content_model, then pass `ifMatch` = the version from that read. A taken slug is 409 `slug_taken`. Section 14 of bettercms://playbook/schema.";
|
|
3094
|
+
var FIELD_REMOVAL_NOTE = "top-level field keys (from get_content_model) to DELETE from this model with every entry's value for them; needs allowFieldRemoval: true. An unknown key refuses the whole call. Never for a rename: rename the label instead.";
|
|
3095
|
+
var ALLOW_FIELD_REMOVAL_NOTE = "true = the user accepts that removeFields discards those fields' data in every entry. There is no dry run on this route: confirm first.";
|
|
2734
3096
|
var MODEL_IF_MATCH_NOTE = "optimisticVersion from get_content_model (with pageId: get_page backingModel). The write applies only at that version; a 409 version-conflict means it changed since: read it again and re-apply, never retry blindly. REQUIRED with field*, override or a fieldset kind/tab/icon/collapsed (428 without); otherwise, omitted, the last write wins.";
|
|
2735
3097
|
var COMPONENT_IF_MATCH_NOTE = "optimisticVersion from get_component. The write applies only at that version; a 409 version-conflict means it changed since: read it again and re-apply. REQUIRED with prop*, override or a prop ui icon/itemLabel/group (428 without).";
|
|
2736
3098
|
var PROP_UI_ARG_NOTE = "top-level prop key \u2192 ui patch, the same vocabulary as a prop's `ui`: each key merges, a null key clears it, null clears the whole ui";
|
|
@@ -2750,9 +3112,15 @@ var MODEL_UPDATE_KEYS = [
|
|
|
2750
3112
|
"override"
|
|
2751
3113
|
];
|
|
2752
3114
|
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.";
|
|
3115
|
+
var ENTRY_MERGE_NOTE = "true = keep the fields you leave out: the entry is read, `data` is laid over its stored data by top-level key (a group or repeater value replaces that whole key) and written at the version read. Default false: `data` REPLACES the whole object.";
|
|
3116
|
+
var ENTRY_IF_MATCH_NOTE = "optimisticVersion from get_content_entry: the write applies only at that version, and a 409 means it changed since: read it again and re-apply. With merge it defaults to the version merge read.";
|
|
3117
|
+
var ENTRY_SCHEDULED_AT_NOTE = "ISO datetime to publish this draft at (needs publish permission); null cancels. An edit that omits scheduledAt CANCELS a pending publish schedule, so send it again with every edit made before it fires.";
|
|
3118
|
+
var ENTRY_UNPUBLISH_AT_NOTE = "ISO datetime at which the published entry returns to draft; null cancels. Omitting it never cancels it.";
|
|
3119
|
+
var ENTRY_LIST_LIMIT_NOTE = "entries per page, 1-200; 50 when omitted. Read `meta.totalPages` and ask for the next `page`.";
|
|
2753
3120
|
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).";
|
|
2754
|
-
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 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 work on them. TYPE PURITY (400 NAV_TYPE_MISMATCH): Other pages holds only pages at any depth, Shared only collections, Settings neither \u2014 globals are not sidebar items. ACCESS: changing the structure needs Admin or Developer, reading only content access; a 403 SCHEMA_ACCESS_REQUIRED means neither: stop and tell the user, do not retry. PINS sit above the folders in their own order, 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 bound model that is NOT a collection: 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. ';
|
|
2755
3121
|
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.";
|
|
3122
|
+
var STRUCTURE_POINTER = "Sidebar only, never a URL; rules: bettercms://playbook/structure.";
|
|
3123
|
+
var BRAND_KIT_POINTER = "The brand kit's rules (consume its tokens, never copy values or invent brand facts) are on get_project.";
|
|
2756
3124
|
function structureResult(d) {
|
|
2757
3125
|
const message = d?.message;
|
|
2758
3126
|
return ok(typeof message === "string" ? message : "Updated the Content structure.", d);
|
|
@@ -2844,97 +3212,51 @@ function refusalDetail(err) {
|
|
|
2844
3212
|
${lines.join("\n")}` : "";
|
|
2845
3213
|
}
|
|
2846
3214
|
function authPrompt(err) {
|
|
2847
|
-
const
|
|
3215
|
+
const text2 = [
|
|
2848
3216
|
"\u{1F510} BetterCMS authorization required \u2014 you're not signed in yet.",
|
|
2849
3217
|
"",
|
|
2850
3218
|
`1. Open this link and approve: ${err.verificationUriComplete}`,
|
|
2851
3219
|
` (or visit ${err.verificationUri} and enter code ${err.userCode})`,
|
|
2852
3220
|
"2. Once approved, run this tool again \u2014 it resumes automatically and completes your request."
|
|
2853
3221
|
].join("\n");
|
|
2854
|
-
return { content: [{ type: "text", text }], isError: true };
|
|
3222
|
+
return { content: [{ type: "text", text: text2 }], isError: true };
|
|
2855
3223
|
}
|
|
2856
3224
|
var currentProject = new AsyncLocalStorage();
|
|
3225
|
+
var currentBranch = new AsyncLocalStorage();
|
|
2857
3226
|
var projectIdArg = z.string().min(1).optional().describe(
|
|
2858
3227
|
"target project id (from list_projects); only for a workspace-wide grant."
|
|
2859
3228
|
);
|
|
3229
|
+
var branchIdArg = z.string().min(1).optional().describe("content branch id (from list_branches); omit to work on main");
|
|
2860
3230
|
var withProjectId = (def) => ({
|
|
2861
3231
|
...def,
|
|
2862
|
-
config:
|
|
3232
|
+
config: {
|
|
3233
|
+
...def.config,
|
|
3234
|
+
inputSchema: {
|
|
3235
|
+
...def.config.inputSchema,
|
|
3236
|
+
...def.config.inputSchema.projectId ? {} : { projectId: projectIdArg },
|
|
3237
|
+
...def.config.inputSchema.branchId ? {} : { branchId: branchIdArg }
|
|
3238
|
+
}
|
|
3239
|
+
},
|
|
2863
3240
|
handler: (args) => {
|
|
2864
|
-
const
|
|
2865
|
-
return currentProject.run(
|
|
3241
|
+
const arg = (key) => typeof args?.[key] === "string" && args[key] ? args[key] : void 0;
|
|
3242
|
+
return currentProject.run(arg("projectId"), () => currentBranch.run(arg("branchId"), () => def.handler(args)));
|
|
2866
3243
|
}
|
|
2867
3244
|
});
|
|
2868
|
-
var LISTING_TAIL = /* @__PURE__ */ new Set([
|
|
2869
|
-
// The agent-job lane: a dashboard surface an agent rarely drives.
|
|
2870
|
-
"list_ai_jobs",
|
|
2871
|
-
"get_ai_job",
|
|
2872
|
-
"approve_ai_job",
|
|
2873
|
-
"reject_ai_job",
|
|
2874
|
-
"list_ai_reports",
|
|
2875
|
-
"connect_native_ai",
|
|
2876
|
-
"native_bridge_heartbeat",
|
|
2877
|
-
// Section evidence and component validation: a CI lane, not an authoring one.
|
|
2878
|
-
"claim_section_validation_request",
|
|
2879
|
-
"complete_section_validation_request",
|
|
2880
|
-
"fail_section_validation_request",
|
|
2881
|
-
"list_section_validation_requests",
|
|
2882
|
-
"submit_section_validation",
|
|
2883
|
-
"submit_section_manifest",
|
|
2884
|
-
"request_component_validation",
|
|
2885
|
-
"declare_component_route",
|
|
2886
|
-
"clear_component_source",
|
|
2887
|
-
"submit_componentize_receipt",
|
|
2888
|
-
// Reporting, history and workflow chrome.
|
|
2889
|
-
"get_analytics_overview",
|
|
2890
|
-
"get_analytics_top_pages",
|
|
2891
|
-
"list_activity",
|
|
2892
|
-
"get_workflow_board",
|
|
2893
|
-
"move_entry_stage",
|
|
2894
|
-
"move_page_stage",
|
|
2895
|
-
"list_entry_versions",
|
|
2896
|
-
"restore_entry_version",
|
|
2897
|
-
// One-off project operations and the extraction lane.
|
|
2898
|
-
"clone_project",
|
|
2899
|
-
"promote_project",
|
|
2900
|
-
"generate_pages_from_dataset",
|
|
2901
|
-
"extract_component",
|
|
2902
|
-
"list_extraction_candidates",
|
|
2903
|
-
"create_deploy_upload",
|
|
2904
|
-
"deploy_from_upload",
|
|
2905
|
-
"set_authoring_preference",
|
|
2906
|
-
"set_media_delivery",
|
|
2907
|
-
"delete_form_submission",
|
|
2908
|
-
"remove_page_fields",
|
|
2909
|
-
// The ABM campaign lane: a marketer's dashboard surface, plus the worker tools a task runner
|
|
2910
|
-
// claims with. Ten more tools than the tail was written for, and a connector that truncates
|
|
2911
|
-
// would otherwise lose `get_deploy_status` and the GitHub lane to make room for them.
|
|
2912
|
-
"research_account",
|
|
2913
|
-
"build_abm_page",
|
|
2914
|
-
"claim_abm_task",
|
|
2915
|
-
"complete_abm_task",
|
|
2916
|
-
"fail_abm_task",
|
|
2917
|
-
"get_campaign_review",
|
|
2918
|
-
"regenerate_entry",
|
|
2919
|
-
"approve_campaign_entries",
|
|
2920
|
-
"publish_campaign",
|
|
2921
|
-
"get_campaign_share_link",
|
|
2922
|
-
"revoke_campaign_share_link"
|
|
2923
|
-
]);
|
|
2924
3245
|
function buildToolDefs(deps) {
|
|
2925
3246
|
async function withClient(fn) {
|
|
2926
3247
|
const token = await deps.auth.getAccessToken();
|
|
2927
3248
|
const project = currentProject.getStore();
|
|
3249
|
+
const branch = currentBranch.getStore();
|
|
2928
3250
|
try {
|
|
2929
|
-
return await fn(deps.createClient(token, project));
|
|
3251
|
+
return await fn(deps.createClient(token, project, branch));
|
|
2930
3252
|
} catch (err) {
|
|
2931
3253
|
if (err instanceof BetterCMSError && err.status === 401) {
|
|
2932
3254
|
const next = await deps.auth.refresh() ?? await deps.auth.getAccessToken();
|
|
2933
|
-
return await fn(deps.createClient(next, project));
|
|
3255
|
+
return await fn(deps.createClient(next, project, branch));
|
|
2934
3256
|
}
|
|
2935
3257
|
if (err instanceof BetterCMSError && err.status === 409 && err.bodyCode === "PROJECT_DELETED") {
|
|
2936
3258
|
const next = await deps.auth.resetAndReauthorize();
|
|
2937
|
-
return await fn(deps.createClient(next, project));
|
|
3259
|
+
return await fn(deps.createClient(next, project, branch));
|
|
2938
3260
|
}
|
|
2939
3261
|
throw err;
|
|
2940
3262
|
}
|
|
@@ -2997,7 +3319,9 @@ function buildToolDefs(deps) {
|
|
|
2997
3319
|
"a SINGLETON's field VALUES keyed by field key \u2014 seeds its entry in the same call. If any value is refused the page is still created, none of `data` is saved, and the result names ENTRY_SEED_FAILED with the per-field reasons: fix them and write with set_page_content."
|
|
2998
3320
|
),
|
|
2999
3321
|
metaTitle: z.string().optional().describe("SEO meta title"),
|
|
3000
|
-
metaDescription: z.string().optional().describe("SEO meta description")
|
|
3322
|
+
metaDescription: z.string().optional().describe("SEO meta description"),
|
|
3323
|
+
locale: z.string().nullable().optional().describe(PARAM_TEXT.locale),
|
|
3324
|
+
translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf)
|
|
3001
3325
|
});
|
|
3002
3326
|
const writeContentInput = z.object({
|
|
3003
3327
|
action: z.enum(["write", "rewrite", "translate"]).describe("'write' = draft from a brief, 'rewrite' = improve existing copy, 'translate' = needs targetLang"),
|
|
@@ -3015,13 +3339,6 @@ function buildToolDefs(deps) {
|
|
|
3015
3339
|
"optional \u2014 any of inspect, propose, implement, cancel, question. Defaults to inspect+propose+cancel."
|
|
3016
3340
|
)
|
|
3017
3341
|
});
|
|
3018
|
-
const nativeBridgeHeartbeatInput = z.object({
|
|
3019
|
-
protocolVersion: z.string().min(1).describe("executor protocol version; currently '1'"),
|
|
3020
|
-
capabilities: z.array(z.string().min(1)).min(1).describe(
|
|
3021
|
-
"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."
|
|
3022
|
-
),
|
|
3023
|
-
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")
|
|
3024
|
-
});
|
|
3025
3342
|
const createModelInput = z.object({
|
|
3026
3343
|
name: z.string().min(1).describe("human model name, e.g. 'Blog Post'"),
|
|
3027
3344
|
slug: slug.describe("url-safe unique slug, e.g. 'blog-post'"),
|
|
@@ -3039,18 +3356,15 @@ function buildToolDefs(deps) {
|
|
|
3039
3356
|
"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."
|
|
3040
3357
|
);
|
|
3041
3358
|
const addFieldInput = z.object({
|
|
3042
|
-
modelId: z.string().min(1).describe("
|
|
3043
|
-
|
|
3044
|
-
after: afterArg
|
|
3045
|
-
});
|
|
3046
|
-
const addPageFieldInput = z.object({
|
|
3047
|
-
pageId: z.string().min(1).describe("id of the page to extend (from list_pages / create_page)"),
|
|
3359
|
+
modelId: z.string().min(1).optional().describe("the content model to extend (from list_content_models); or send pageId"),
|
|
3360
|
+
pageId: z.string().min(1).optional().describe("instead of modelId: the page whose schema to extend (from list_pages / create_page)"),
|
|
3048
3361
|
...fieldShape,
|
|
3049
3362
|
after: afterArg
|
|
3050
3363
|
});
|
|
3051
3364
|
const uploadAssetInput = z.object({
|
|
3052
3365
|
localPath: z.string().min(1).optional().describe("absolute path to a local file (e.g. a repo image); provide this OR url"),
|
|
3053
|
-
url: z.string().url().optional().describe("remote image URL
|
|
3366
|
+
url: z.string().url().optional().describe("remote image URL this client downloads and uploads; provide this OR localPath OR sourceUrl"),
|
|
3367
|
+
sourceUrl: z.string().url().optional().describe(PARAM_TEXT.sourceUrl),
|
|
3054
3368
|
filename: z.string().optional().describe("override the stored filename"),
|
|
3055
3369
|
altText: z.string().optional().describe("accessibility alt text"),
|
|
3056
3370
|
caption: z.string().optional(),
|
|
@@ -3061,7 +3375,9 @@ function buildToolDefs(deps) {
|
|
|
3061
3375
|
contentModelId: z.string().min(1).describe("id of the model this entry belongs to"),
|
|
3062
3376
|
slug: slug.optional(),
|
|
3063
3377
|
status: z.enum(["draft", "published"]).optional().describe("defaults to draft"),
|
|
3064
|
-
data: z.record(z.string(), z.unknown()).optional().describe("field values keyed by field key")
|
|
3378
|
+
data: z.record(z.string(), z.unknown()).optional().describe("field values keyed by field key"),
|
|
3379
|
+
locale: z.string().nullable().optional().describe(PARAM_TEXT.locale),
|
|
3380
|
+
translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf)
|
|
3065
3381
|
});
|
|
3066
3382
|
const getPageInput = z.object({
|
|
3067
3383
|
pageId: z.string().min(1).describe("page id (from list_pages / create_page)")
|
|
@@ -3080,7 +3396,11 @@ function buildToolDefs(deps) {
|
|
|
3080
3396
|
const listEntriesInput = z.object({
|
|
3081
3397
|
modelId: z.string().optional().describe("filter by content model id"),
|
|
3082
3398
|
pageId: z.string().optional().describe("filter by page id (a singleton page has one entry)"),
|
|
3083
|
-
status: z.enum(["draft", "published"]).optional()
|
|
3399
|
+
status: z.enum(["draft", "published"]).optional(),
|
|
3400
|
+
where: z.record(z.string(), z.unknown()).optional().describe(PARAM_TEXT.where),
|
|
3401
|
+
sort: z.string().max(100).optional().describe(PARAM_TEXT.sort),
|
|
3402
|
+
page: z.number().int().min(1).optional().describe(PARAM_TEXT.page),
|
|
3403
|
+
limit: z.number().int().min(1).max(200).optional().describe(ENTRY_LIST_LIMIT_NOTE)
|
|
3084
3404
|
});
|
|
3085
3405
|
const clearableText = (max) => z.string().max(max).nullable().optional();
|
|
3086
3406
|
const headPatchShape = {
|
|
@@ -3101,7 +3421,13 @@ function buildToolDefs(deps) {
|
|
|
3101
3421
|
data: z.record(z.string(), z.unknown()).optional().describe("field values keyed by field key"),
|
|
3102
3422
|
status: z.enum(["draft", "published"]).optional(),
|
|
3103
3423
|
slug: slug.optional(),
|
|
3104
|
-
meta: entryMetaInput.optional().describe("the entry's native SEO; merges into what is stored")
|
|
3424
|
+
meta: entryMetaInput.optional().describe("the entry's native SEO; merges into what is stored"),
|
|
3425
|
+
merge: z.boolean().optional().describe(ENTRY_MERGE_NOTE),
|
|
3426
|
+
ifMatch: z.number().int().min(0).optional().describe(ENTRY_IF_MATCH_NOTE),
|
|
3427
|
+
scheduledAt: z.string().datetime().nullable().optional().describe(ENTRY_SCHEDULED_AT_NOTE),
|
|
3428
|
+
unpublishAt: z.string().datetime().nullable().optional().describe(ENTRY_UNPUBLISH_AT_NOTE),
|
|
3429
|
+
locale: z.string().nullable().optional().describe(PARAM_TEXT.locale),
|
|
3430
|
+
translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf)
|
|
3105
3431
|
});
|
|
3106
3432
|
const deletePageInput = z.object({
|
|
3107
3433
|
pageId: z.string().min(1).describe("id of the page to delete (from list_pages)")
|
|
@@ -3298,7 +3624,7 @@ function buildToolDefs(deps) {
|
|
|
3298
3624
|
description: z.string().optional(),
|
|
3299
3625
|
blockJson: z.array(blockObject).default([]).describe("the component's block tree"),
|
|
3300
3626
|
props: z.array(componentPropObject).default([]).describe(
|
|
3301
|
-
"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."
|
|
3627
|
+
"overridable fields. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: only the leaves a prop targets are click-to-edit. Each one may carry `ui` to say how its control presents in the inspector \u2014 a collapsed group, what titles and thumbnails a table's rows, whether that table's rows can be dragged at all."
|
|
3302
3628
|
)
|
|
3303
3629
|
});
|
|
3304
3630
|
const updateComponentInput = z.object({
|
|
@@ -3347,14 +3673,6 @@ function buildToolDefs(deps) {
|
|
|
3347
3673
|
const getComponentInput = z.object({
|
|
3348
3674
|
componentId: z.string().min(1).describe("component id (from list_components)")
|
|
3349
3675
|
});
|
|
3350
|
-
const duplicateComponentInput = z.object({
|
|
3351
|
-
componentId: z.string().min(1).describe("the look to copy (from list_components)"),
|
|
3352
|
-
name: z.string().min(1).max(255).describe("the new look's name, unique in its family, e.g. 'Ghost'"),
|
|
3353
|
-
preset: z.enum(["primary", "secondary", "outline", "ghost", "link"]).optional().describe("restyle the copy's button (see above); omit for a plain copy")
|
|
3354
|
-
});
|
|
3355
|
-
const createComponentsInput = z.object({
|
|
3356
|
-
components: z.array(createComponentInput).min(1).max(50).describe("up to 50 components, each exactly the create_component input")
|
|
3357
|
-
});
|
|
3358
3676
|
const publishComponentsInput = z.object({
|
|
3359
3677
|
componentIds: z.array(z.string().min(1)).min(1).max(100).describe("component ids (from list_components)")
|
|
3360
3678
|
});
|
|
@@ -3379,83 +3697,6 @@ function buildToolDefs(deps) {
|
|
|
3379
3697
|
slug: slug.describe("url-safe unique slug (lowercase letters/numbers/hyphens)"),
|
|
3380
3698
|
projectId: z.string().min(1).optional().describe("only needed for a workspace-wide key")
|
|
3381
3699
|
});
|
|
3382
|
-
const sectionEvidenceProjectId = z.string().min(1).max(64).describe("exact project id from get_project (not a slug); Section evidence is project-local and never reusable across projects");
|
|
3383
|
-
const sectionId = z.string().uuid().describe("Section definition id from Section Studio");
|
|
3384
|
-
const sectionVersion = z.number().int().positive().describe("exact Section schema version implemented or tested");
|
|
3385
|
-
const sha256 = z.string().regex(/^[0-9a-f]{64}$/i, "Expected a SHA-256 hex digest");
|
|
3386
|
-
const sectionViewport = z.object({
|
|
3387
|
-
name: z.string().trim().min(1).max(64),
|
|
3388
|
-
width: z.number().int().min(240).max(7680),
|
|
3389
|
-
height: z.number().int().min(240).max(7680)
|
|
3390
|
-
});
|
|
3391
|
-
const sectionEvidenceDigest = z.string().regex(/^(?:sha256:)?[0-9a-f]{64}$/i, "Expected a SHA-256 digest");
|
|
3392
|
-
const nonEmptyDescriptor = z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, "Descriptor cannot be empty");
|
|
3393
|
-
const sectionRequestId = z.string().uuid().describe("durable Section validation request id returned by list_section_validation_requests or the dashboard");
|
|
3394
|
-
const sectionHttpUrl = z.string().url().max(2048).refine((value) => {
|
|
3395
|
-
try {
|
|
3396
|
-
const protocol = new URL(value).protocol;
|
|
3397
|
-
return protocol === "http:" || protocol === "https:";
|
|
3398
|
-
} catch {
|
|
3399
|
-
return false;
|
|
3400
|
-
}
|
|
3401
|
-
}, "Expected an HTTP or HTTPS URL");
|
|
3402
|
-
const sectionValidationViewport = sectionViewport.extend({
|
|
3403
|
-
status: z.enum(["passed", "failed"]),
|
|
3404
|
-
evidenceDigest: sectionEvidenceDigest
|
|
3405
|
-
});
|
|
3406
|
-
const submitSectionManifestInput = z.object({
|
|
3407
|
-
projectId: sectionEvidenceProjectId,
|
|
3408
|
-
requestId: sectionRequestId.optional().describe("include when fulfilling a claimed dashboard request so the evidence is bound to that exact run"),
|
|
3409
|
-
sectionId,
|
|
3410
|
-
version: sectionVersion,
|
|
3411
|
-
apiId: z.string().regex(/^[a-z][A-Za-z0-9]*$/, "Use lower camelCase").max(100),
|
|
3412
|
-
schemaHash: sha256.describe("exact SHA-256 hex hash shown for this Section version"),
|
|
3413
|
-
commitSha: z.string().regex(/^[0-9a-f]{7,64}$/i).describe("git commit containing the implementation"),
|
|
3414
|
-
loader: nonEmptyDescriptor.describe("non-empty serializable loader descriptor emitted by the repo build; never executable code"),
|
|
3415
|
-
previewAdapter: nonEmptyDescriptor.describe("non-empty serializable adapter descriptor for the customer's real app shell; never executable code"),
|
|
3416
|
-
nativeViewports: z.array(sectionViewport).max(20).optional().describe("the customer's named responsive viewports; BetterCMS also enforces Desktop 1440x900, Tablet 768x1024, and Mobile 390x844")
|
|
3417
|
-
});
|
|
3418
|
-
const submitSectionValidationInput = z.object({
|
|
3419
|
-
projectId: sectionEvidenceProjectId.describe("exact project id from get_project (not a slug); must match the manifest's project"),
|
|
3420
|
-
requestId: sectionRequestId.optional().describe("same claimed request id used for the manifest; binds and terminalizes that run"),
|
|
3421
|
-
sectionId: sectionId.describe("exact Section definition id"),
|
|
3422
|
-
version: sectionVersion,
|
|
3423
|
-
manifestId: z.string().uuid().describe("manifest id returned by submit_section_manifest; it pins the schema hash and commit SHA"),
|
|
3424
|
-
status: z.enum(["passed", "failed"]).describe("the actual result of the external repo/app-shell validation"),
|
|
3425
|
-
fixtureHash: sha256.describe("SHA-256 hex hash of the canonical preview dataset and stress fixtures used"),
|
|
3426
|
-
evidenceDigest: sectionEvidenceDigest.describe("SHA-256 digest of the immutable validation evidence bundle (screenshots/report/results)"),
|
|
3427
|
-
appShell: z.object({
|
|
3428
|
-
kind: z.literal("actual-app"),
|
|
3429
|
-
identifier: z.string().trim().min(1).max(255),
|
|
3430
|
-
url: sectionHttpUrl.optional()
|
|
3431
|
-
}).describe("the real customer application shell used for the validation run"),
|
|
3432
|
-
viewportResults: z.array(sectionValidationViewport).min(1).max(23).describe("one signed result for every required and native manifest viewport")
|
|
3433
|
-
});
|
|
3434
|
-
const listSectionValidationRequestsInput = z.object({
|
|
3435
|
-
projectId: sectionEvidenceProjectId,
|
|
3436
|
-
limit: z.number().int().min(1).max(100).optional()
|
|
3437
|
-
});
|
|
3438
|
-
const claimSectionValidationRequestInput = z.object({
|
|
3439
|
-
projectId: sectionEvidenceProjectId,
|
|
3440
|
-
requestId: sectionRequestId,
|
|
3441
|
-
commitSha: z.string().regex(/^[0-9a-f]{7,64}$/i).describe("exact git commit the agent will inspect and validate; evidence must use this same commit"),
|
|
3442
|
-
providerRunId: z.string().trim().min(1).max(255).optional(),
|
|
3443
|
-
providerRunUrl: sectionHttpUrl.optional().describe("optional HTTP(S) link to the user's external agent run")
|
|
3444
|
-
});
|
|
3445
|
-
const completeSectionValidationRequestInput = z.object({
|
|
3446
|
-
projectId: sectionEvidenceProjectId,
|
|
3447
|
-
requestId: sectionRequestId,
|
|
3448
|
-
manifestId: z.string().uuid().optional(),
|
|
3449
|
-
validationRunId: z.string().uuid().optional()
|
|
3450
|
-
});
|
|
3451
|
-
const failSectionValidationRequestInput = z.object({
|
|
3452
|
-
projectId: sectionEvidenceProjectId,
|
|
3453
|
-
requestId: sectionRequestId,
|
|
3454
|
-
errorCode: z.string().trim().regex(/^[A-Z][A-Z0-9_]*$/).max(100),
|
|
3455
|
-
errorMessage: z.string().trim().min(1).max(2e3),
|
|
3456
|
-
providerRunId: z.string().trim().min(1).max(255).optional(),
|
|
3457
|
-
providerRunUrl: sectionHttpUrl.optional().describe("optional HTTP(S) link to the user's external agent run")
|
|
3458
|
-
});
|
|
3459
3700
|
function lifecycleTools() {
|
|
3460
3701
|
const def = (name, title, description, shape, run) => ({
|
|
3461
3702
|
name,
|
|
@@ -3508,14 +3749,16 @@ function buildToolDefs(deps) {
|
|
|
3508
3749
|
def(
|
|
3509
3750
|
"update_media",
|
|
3510
3751
|
"Update a media asset",
|
|
3511
|
-
"Describe an existing media asset: set its alt text, caption or
|
|
3752
|
+
"Describe an existing media asset: set its alt text, caption, transcript or focal point (focalX/focalY, the spot crops keep in frame; delivered images carry it with width, height and thumbHash). A transcript (video/audio only, \u2264 100,000 chars) is stored ON the asset, so every page and entry using the file publishes it \u2014 hosted pages, the .md twin, llms-full.txt and the delivery API. Pass null to clear a field. Use this after your own speech-to-text; BetterCMS does not generate transcripts.",
|
|
3512
3753
|
z.object({
|
|
3513
3754
|
assetId: z.string().min(1).describe("media asset id (from list_media / upload_asset)"),
|
|
3514
3755
|
altText: z.string().max(500).nullable().optional().describe("alt text; null clears"),
|
|
3515
3756
|
caption: z.string().max(1e3).nullable().optional().describe("caption; null clears"),
|
|
3516
|
-
transcript: z.string().max(1e5).nullable().optional().describe("the spoken words; null clears. Video/audio only.")
|
|
3757
|
+
transcript: z.string().max(1e5).nullable().optional().describe("the spoken words; null clears. Video/audio only."),
|
|
3758
|
+
focalX: z.number().min(0).max(1).nullable().optional().describe(PARAM_TEXT.focalX),
|
|
3759
|
+
focalY: z.number().min(0).max(1).nullable().optional().describe(PARAM_TEXT.focalY)
|
|
3517
3760
|
}).shape,
|
|
3518
|
-
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 }))
|
|
3761
|
+
async (c, a) => ok("Updated media asset.", await data(c, "PATCH", `/management/media/${encodeURIComponent(s(a.assetId))}`, { altText: a.altText, caption: a.caption, transcript: a.transcript, focalX: a.focalX, focalY: a.focalY }))
|
|
3519
3762
|
),
|
|
3520
3763
|
def(
|
|
3521
3764
|
"list_media",
|
|
@@ -3563,7 +3806,7 @@ function buildToolDefs(deps) {
|
|
|
3563
3806
|
def(
|
|
3564
3807
|
"get_content_structure",
|
|
3565
3808
|
"Get the Content sidebar structure",
|
|
3566
|
-
`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. ${
|
|
3809
|
+
`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. Changing it needs Admin or Developer; a 403 SCHEMA_ACCESS_REQUIRED means neither: stop and tell the user, do not retry. ${STRUCTURE_FENCE}`,
|
|
3567
3810
|
z.object({}).shape,
|
|
3568
3811
|
async (c) => {
|
|
3569
3812
|
const d = await data(c, "GET", `/management/content-structure`);
|
|
@@ -3624,7 +3867,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3624
3867
|
def(
|
|
3625
3868
|
"rename_folder",
|
|
3626
3869
|
"Rename a Content sidebar folder",
|
|
3627
|
-
`Rename a Content sidebar folder. ${
|
|
3870
|
+
`Rename a Content sidebar folder. ${STRUCTURE_POINTER}`,
|
|
3628
3871
|
z.object({
|
|
3629
3872
|
folderId: z.string().min(1).describe("folder id (from get_content_structure)"),
|
|
3630
3873
|
name: z.string().min(1).describe("the new label, 1-80 chars")
|
|
@@ -3634,7 +3877,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3634
3877
|
def(
|
|
3635
3878
|
"move_to_folder",
|
|
3636
3879
|
"Move into a Content sidebar folder",
|
|
3637
|
-
`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. ${
|
|
3880
|
+
`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_POINTER}`,
|
|
3638
3881
|
z.object({
|
|
3639
3882
|
kind: z.enum(["page", "collection", "folder"]).describe(
|
|
3640
3883
|
"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."
|
|
@@ -3647,7 +3890,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3647
3890
|
def(
|
|
3648
3891
|
"set_brand_assets",
|
|
3649
3892
|
"Point the brand's logo or favicon at a media asset",
|
|
3650
|
-
`Point the brand's logo (mark) or favicon at a media asset of this project \u2014 upload it to this project's media library first if it is not there. A slot a PERSON chose is refused without replace: true, and the refusal says so. ${
|
|
3893
|
+
`Point the brand's logo (mark) or favicon at a media asset of this project \u2014 upload it to this project's media library first if it is not there. A slot a PERSON chose is refused without replace: true, and the refusal says so. ${BRAND_KIT_POINTER}`,
|
|
3651
3894
|
z.object({
|
|
3652
3895
|
mark: z.string().min(1).nullable().optional().describe("media asset id for the logo; null clears it"),
|
|
3653
3896
|
favicon: z.string().min(1).nullable().optional().describe("media asset id for the favicon; null clears it"),
|
|
@@ -3658,7 +3901,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3658
3901
|
def(
|
|
3659
3902
|
"set_brand_graphics",
|
|
3660
3903
|
"Add, replace or remove the brand's gradients",
|
|
3661
|
-
`Add, replace or remove the brand's gradients \u2014 the blobs and washes a site uses as backgrounds. They are TOKENS, not media. A stop naming a colour the kit does not have is refused; add it to \`extras\` first. Upsert matches BY KEY. ${
|
|
3904
|
+
`Add, replace or remove the brand's gradients \u2014 the blobs and washes a site uses as backgrounds. They are TOKENS, not media. A stop naming a colour the kit does not have is refused; add it to \`extras\` first. Upsert matches BY KEY. ${BRAND_KIT_POINTER}`,
|
|
3662
3905
|
z.object({
|
|
3663
3906
|
upsert: z.array(z.object({
|
|
3664
3907
|
key: z.string().min(1).describe("token key: lowercase letters, digits and hyphens. Emitted as --brand-gradient-<key>"),
|
|
@@ -3677,7 +3920,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3677
3920
|
def(
|
|
3678
3921
|
"set_icon",
|
|
3679
3922
|
"Set a Content sidebar icon",
|
|
3680
|
-
`Set or clear the sidebar icon of a page, a collection or a folder. ${
|
|
3923
|
+
`Set or clear the sidebar icon of a page, a collection or a folder. ${STRUCTURE_POINTER}`,
|
|
3681
3924
|
z.object({
|
|
3682
3925
|
kind: z.enum(["page", "collection", "folder"]).describe(
|
|
3683
3926
|
"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."
|
|
@@ -3690,7 +3933,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3690
3933
|
def(
|
|
3691
3934
|
"delete_folder",
|
|
3692
3935
|
"Delete a folder (its contents move up; nothing is deleted)",
|
|
3693
|
-
`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. ${
|
|
3936
|
+
`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_POINTER}`,
|
|
3694
3937
|
z.object({ folderId: z.string().min(1).describe("folder id (from get_content_structure)") }).shape,
|
|
3695
3938
|
async (c, a) => structureResult(await data(c, "DELETE", `/management/content-structure/folders/${encodeURIComponent(s(a.folderId))}`))
|
|
3696
3939
|
),
|
|
@@ -3725,16 +3968,16 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3725
3968
|
def(
|
|
3726
3969
|
"get_seo",
|
|
3727
3970
|
"Get site SEO",
|
|
3728
|
-
|
|
3971
|
+
GET_SEO_DESCRIPTION,
|
|
3729
3972
|
z.object({}).shape,
|
|
3730
3973
|
async (c) => ok("SEO settings.", await data(c, "GET", `/management/seo`))
|
|
3731
3974
|
),
|
|
3732
3975
|
def(
|
|
3733
3976
|
"update_seo",
|
|
3734
3977
|
"Update site SEO",
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
async (c, a) => ok("Updated SEO.", await data(c, "PATCH", `/management/seo`, a))
|
|
3978
|
+
UPDATE_SEO_DESCRIPTION,
|
|
3979
|
+
seoPatchShape,
|
|
3980
|
+
async (c, a) => ok("Updated SEO.", await data(c, "PATCH", `/management/seo`, Object.fromEntries(Object.keys(seoPatchShape).filter((k) => a[k] !== void 0).map((k) => [k, a[k]]))))
|
|
3738
3981
|
),
|
|
3739
3982
|
def(
|
|
3740
3983
|
"get_site_files",
|
|
@@ -3765,23 +4008,25 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3765
4008
|
def(
|
|
3766
4009
|
"get_workflow_board",
|
|
3767
4010
|
"List editorial workflow stages",
|
|
3768
|
-
"List the connected project's editorial board columns in order \u2014 each stage's `key`, name, and whether it is the `publishGate` (the one stage content may go live from). Read this BEFORE
|
|
4011
|
+
"List the connected project's editorial board columns in order \u2014 each stage's `key`, name, and whether it is the `publishGate` (the one stage content may go live from). Read this BEFORE move_stage: stage keys are per-project and not guessable, because a project can rename or replace every column.",
|
|
3769
4012
|
z.object({}).shape,
|
|
3770
4013
|
async (c) => ok("Workflow stages.", await data(c, "GET", `/management/workflow/stages`))
|
|
3771
4014
|
),
|
|
3772
4015
|
def(
|
|
3773
|
-
"
|
|
3774
|
-
"Move a
|
|
3775
|
-
|
|
3776
|
-
z.object({
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
4016
|
+
"move_stage",
|
|
4017
|
+
"Move an entry or a page across the board",
|
|
4018
|
+
MOVE_STAGE_DESCRIPTION,
|
|
4019
|
+
z.object({
|
|
4020
|
+
entryId: z.string().min(1).optional().describe("the entry to move; or send pageId"),
|
|
4021
|
+
pageId: z.string().min(1).optional().describe("instead of entryId: the page to move"),
|
|
4022
|
+
workflowStage: z.string().max(64).describe("stage key from get_workflow_board"),
|
|
4023
|
+
fromStage: z.string().max(64).optional().describe("the stage you believe it is in \u2014 409s if it has moved")
|
|
4024
|
+
}).shape,
|
|
4025
|
+
async (c, a) => {
|
|
4026
|
+
if (!a.entryId === !a.pageId) return fail("Pass exactly one of entryId (a content entry) or pageId (a page).");
|
|
4027
|
+
const path = a.pageId ? `/management/pages/${s(a.pageId)}/workflow` : `/management/content/entries/${s(a.entryId)}/workflow`;
|
|
4028
|
+
return ok(a.pageId ? "Moved page." : "Moved entry.", await data(c, "PATCH", path, { workflowStage: a.workflowStage, fromStage: a.fromStage }));
|
|
4029
|
+
}
|
|
3785
4030
|
),
|
|
3786
4031
|
def(
|
|
3787
4032
|
"promote_project",
|
|
@@ -3819,6 +4064,48 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3819
4064
|
z.object({}).shape,
|
|
3820
4065
|
async (c) => ok("Projects.", await data(c, "GET", `/management/projects`))
|
|
3821
4066
|
),
|
|
4067
|
+
{
|
|
4068
|
+
name: "whoami",
|
|
4069
|
+
config: { title: "Who this connection acts as", description: WHOAMI_DESCRIPTION, inputSchema: {} },
|
|
4070
|
+
/**
|
|
4071
|
+
* The stdio server holds a device-grant token and nothing that lists its grants, so it
|
|
4072
|
+
* reports only what it can observe: whether GET /projects/current resolves a project with
|
|
4073
|
+
* none named (a project-bound grant) or refuses with 400 (a workspace-wide one). The
|
|
4074
|
+
* lifted `projectId`, when sent, is checked separately — it must not decide the scope.
|
|
4075
|
+
*/
|
|
4076
|
+
handler: guard(async () => {
|
|
4077
|
+
const requested = currentProject.getStore();
|
|
4078
|
+
const current = (c) => data(c, "GET", `/management/projects/current`);
|
|
4079
|
+
const bound = await currentProject.run(
|
|
4080
|
+
void 0,
|
|
4081
|
+
() => withClient(async (c) => {
|
|
4082
|
+
try {
|
|
4083
|
+
return { apiBaseUrl: c.url(""), project: await current(c) };
|
|
4084
|
+
} catch (err) {
|
|
4085
|
+
if (err instanceof BetterCMSError && err.status === 400) return { apiBaseUrl: c.url(""), project: null };
|
|
4086
|
+
throw err;
|
|
4087
|
+
}
|
|
4088
|
+
})
|
|
4089
|
+
);
|
|
4090
|
+
const target = requested ? await withClient(current) : null;
|
|
4091
|
+
const pick = (p) => p ? { id: p.id, name: p.name, slug: p.slug } : null;
|
|
4092
|
+
const scope = bound.project ? "project" : "workspace";
|
|
4093
|
+
return ok(
|
|
4094
|
+
bound.project ? `Bound to project '${bound.project.slug}' through a device grant.` : "A workspace-wide device grant: pass projectId on every project tool.",
|
|
4095
|
+
{
|
|
4096
|
+
surface: "stdio",
|
|
4097
|
+
principal: { kind: "device_grant" },
|
|
4098
|
+
apiBaseUrl: bound.apiBaseUrl,
|
|
4099
|
+
scope,
|
|
4100
|
+
workspaceSlug: (bound.project ?? target)?.workspaceSlug ?? null,
|
|
4101
|
+
project: pick(bound.project),
|
|
4102
|
+
...requested ? { requestedProject: pick(target) } : {},
|
|
4103
|
+
grants: null,
|
|
4104
|
+
note: "This server cannot see the connection's grants; a missing one is a 403 naming it on the call that needs it. The hosted connector's whoami lists them."
|
|
4105
|
+
}
|
|
4106
|
+
);
|
|
4107
|
+
})
|
|
4108
|
+
},
|
|
3822
4109
|
def(
|
|
3823
4110
|
"update_project",
|
|
3824
4111
|
"Update the connected project",
|
|
@@ -3848,7 +4135,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3848
4135
|
def(
|
|
3849
4136
|
"set_authoring_preference",
|
|
3850
4137
|
"Set the site's authoring architecture",
|
|
3851
|
-
"Record which authoring architecture this site uses \u2014 'components' (RECOMMENDED: reusable section components placed as blocks; editors add, reorder and swap sections without touching a schema \u2014 recommend it for every marketing, landing, agency or product site) or 'fields' (a typed field schema per page \u2014 only for a blog, catalogue or directory where many rows share one shape). ASK THE USER; do not pick for them. Called without `preference`, this tool asks them directly (or hands you the question to ask). deploy_project
|
|
4138
|
+
"Record which authoring architecture this site uses \u2014 'components' (RECOMMENDED: reusable section components placed as blocks; editors add, reorder and swap sections without touching a schema \u2014 recommend it for every marketing, landing, agency or product site) or 'fields' (a typed field schema per page \u2014 only for a blog, catalogue or directory where many rows share one shape). ASK THE USER; do not pick for them. Called without `preference`, this tool asks them directly (or hands you the question to ask). deploy_project and promote_project both refuse with 409 AUTHORING_DECISION_REQUIRED until it is set, carrying this project's real page counts to show the user. Unset, the release lane already acts as `components`; only `fields` stops it. On a site whose pages were DERIVED at import, answering 'components' redoes nothing: after set_authoring_preference, get_componentize_plan proposes one component per section (it reads the PUBLISHED pages \u2014 no deploy needed to reach it), you confirm it with the user, then componentize_sections places them with `props.bind` (dry run first \u2014 they land as drafts and the page keeps its fields, values and bindings, so click-to-edit and the coverage meter are unchanged), then publish_component each one, publish the pages, run `npx @bettercms-ai/convert --componentize` in the repo, and deploy. Asked once per project; re-callable if the user changes their mind.",
|
|
3852
4139
|
// Optional in the schema for exactly the reason `framework` is above: a required arg is
|
|
3853
4140
|
// rejected by the SDK before the handler runs, which would kill the elicitation below
|
|
3854
4141
|
// and leave the model guessing. Optional here, answered by a human there. The backend
|
|
@@ -3892,7 +4179,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3892
4179
|
def(
|
|
3893
4180
|
"submit_conversion_receipt",
|
|
3894
4181
|
"Record what the conversion codemod could and could not do",
|
|
3895
|
-
"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 --receipt out.json` wrote as `{ briefDigest, receipt }`, for the SAME `briefDigest` get_conversion_brief { complete: true } returned (its shape: see `receipt`). \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: the response answers `complete` (`sourceComplete` is the receipt's own verdict; `liveChecks` gives the last release's `structureUnstamped` route count and `reportSha`) and `pendingTotal`, and echoes the first 40 pending rows with their `fix` \u2014 `{ action, file, line, col?, snippet, why?, kind? }`, `action` one of `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data`, `manual`; `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: only `complete: true` ends a conversion \u2014 never report a site converted on less. It is a RECORD, not a release: nothing on the site changes; the meter reads it on the get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here. \u{1F534}
|
|
4182
|
+
"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 --receipt out.json` wrote as `{ briefDigest, receipt }`, for the SAME `briefDigest` get_conversion_brief { complete: true } returned (its shape: see `receipt`). \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: the response answers `complete` (`sourceComplete` is the receipt's own verdict; `liveChecks` gives the last release's `structureUnstamped` route count and `reportSha`) and `pendingTotal`, and echoes the first 40 pending rows with their `fix` \u2014 `{ action, file, line, col?, snippet, why?, kind? }`, `action` one of `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data`, `manual`; `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: only `complete: true` ends a conversion \u2014 never report a site converted on less. It is a RECORD, not a release: nothing on the site changes; the meter reads it on the get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here. \u{1F534} The same run owes two more things (playbook \xA713 steps 2b and 3b): the `--forms` receipt, submitted as a SECOND call under the same `briefDigest`, and the `styleEdits` of `npx @bettercms-ai/convert --verify-styles` inside this receipt \u2014 one listing a changed class, className or style is refused with 409 `style-edits`. Requires artifact:write.",
|
|
3896
4183
|
z.object({
|
|
3897
4184
|
briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
|
|
3898
4185
|
receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim: `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message, fix }] }` plus `styleEdits`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length; each pending `reason` is the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026); a path with no row reads `not-declared`.")
|
|
@@ -3906,20 +4193,6 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3906
4193
|
z.object({ sourceProjectId: z.string().optional(), name: z.string().optional(), slug: z.string().optional() }).shape,
|
|
3907
4194
|
async (c, a) => ok("Cloned project.", await data(c, "POST", `/management/projects/clone`, a))
|
|
3908
4195
|
),
|
|
3909
|
-
def(
|
|
3910
|
-
"create_template",
|
|
3911
|
-
"Save a project/page as a template",
|
|
3912
|
-
"Save a project (or one page) as a REUSABLE template \u2014 a frozen snapshot of its content models, pages, entries, components, and forms (secrets/domains/analytics excluded). Later seed a new project from it with create_project { templateId }. Set visibility 'public' to list it in the cross-workspace gallery. Returns the new template's id.",
|
|
3913
|
-
z.object({ sourceProjectId: z.string().min(1), name: z.string().min(1), scope: z.string().optional(), sourcePageId: z.string().optional(), description: z.string().optional(), visibility: z.string().optional() }).shape,
|
|
3914
|
-
async (c, a) => ok("Created template.", await data(c, "POST", `/management/templates`, a))
|
|
3915
|
-
),
|
|
3916
|
-
def(
|
|
3917
|
-
"list_templates",
|
|
3918
|
-
"List saved templates",
|
|
3919
|
-
"List your workspace's saved templates (id, name, scope, visibility). Use a template's id as create_project { templateId } to seed a new project from it.",
|
|
3920
|
-
z.object({}).shape,
|
|
3921
|
-
async (c) => ok("Templates.", await data(c, "GET", `/management/templates`))
|
|
3922
|
-
),
|
|
3923
4196
|
// ── Content models (read/metadata; parity with remote /mcp) ──
|
|
3924
4197
|
def(
|
|
3925
4198
|
"list_content_models",
|
|
@@ -3931,7 +4204,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3931
4204
|
def(
|
|
3932
4205
|
"get_content_model",
|
|
3933
4206
|
"Get a content model",
|
|
3934
|
-
"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.",
|
|
4207
|
+
"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. Its `optimisticVersion` is the ifMatch for update_content_model.",
|
|
3935
4208
|
z.object({ modelId: z.string().min(1) }).shape,
|
|
3936
4209
|
async (c, a) => ok("Content model.", await data(c, "GET", `/management/content/models/${s(a.modelId)}`))
|
|
3937
4210
|
),
|
|
@@ -3953,21 +4226,34 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3953
4226
|
fieldLabel: z.record(z.string(), z.string().min(1).max(255)).optional().describe("path \u2192 label"),
|
|
3954
4227
|
fieldHelpText: z.record(z.string(), z.string().max(500).nullable()).optional().describe("path \u2192 help text, null clears it"),
|
|
3955
4228
|
override: z.boolean().optional().describe(OVERRIDE_NOTE),
|
|
3956
|
-
ifMatch: z.number().int().min(0).optional().describe(MODEL_IF_MATCH_NOTE)
|
|
4229
|
+
ifMatch: z.number().int().min(0).optional().describe(MODEL_IF_MATCH_NOTE),
|
|
4230
|
+
removeFields: z.array(z.string().min(1)).min(1).max(100).optional().describe(FIELD_REMOVAL_NOTE),
|
|
4231
|
+
allowFieldRemoval: z.boolean().optional().describe(ALLOW_FIELD_REMOVAL_NOTE)
|
|
3957
4232
|
}).shape,
|
|
3958
|
-
// `fields` is
|
|
4233
|
+
// `fields` is sent only by removeFields, rebuilt from the model just read: the PATCH
|
|
4234
|
+
// replaces `fields` wholesale when present.
|
|
3959
4235
|
async (c, a) => {
|
|
3960
4236
|
if (Boolean(a.modelId) === Boolean(a.pageId)) return fail("Send exactly one of modelId or pageId.");
|
|
3961
4237
|
const path = a.pageId ? `/management/pages/${s(a.pageId)}/model` : `/management/content/models/${s(a.modelId)}`;
|
|
3962
4238
|
const body = Object.fromEntries(
|
|
3963
4239
|
MODEL_UPDATE_KEYS.filter((k) => a[k] !== void 0).map((k) => [k, k === "fieldsets" ? dropProvenance(a[k]) : a[k]])
|
|
3964
4240
|
);
|
|
4241
|
+
let ifMatch = a.ifMatch;
|
|
4242
|
+
const removal = fieldRemovalRequest(a);
|
|
4243
|
+
if (removal.kind === "refuse") return fail(removal.error);
|
|
4244
|
+
if (removal.kind === "remove") {
|
|
4245
|
+
const plan = planFieldRemoval(await data(c, "GET", path), removal.keys, a.ifMatch);
|
|
4246
|
+
if (!plan.ok) return fail(plan.error);
|
|
4247
|
+
body.fields = plan.fields;
|
|
4248
|
+
body.allowFieldRemoval = true;
|
|
4249
|
+
ifMatch = plan.ifMatch;
|
|
4250
|
+
}
|
|
3965
4251
|
return ok(
|
|
3966
4252
|
"Updated content model.",
|
|
3967
4253
|
(await c.fetchJSON(c.url(path), {
|
|
3968
4254
|
method: "PATCH",
|
|
3969
4255
|
body: JSON.stringify(body),
|
|
3970
|
-
...
|
|
4256
|
+
...ifMatch !== void 0 ? { headers: { "if-match": `W/"${ifMatch}"` } } : {}
|
|
3971
4257
|
})).data
|
|
3972
4258
|
);
|
|
3973
4259
|
}
|
|
@@ -4002,14 +4288,15 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
4002
4288
|
def(
|
|
4003
4289
|
"update_page",
|
|
4004
4290
|
"Edit a page",
|
|
4005
|
-
"Edit a page: title, slug, SEO metaTitle/metaDescription, structured data (`schemaType`, `schema`), publish status (draft|published), and `blockJson` (its block composition \u2014 passing it REPLACES the whole array, so read get_page first). A `blockJson` that swaps a component block to another component comes back with `warnings` (COMPONENT_SWAP_DROPS_OVERRIDES) naming the overrides the new component does not render; they stay on the block, so swapping back restores them. It does NOT change the field SCHEMA \u2014 use
|
|
4006
|
-
z.object({ ...headPatchShape, noindex: z.boolean().optional().describe("hide this page from search engines and site search"), pageId: z.string().min(1), title: z.string().optional(), slug: z.string().optional(), blockJson: z.array(blockObject).optional().describe("REPLACES the page's block composition"), metaTitle: z.string().optional(), metaDescription: z.string().optional(), schemaType: z.enum(["auto", "WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness"]).optional().describe("structured data type; 'auto' (the default) derives it from the content"), schema: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).nullable().optional().describe("custom JSON-LD; wins over schemaType; null clears it"), status: z.enum(["draft", "published"]).optional() }).shape,
|
|
4291
|
+
"Edit a page: title, slug, SEO metaTitle/metaDescription, structured data (`schemaType`, `schema`), publish status (draft|published), and `blockJson` (its block composition \u2014 passing it REPLACES the whole array, so read get_page first and send its `optimisticVersion` as `ifMatch`). A `blockJson` that swaps a component block to another component comes back with `warnings` (COMPONENT_SWAP_DROPS_OVERRIDES) naming the overrides the new component does not render; they stay on the block, so swapping back restores them. It does NOT change the field SCHEMA \u2014 use add_field (pageId) / set_page_content for that. Renaming the slug keeps content intact. Publishing copies the draft blocks live in the same call \u2014 and on a singleton it publishes the page's content entry too; if the entry's workflow stage refuses, the page still goes live and `warnings` carries ENTRY_STILL_DRAFT with the entry id and the reason. STRUCTURED DATA is native SEO, never a field: leave it out and the page gets Automatic JSON-LD from its content (WebSite on the home page, Blog/CollectionPage on a collection's list page, WebPage otherwise); `schemaType` picks an explicit schema.org type whose properties are filled from the content; `schema` is pasted JSON-LD and wins over both (null clears it). " + PAGE_HEAD_NOTE + " " + SECTION_DOCTRINE,
|
|
4292
|
+
z.object({ ...headPatchShape, noindex: z.boolean().optional().describe("hide this page from search engines and site search"), pageId: z.string().min(1), title: z.string().optional(), slug: z.string().optional(), blockJson: z.array(blockObject).optional().describe("REPLACES the page's block composition"), metaTitle: z.string().optional(), metaDescription: z.string().optional(), schemaType: z.enum(["auto", "WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness"]).optional().describe("structured data type; 'auto' (the default) derives it from the content"), schema: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).nullable().optional().describe("custom JSON-LD; wins over schemaType; null clears it"), status: z.enum(["draft", "published"]).optional(), scheduledAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageScheduledAt), unpublishAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageUnpublishAt), locale: z.string().nullable().optional().describe(PARAM_TEXT.locale), translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf), ifMatch: z.number().int().min(0).optional().describe(UPDATE_PAGE_IF_MATCH_NOTE) }).shape,
|
|
4007
4293
|
async (c, a) => {
|
|
4008
4294
|
const res = await c.fetchJSON(
|
|
4009
4295
|
c.url(`/management/pages/${s(a.pageId)}/meta`),
|
|
4010
4296
|
{
|
|
4011
4297
|
method: "PATCH",
|
|
4012
|
-
|
|
4298
|
+
...a.ifMatch !== void 0 ? { headers: { "If-Match": `W/"${a.ifMatch}"` } } : {},
|
|
4299
|
+
body: JSON.stringify({ title: a.title, slug: a.slug, blockJson: a.blockJson, metaTitle: a.metaTitle, metaDescription: a.metaDescription, schemaType: a.schemaType, schema: a.schema, status: a.status, noindex: a.noindex, canonical: a.canonical, og: a.og, twitter: a.twitter, scheduledAt: a.scheduledAt, unpublishAt: a.unpublishAt, locale: a.locale, translationOf: a.translationOf })
|
|
4013
4300
|
}
|
|
4014
4301
|
);
|
|
4015
4302
|
const note = res.redirectNote;
|
|
@@ -4031,24 +4318,24 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4031
4318
|
def(
|
|
4032
4319
|
"deploy_project",
|
|
4033
4320
|
"Deploy new source/build",
|
|
4034
|
-
"Deploy new source/build for the connected project and make it live at its <handle>.bettercms.site. Pass a .tgz or .zip of the project as a base64 string in `data
|
|
4035
|
-
z.object({
|
|
4036
|
-
|
|
4321
|
+
"Deploy new source/build for the connected project and make it live at its <handle>.bettercms.site. Pass a .tgz or .zip of the project as a base64 string in `data`, or the `uploadKey` of an archive you PUT through create_deploy_upload (no size ceiling: use it for anything large, or after a 413/502). SOURCE (has package.json) is built server-side in an isolated sandbox; a prebuilt static site is served as-is. Returns the release id + sha \u2014 then poll get_deploy_status until it is live. IMPORTANT \u2014 you MUST exclude node_modules, .git and build output/caches (dist, build, .next, .astro, .cache) from the archive: the source is reinstalled and built server-side, and the ~100 MB upload ceiling is enforced in transit (a 413/502 with no server-side detail) \u2014 server-side stripping runs AFTER the upload and cannot rescue an over-limit body. Returns `canvas.lane` (the editor's live-preview lane: `bridge` / `draft-route` / `none`; playbook section 11) and `editorUrl`, the visual editor to hand the user. On a workspace-wide connection pass `projectId` (from list_projects) to say which site this ships to.",
|
|
4322
|
+
z.object({
|
|
4323
|
+
data: z.string().min(1).optional().describe("base64 .tgz/.zip of the project; or send uploadKey"),
|
|
4324
|
+
uploadKey: z.string().min(1).optional().describe("instead of data: the uploadKey from create_deploy_upload, after PUTting the archive to its uploadUrl"),
|
|
4325
|
+
mimeType: z.string().optional()
|
|
4326
|
+
}).shape,
|
|
4327
|
+
async (c, a) => {
|
|
4328
|
+
if (!a.data === !a.uploadKey) return fail("Pass exactly one of data (the archive, base64) or uploadKey (from create_deploy_upload).");
|
|
4329
|
+
return ok("Deploy queued.", a.uploadKey ? await data(c, "POST", `/management/projects/deploy/from-upload`, { uploadKey: a.uploadKey }) : await raw(c, `/management/projects/deploy`, s(a.data), a.mimeType));
|
|
4330
|
+
}
|
|
4037
4331
|
),
|
|
4038
4332
|
def(
|
|
4039
4333
|
"create_deploy_upload",
|
|
4040
4334
|
"Get a presigned deploy-upload URL",
|
|
4041
|
-
'Get a presigned URL to upload a LARGE deploy archive directly to storage, bypassing the ~100 MB body limit deploy_project\'s inline `data` hits. Flow: (1) call this for `uploadUrl` + `uploadKey`; (2) PUT your .tgz/.zip to `uploadUrl` (e.g. `curl -X PUT --data-binary @archive.tgz "<uploadUrl>"`) \u2014 straight to storage, no size ceiling; (3) call
|
|
4335
|
+
'Get a presigned URL to upload a LARGE deploy archive directly to storage, bypassing the ~100 MB body limit deploy_project\'s inline `data` hits. Flow: (1) call this for `uploadUrl` + `uploadKey`; (2) PUT your .tgz/.zip to `uploadUrl` (e.g. `curl -X PUT --data-binary @archive.tgz "<uploadUrl>"`) \u2014 straight to storage, no size ceiling; (3) call deploy_project with `uploadKey`. Still exclude node_modules/.git/build caches. URL expires in 10 minutes.',
|
|
4042
4336
|
z.object({}).shape,
|
|
4043
4337
|
async (c) => ok("Upload URL.", await data(c, "POST", `/management/projects/deploy/upload-url`))
|
|
4044
4338
|
),
|
|
4045
|
-
def(
|
|
4046
|
-
"deploy_from_upload",
|
|
4047
|
-
"Deploy from a staged upload",
|
|
4048
|
-
"Deploy from an archive already uploaded via create_deploy_upload (step 3). Pass the `uploadKey` you received. Builds SOURCE server-side or serves a prebuilt site \u2014 then poll get_deploy_status until live.",
|
|
4049
|
-
z.object({ uploadKey: z.string().min(1).describe("the uploadKey from create_deploy_upload, after PUTting the archive to its uploadUrl") }).shape,
|
|
4050
|
-
async (c, a) => ok("Deploy queued.", await data(c, "POST", `/management/projects/deploy/from-upload`, { uploadKey: a.uploadKey }))
|
|
4051
|
-
),
|
|
4052
4339
|
// ── Git (import a repository, read it, push commits back) ──
|
|
4053
4340
|
// Same artifact:write authority as the deploy tools: on the build branch a push IS a
|
|
4054
4341
|
// deploy. Every call runs server-side as the GitHub App installation, so the agent
|
|
@@ -4145,20 +4432,228 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4145
4432
|
async (c, a) => ok("Pull requests.", await data(c, "GET", `/management/git/pulls${q({ state: a.state, limit: a.limit })}`))
|
|
4146
4433
|
),
|
|
4147
4434
|
// ── Insight: what happened, what's working, what's broken ─────────────────
|
|
4435
|
+
def(
|
|
4436
|
+
"update_field",
|
|
4437
|
+
"Change one field of a model or a page",
|
|
4438
|
+
UPDATE_FIELD_DESCRIPTION,
|
|
4439
|
+
z.object({
|
|
4440
|
+
modelId: z.string().min(1).optional().describe("the content model (from list_content_models); or send pageId"),
|
|
4441
|
+
pageId: z.string().min(1).optional().describe("instead of modelId: the page whose schema to edit"),
|
|
4442
|
+
path: z.string().min(1).describe("the field's key, or dot-separated keys through its parents, e.g. 'hero.title'"),
|
|
4443
|
+
label: z.string().min(1).optional(),
|
|
4444
|
+
helpText: z.string().nullable().optional().describe("null clears it"),
|
|
4445
|
+
required: z.boolean().optional(),
|
|
4446
|
+
config: z.record(z.string(), z.unknown()).optional().describe("per-type validation/config keys, merged into the stored config; null deletes a key"),
|
|
4447
|
+
showIf: z.record(z.string(), z.unknown()).nullable().optional().describe("same shape as on add_field; null clears it"),
|
|
4448
|
+
ui: z.record(z.string(), z.unknown()).nullable().optional().describe("same vocabulary as on add_field; null clears it"),
|
|
4449
|
+
placement: z.enum(["cover", "title", "excerpt", "body", "inline", "panel"]).nullable().optional(),
|
|
4450
|
+
type: fieldType.optional().describe("a new type; needs allowRetype"),
|
|
4451
|
+
key: fieldKey.optional().describe("a new key (flat namespace, unique in the schema)"),
|
|
4452
|
+
allowRetype: z.boolean().optional().describe("confirm a type change, once the user has agreed"),
|
|
4453
|
+
allowFieldRemoval: z.boolean().optional().describe("with a rename: discard the old key's stored values"),
|
|
4454
|
+
ifMatch: z.number().int().min(0).optional().describe("the model's optimisticVersion; the version read when omitted")
|
|
4455
|
+
}).shape,
|
|
4456
|
+
// Read the model (a page's through its backingModel), patch ONE field with the helper the
|
|
4457
|
+
// hosted catalogue uses, and PATCH the whole list at the version read.
|
|
4458
|
+
async (c, a) => {
|
|
4459
|
+
let modelId = a.modelId;
|
|
4460
|
+
if (a.pageId) {
|
|
4461
|
+
const page = await data(c, "GET", `/management/pages/${s(a.pageId)}`);
|
|
4462
|
+
modelId = page?.backingModel?.id;
|
|
4463
|
+
if (!modelId) return fail("This page has no backing content model; address its schema by modelId.");
|
|
4464
|
+
}
|
|
4465
|
+
if (!modelId) return fail("Pass modelId (a content model) or pageId (a page).");
|
|
4466
|
+
const plan = planFieldEdit(await data(c, "GET", `/management/content/models/${s(modelId)}`), a, a.ifMatch);
|
|
4467
|
+
if (!plan.ok) return fail(plan.error);
|
|
4468
|
+
const path = a.pageId ? `/management/pages/${s(a.pageId)}/model` : `/management/content/models/${s(modelId)}`;
|
|
4469
|
+
const updated = (await c.fetchJSON(c.url(path), {
|
|
4470
|
+
method: "PATCH",
|
|
4471
|
+
body: JSON.stringify({ fields: plan.fields, ...a.allowFieldRemoval === true ? { allowFieldRemoval: true } : {} }),
|
|
4472
|
+
...plan.ifMatch !== void 0 ? { headers: { "If-Match": `W/"${plan.ifMatch}"` } } : {}
|
|
4473
|
+
})).data;
|
|
4474
|
+
return ok(plan.renamed ? `Renamed '${plan.renamed.from}' to '${plan.renamed.to}'.` : `Updated field '${s(a.path)}'.`, updated);
|
|
4475
|
+
}
|
|
4476
|
+
),
|
|
4477
|
+
// ── Search, batch, localisation, automation, branches and AI images (2026-09-30) ──
|
|
4478
|
+
// Descriptions from ./tool-text.ts, shared word for word with the hosted catalogue.
|
|
4479
|
+
def(
|
|
4480
|
+
"search_content",
|
|
4481
|
+
"Search published content",
|
|
4482
|
+
NEW_TOOL_TEXT.search_content,
|
|
4483
|
+
z.object({
|
|
4484
|
+
q: z.string().min(1).max(200).describe("the words to find, 1-200 chars"),
|
|
4485
|
+
type: z.enum(["page", "entry"]).optional().describe("only pages, or only entries"),
|
|
4486
|
+
limit: z.number().int().min(1).max(50).optional().describe("hits to return; 10 when omitted")
|
|
4487
|
+
}).shape,
|
|
4488
|
+
async (c, a) => ok("Search results.", await c.fetchJSON(c.url(`/management/search${q({ q: a.q, type: a.type, limit: a.limit })}`), { method: "GET" }))
|
|
4489
|
+
),
|
|
4490
|
+
def(
|
|
4491
|
+
"discard_draft",
|
|
4492
|
+
"Discard unpublished edits",
|
|
4493
|
+
NEW_TOOL_TEXT.discard_draft,
|
|
4494
|
+
z.object({
|
|
4495
|
+
entryId: z.string().min(1).optional().describe("the entry (from list_content_entries); or send pageId"),
|
|
4496
|
+
pageId: z.string().min(1).optional().describe("instead of entryId: the page (from list_pages)"),
|
|
4497
|
+
ifMatch: z.number().int().min(0).optional().describe("optimisticVersion from get_content_entry or get_page")
|
|
4498
|
+
}).shape,
|
|
4499
|
+
async (c, a) => {
|
|
4500
|
+
if (!a.entryId === !a.pageId) return fail("Pass exactly one of entryId (an entry) or pageId (a page).");
|
|
4501
|
+
const path = a.pageId ? `/management/pages/${s(a.pageId)}/discard-draft` : `/management/content/entries/${s(a.entryId)}/discard-draft`;
|
|
4502
|
+
return ok("Draft discarded.", (await c.fetchJSON(c.url(path), {
|
|
4503
|
+
method: "POST",
|
|
4504
|
+
body: JSON.stringify({}),
|
|
4505
|
+
...a.ifMatch !== void 0 ? { headers: { "If-Match": `W/"${a.ifMatch}"` } } : {}
|
|
4506
|
+
})).data);
|
|
4507
|
+
}
|
|
4508
|
+
),
|
|
4509
|
+
def(
|
|
4510
|
+
"refresh_search_index",
|
|
4511
|
+
"Refresh IndexNow or site search",
|
|
4512
|
+
NEW_TOOL_TEXT.refresh_search_index,
|
|
4513
|
+
z.object({
|
|
4514
|
+
action: z.enum(["indexnow", "reindex"]).describe("'indexnow' pings search engines; 'reindex' rebuilds site search"),
|
|
4515
|
+
urls: z.array(z.string().min(1)).min(1).max(1e3).optional().describe("indexnow only: this site's URLs; every change since the last submission when omitted")
|
|
4516
|
+
}).shape,
|
|
4517
|
+
async (c, a) => ok(a.action === "reindex" ? "Site search rebuilt." : "IndexNow submission.", a.action === "reindex" ? await data(c, "POST", `/management/search/reindex`, {}) : await data(c, "POST", `/management/seo/indexnow/submit`, { urls: a.urls }))
|
|
4518
|
+
),
|
|
4519
|
+
def(
|
|
4520
|
+
"give_feedback",
|
|
4521
|
+
"Send feedback to the BetterCMS team",
|
|
4522
|
+
NEW_TOOL_TEXT.give_feedback,
|
|
4523
|
+
z.object({
|
|
4524
|
+
kind: z.enum(["bug", "missing_capability", "unclear_docs", "other"]).describe("what went wrong"),
|
|
4525
|
+
message: z.string().min(1).max(1e4).describe("what you tried, what happened and what you expected (1-10000 chars)"),
|
|
4526
|
+
tool: z.string().max(100).optional().describe("the tool it is about, if any"),
|
|
4527
|
+
context: z.string().max(4e3).optional().describe("the request and response, or other detail (up to 4000 chars)")
|
|
4528
|
+
}).shape,
|
|
4529
|
+
async (c, a) => ok("Feedback sent.", await data(c, "POST", `/management/feedback`, { kind: a.kind, message: a.message, tool: a.tool, context: a.context }))
|
|
4530
|
+
),
|
|
4531
|
+
def(
|
|
4532
|
+
"publish_entries",
|
|
4533
|
+
"Publish or unpublish many entries and pages",
|
|
4534
|
+
NEW_TOOL_TEXT.publish_entries,
|
|
4535
|
+
z.object({
|
|
4536
|
+
action: z.enum(["publish", "unpublish"]).describe("take the items live, or back to draft"),
|
|
4537
|
+
entryIds: z.array(z.string().min(1)).max(100).optional().describe("entry ids (from list_content_entries)"),
|
|
4538
|
+
pageIds: z.array(z.string().min(1)).max(100).optional().describe("page ids (from list_pages); publish only")
|
|
4539
|
+
}).shape,
|
|
4540
|
+
async (c, a) => ok(a.action === "publish" ? "Publish receipt." : "Unpublish receipt.", await data(c, "POST", `/management/publish`, { action: a.action, entryIds: a.entryIds, pageIds: a.pageIds }))
|
|
4541
|
+
),
|
|
4542
|
+
def(
|
|
4543
|
+
"create_content_entries",
|
|
4544
|
+
"Create many draft entries",
|
|
4545
|
+
NEW_TOOL_TEXT.create_content_entries,
|
|
4546
|
+
z.object({
|
|
4547
|
+
contentModelId: z.string().min(1).describe("the collection every entry belongs to (from list_content_models)"),
|
|
4548
|
+
entries: z.array(z.object({ slug: z.string().optional(), data: z.record(z.string(), z.unknown()) })).min(1).max(200).describe("1-200 entries, each {slug?, data}")
|
|
4549
|
+
}).shape,
|
|
4550
|
+
// The whole body: the per-row receipt is `data`, the counts ride in `meta`.
|
|
4551
|
+
async (c, a) => ok("Batch receipt.", await c.fetchJSON(c.url(`/management/content/entries/batch`), { method: "POST", body: JSON.stringify({ contentModelId: a.contentModelId, entries: a.entries }) }))
|
|
4552
|
+
),
|
|
4553
|
+
def(
|
|
4554
|
+
"translate_entry",
|
|
4555
|
+
"Translate an entry into another locale",
|
|
4556
|
+
NEW_TOOL_TEXT.translate_entry,
|
|
4557
|
+
z.object({
|
|
4558
|
+
entryId: z.string().min(1).describe("the SOURCE entry, never another translation"),
|
|
4559
|
+
targetLocale: z.string().min(1).describe("BCP-47 locale from the project's locales, e.g. 'fr'"),
|
|
4560
|
+
slug: z.string().optional().describe("the translation's slug; <source slug>-<locale> when omitted"),
|
|
4561
|
+
fields: z.array(z.string().min(1)).optional().describe("translate only these field keys")
|
|
4562
|
+
}).shape,
|
|
4563
|
+
async (c, a) => ok("Translation created.", await c.fetchJSON(c.url(`/management/content/entries/${s(a.entryId)}/translate`), { method: "POST", body: JSON.stringify({ targetLocale: a.targetLocale, slug: a.slug, fields: a.fields }) }))
|
|
4564
|
+
),
|
|
4565
|
+
def(
|
|
4566
|
+
"list_webhooks",
|
|
4567
|
+
"List webhooks",
|
|
4568
|
+
NEW_TOOL_TEXT.list_webhooks,
|
|
4569
|
+
z.object({}).shape,
|
|
4570
|
+
async (c) => ok("Webhooks.", await data(c, "GET", `/management/webhooks`))
|
|
4571
|
+
),
|
|
4572
|
+
def(
|
|
4573
|
+
"create_webhook",
|
|
4574
|
+
"Create a webhook",
|
|
4575
|
+
NEW_TOOL_TEXT.create_webhook,
|
|
4576
|
+
z.object({
|
|
4577
|
+
name: z.string().min(1).max(255).describe("a label, 1-255 chars"),
|
|
4578
|
+
url: z.string().url().describe("the https endpoint that receives the events"),
|
|
4579
|
+
events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).optional().describe("events to send; ['content.publish'] when omitted"),
|
|
4580
|
+
isActive: z.boolean().optional().describe("default true"),
|
|
4581
|
+
test: z.boolean().optional().describe("send a sample delivery now")
|
|
4582
|
+
}).shape,
|
|
4583
|
+
// The whole body: a `test` delivery's outcome rides beside `data`.
|
|
4584
|
+
async (c, a) => ok("Webhook created. Store the `secret` now: it is never shown again.", await c.fetchJSON(c.url(`/management/webhooks`), { method: "POST", body: JSON.stringify({ name: a.name, url: a.url, events: a.events, isActive: a.isActive, test: a.test }) }))
|
|
4585
|
+
),
|
|
4586
|
+
def(
|
|
4587
|
+
"list_branches",
|
|
4588
|
+
"List content branches",
|
|
4589
|
+
NEW_TOOL_TEXT.list_branches,
|
|
4590
|
+
z.object({}).shape,
|
|
4591
|
+
async (c) => ok("Branches.", await c.fetchJSON(c.url(`/management/branches`), { method: "GET" }))
|
|
4592
|
+
),
|
|
4593
|
+
def(
|
|
4594
|
+
"create_branch",
|
|
4595
|
+
"Create a content branch",
|
|
4596
|
+
NEW_TOOL_TEXT.create_branch,
|
|
4597
|
+
z.object({
|
|
4598
|
+
name: z.string().min(1).max(80).describe("1-80 chars"),
|
|
4599
|
+
slug: z.string().max(80).optional().describe("derived from the name when omitted")
|
|
4600
|
+
}).shape,
|
|
4601
|
+
async (c, a) => ok("Branch created.", await data(c, "POST", `/management/branches`, { name: a.name, slug: a.slug }))
|
|
4602
|
+
),
|
|
4603
|
+
def(
|
|
4604
|
+
"apply_branch",
|
|
4605
|
+
"Merge a branch into its parent",
|
|
4606
|
+
NEW_TOOL_TEXT.apply_branch,
|
|
4607
|
+
z.object({
|
|
4608
|
+
branchId: z.string().min(1).describe("the branch to merge (from list_branches)"),
|
|
4609
|
+
dryRun: z.boolean().optional().describe("true = return the diff and write nothing"),
|
|
4610
|
+
include: z.array(z.string().min(1)).max(500).optional().describe("change keys to merge (from the diff); all when omitted"),
|
|
4611
|
+
fields: z.record(z.string(), z.record(z.string(), z.enum(["keep_branch", "keep_parent"]))).optional().describe("change key \u2192 field \u2192 'keep_branch' | 'keep_parent', for each conflict")
|
|
4612
|
+
}).shape,
|
|
4613
|
+
async (c, a) => a.dryRun === true ? ok("Branch diff (nothing was written).", await data(c, "GET", `/management/branches/${s(a.branchId)}/diff`)) : ok("Branch applied.", await c.fetchJSON(c.url(`/management/branches/${s(a.branchId)}/apply`), { method: "POST", body: JSON.stringify({ include: a.include, fields: a.fields }) }))
|
|
4614
|
+
),
|
|
4615
|
+
def(
|
|
4616
|
+
"search_docs",
|
|
4617
|
+
"Search the BetterCMS docs",
|
|
4618
|
+
NEW_TOOL_TEXT.search_docs,
|
|
4619
|
+
z.object({
|
|
4620
|
+
q: z.string().min(1).describe("the question or words to find"),
|
|
4621
|
+
limit: z.number().int().min(1).max(20).optional().describe("pages to return; 5 when omitted")
|
|
4622
|
+
}).shape,
|
|
4623
|
+
async (c, a) => ok("Docs.", await c.fetchJSON(c.url(`/management/docs/search${q({ q: a.q, limit: a.limit })}`), { method: "GET" }))
|
|
4624
|
+
),
|
|
4625
|
+
def(
|
|
4626
|
+
"generate_image",
|
|
4627
|
+
"Generate an image with AI",
|
|
4628
|
+
NEW_TOOL_TEXT.generate_image,
|
|
4629
|
+
z.object({
|
|
4630
|
+
prompt: z.string().min(1).max(2e3).describe("what the image shows, 1-2000 chars"),
|
|
4631
|
+
altText: z.string().optional().describe("alt text for the new asset"),
|
|
4632
|
+
width: z.number().int().min(256).max(1440).multipleOf(16).optional(),
|
|
4633
|
+
height: z.number().int().min(256).max(1440).multipleOf(16).optional(),
|
|
4634
|
+
folderId: z.string().optional().describe("Media Library folder id")
|
|
4635
|
+
}).shape,
|
|
4636
|
+
async (c, a) => ok("Generated image.", await data(c, "POST", `/management/ai/image`, { prompt: a.prompt, altText: a.altText, width: a.width, height: a.height, folderId: a.folderId }))
|
|
4637
|
+
),
|
|
4638
|
+
def(
|
|
4639
|
+
"transform_image",
|
|
4640
|
+
"Edit an image with AI",
|
|
4641
|
+
NEW_TOOL_TEXT.transform_image,
|
|
4642
|
+
z.object({
|
|
4643
|
+
mediaId: z.string().min(1).describe("the image to edit (from list_media)"),
|
|
4644
|
+
prompt: z.string().min(1).max(2e3).describe("the change to make, 1-2000 chars"),
|
|
4645
|
+
altText: z.string().optional().describe("alt text for the new asset"),
|
|
4646
|
+
folderId: z.string().optional().describe("Media Library folder id")
|
|
4647
|
+
}).shape,
|
|
4648
|
+
async (c, a) => ok("Edited image (a new asset).", await data(c, "POST", `/management/ai/image/transform`, { mediaId: a.mediaId, prompt: a.prompt, altText: a.altText, folderId: a.folderId }))
|
|
4649
|
+
),
|
|
4148
4650
|
def(
|
|
4149
4651
|
"list_activity",
|
|
4150
4652
|
"List project activity",
|
|
4151
|
-
"Recent activity in the connected project \u2014 who changed what, and when. Filter by `category` (comma-separated: content, pages, media, forms, deployment, settings, members), `actorId`, or a `since`/`until` ISO window.",
|
|
4653
|
+
"Recent activity in the connected project \u2014 who changed what, and when. Filter by `category` (comma-separated: content, pages, media, forms, deployment, settings, members), `actorId`, or a `since`/`until` ISO window. At the start of a turn, pass `since` (your last look) to notice dashboard or agent edits made since.",
|
|
4152
4654
|
z.object({ category: z.string().optional(), actorId: z.string().optional(), since: z.string().optional(), until: z.string().optional(), limit: z.number().optional() }).shape,
|
|
4153
4655
|
async (c, a) => ok("Project activity.", await data(c, "GET", `/management/insights/activity${q({ category: a.category, actorId: a.actorId, since: a.since, until: a.until, limit: a.limit })}`))
|
|
4154
4656
|
),
|
|
4155
|
-
def(
|
|
4156
|
-
"get_changes",
|
|
4157
|
-
"Get changes since a timestamp",
|
|
4158
|
-
"Read the durable project change feed since an ISO timestamp. Call this at the start of a new turn to notice dashboard or agent edits made since your previous observation.",
|
|
4159
|
-
z.object({ since: z.string().datetime(), limit: z.number().int().min(1).max(100).optional() }).shape,
|
|
4160
|
-
async (c, a) => ok("Project changes.", await data(c, "GET", `/management/insights/activity${q({ since: a.since, limit: a.limit ?? 100 })}`))
|
|
4161
|
-
),
|
|
4162
4657
|
def(
|
|
4163
4658
|
"get_next_steps",
|
|
4164
4659
|
"Get what to do next",
|
|
@@ -4170,14 +4665,14 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4170
4665
|
def(
|
|
4171
4666
|
"get_binding_report",
|
|
4172
4667
|
"Check what on the live site is editable",
|
|
4173
|
-
"The receipt for 'is this site actually EDITABLE?'. Each release scans the built HTML for the element rendering each CMS field value; per slot this returns `mode` ('text-match' = guessed from rendered text, 'declared' = template-declared), what was bound, and `unmatched`: each path with its reason \u2014 not-declared, ambiguous-text, no-element, declared-only, needs-wrap, refused, layout-owned, page-owned, layout-locator-stale, locale-unvarianted, svg-unavailable, partial and unstamped (a page list the content apply left as built: `kind` `list <bound>/<total>`), mismatch (a text-match stamp whose element shows MORE than the value, or no longer the text it was matched on (`data-bcms-from`): never written), report-outdated. DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason`: `never-generated` \u2014 only a release BetterCMS builds and serves writes a report, so a HEADLESS site never gets one, redeploy or not; `outdated` \u2014 the next release rewrites it. It certifies only that every non-empty field has SOME element carrying its path. `unaddressable` is the copy it cannot see: visible live text no field owns, by route and bucket, biggest first (null = unmeasured: diff visible text against entry values). Over `count / visible > 0.02`, 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 \xA711. `
|
|
4174
|
-
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. The response's `sha` is the build the report describes; `refreshRequired` is true when that is not the build this slot serves.") }).shape,
|
|
4668
|
+
"The receipt for 'is this site actually EDITABLE?'. Each release scans the built HTML for the element rendering each CMS field value; per slot this returns `mode` ('text-match' = guessed from rendered text, 'declared' = template-declared), what was bound, and `unmatched`: each path with its reason \u2014 not-declared, ambiguous-text, no-element, declared-only, needs-wrap, refused, layout-owned, page-owned, layout-locator-stale, locale-unvarianted, svg-unavailable, partial and unstamped (a page list the content apply left as built: `kind` `list <bound>/<total>`), mismatch (a text-match stamp whose element shows MORE than the value, or no longer the text it was matched on (`data-bcms-from`): never written), report-outdated. DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason`: `never-generated` \u2014 only a release BetterCMS builds and serves writes a report, so a HEADLESS site never gets one, redeploy or not; `outdated` \u2014 the next release rewrites it. It certifies only that every non-empty field has SOME element carrying its path. `unaddressable` is the copy it cannot see: visible live text no field owns, by route and bucket, biggest first (null = unmeasured: diff visible text against entry values). Over `count / visible > 0.02`, 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 \xA711. `canvas.lane`: the editor's live-preview lane (`bridge`, `draft-route`; `none` \u2192 get_next_steps). `coverage.error` `nothing-bound`: no element of this build is bound, so its percentage describes another site.",
|
|
4669
|
+
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. The response's `sha` is the build the report describes; `refreshRequired` is true when that is not the build this slot serves. Also per slot: `builtRoutes`: routes the live build has HTML for (null = cannot say). `layoutIncomplete`: Layout paths the last content apply could not write (their publish waits); `layoutUnread`: Layout fields no route reads (`layoutUnreadJudged` false = not judged).") }).shape,
|
|
4175
4670
|
async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
|
|
4176
4671
|
),
|
|
4177
4672
|
def(
|
|
4178
4673
|
"get_conversion_brief",
|
|
4179
4674
|
"Get the brief for making this site's bindings durable",
|
|
4180
|
-
"The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route, every bindable field path with its `label`, `kind`, the CMS value (`current`) and the copy the repo renders today (`original`), the exact attributes to declare, and the ordered steps. Call it for any site whose pages were DERIVED at import, and whenever get_next_steps reports `bindings-not-declared`. It REPLACES re-registering a schema: these pages, fields and values exist already, so create_page /
|
|
4675
|
+
"The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route, every bindable field path with its `label`, `kind`, the CMS value (`current`) and the copy the repo renders today (`original`), the exact attributes to declare, and the ordered steps. Call it for any site whose pages were DERIVED at import, and whenever get_next_steps reports `bindings-not-declared`. It REPLACES re-registering a schema: these pages, fields and values exist already, so create_page / add_field / create_content_model would build a second schema over the first \u2014 edit values with set_page_content instead. `lane`: 'git-connected' = pull_project_source returns a repo; 'archive' = a tarball. Full recipe: section 13 of bettercms://playbook/schema; get_binding_report is the receipt that says you finished. Pass `complete: true` before running the CODEMOD (`npx @bettercms-ai/convert`): the brief UNCAPPED and paged, its full path list pinned under a `briefDigest` \u2014 what the coverage meter measures the build against. The complete brief also carries `forms` (id, status, fields, submitUrl), which `npx @bettercms-ai/convert --forms --brief brief.json --root . --receipt forms-receipt.json` wires into the repository's own <form> elements after the binding pass; read its `forms.pending` and publish every form its `forms.notes` names in the Forms tab (a draft form rejects every submission with a 403).",
|
|
4181
4676
|
z.object({
|
|
4182
4677
|
complete: z.boolean().optional().describe("true = the COMPLETE brief for a codemod: nothing truncated, no page omitted, paged 50 pages at a time. Page 1 pins the path list the coverage meter measures against."),
|
|
4183
4678
|
cursor: z.string().optional().describe("The `cursor` from the previous page. Implies complete. A 409 BRIEF_CHANGED means the brief was re-derived mid-pagination \u2014 start again with no cursor.")
|
|
@@ -4201,9 +4696,9 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4201
4696
|
def(
|
|
4202
4697
|
"get_componentize_plan",
|
|
4203
4698
|
"Get the plan for turning this site's sections into components",
|
|
4204
|
-
"What this site's SECTIONS would become as components \u2014 a proposal that creates nothing and is computed live. For a site whose pages were DERIVED at import, each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import folded (`group-*`). Per page: each section's `groupKey`, `sectionType` family, leaf `fields` with their CMS values, `shapeHash`, and either the component already rendering it (`reuse.componentId`) or the proposed one (`reuse.proposedSlug`). Under `components`, a NEW one carries the exact `props` and `blockJson` create_component would take; an EXISTING one only `componentId`, `slug` and `name` (nothing is written for it). Every page gets its OWN components; a locale copy (`/fr`) reuses its default-locale page's. A COLLECTION TEMPLATE (dynamic page, `pattern: \"{slug}\"`) is planned ONCE, every prop reading the rendered entry: only its prose body and object-row lists are bands; frontmatter (author, images, tags, dates) stays entry fields. `pending` says why a group is not offered
|
|
4699
|
+
"What this site's SECTIONS would become as components \u2014 a proposal that creates nothing and is computed live. For a site whose pages were DERIVED at import, each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import folded (`group-*`). Per page: each section's `groupKey`, `sectionType` family, leaf `fields` with their CMS values, `shapeHash`, and either the component already rendering it (`reuse.componentId`) or the proposed one (`reuse.proposedSlug`). Under `components`, a NEW one carries the exact `props` and `blockJson` create_component would take; an EXISTING one only `componentId`, `slug` and `name` (nothing is written for it). Every page gets its OWN components; a locale copy (`/fr`) reuses its default-locale page's. A COLLECTION TEMPLATE (dynamic page, `pattern: \"{slug}\"`) is planned ONCE, every prop reading the rendered entry: only its prose body and object-row lists are bands; frontmatter (author, images, tags, dates) stays entry fields. `pending` says why a group is not offered (its codes: see `summary`). Chrome (`nav`/`footer`/`modal`: LAYOUT_CHROME, or in the Layout) is NEVER a section: edit it through the Layout. READ `summary: true` FIRST \u2014 the full plan runs to hundreds of KB; `paged` pages it (409 PLAN_CHANGED = start again); the unpaged full read is what `--plan plan.json` takes. Keep the `digest` \u2014 componentize_sections refuses any other.",
|
|
4205
4700
|
z.object({
|
|
4206
|
-
summary: z.boolean().optional().describe("true = no values: each section's groupKey, family, pending and component slug. Read this first."),
|
|
4701
|
+
summary: z.boolean().optional().describe("true = no values: each section's groupKey, family, pending and component slug. Read this first. `pending` codes: `NO_GROUP_ROOT` (keys are still derive-lane names like `h1-welcome` \u2014 rename them into families first), `NOT_A_SECTION` (a lone scalar or the page's own metadata; `detail: ENTRY_METADATA` = collection-template frontmatter, `detail: DECLINED` = declined with componentize_sections `decline`), `IMAGE_AS_TEXT` (a text field holding an image path \u2014 make it an image field), `ALREADY_COMPONENTIZED`, `EMPTY_GROUP`, `NESTED_REPEATER` (three deep; two levels become a nested `table` sub-field), `PAGE_NOT_EMPTY` (blocks this lane does not own and will not overwrite)."),
|
|
4207
4702
|
paged: z.boolean().optional().describe("true = 10 pages at a time; the response carries `page`, `pageCount` and a `cursor` for the next."),
|
|
4208
4703
|
cursor: z.string().optional().describe("The `cursor` from the previous page. Implies paged. A 409 `PLAN_CHANGED` means start again with no cursor.")
|
|
4209
4704
|
}).shape,
|
|
@@ -4437,102 +4932,6 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4437
4932
|
z.object({ jobId: z.string().min(1) }).shape,
|
|
4438
4933
|
async (c, a) => ok("Job rejected.", await data(c, "POST", `/management/bulk/jobs/${s(a.jobId)}/reject`))
|
|
4439
4934
|
),
|
|
4440
|
-
def(
|
|
4441
|
-
"list_section_validation_requests",
|
|
4442
|
-
"List queued Section validation requests",
|
|
4443
|
-
"Poll for user-agent Section implementation-validation requests in this exact project. BetterCMS cannot push work into an ordinary MCP client: call this explicitly, claim one request, inspect and run the customer's real repository/app shell, then submit request-bound evidence and complete it. Returns only unclaimed user-agent requests; it never exposes another project.",
|
|
4444
|
-
listSectionValidationRequestsInput.shape,
|
|
4445
|
-
async (c, a) => ok("Queued user-agent Section validation requests.", await data(
|
|
4446
|
-
c,
|
|
4447
|
-
"GET",
|
|
4448
|
-
`/projects/${s(a.projectId)}/section-evidence/implementation-requests${q({ limit: a.limit })}`
|
|
4449
|
-
))
|
|
4450
|
-
),
|
|
4451
|
-
def(
|
|
4452
|
-
"claim_section_validation_request",
|
|
4453
|
-
"Claim a Section validation request",
|
|
4454
|
-
"Atomically claim one queued user-agent request and pin the exact git commit you will inspect. Claim BEFORE submitting evidence. BetterCMS records coordination only; all customer code and responsive checks must run in the user's repository and real app shell.",
|
|
4455
|
-
claimSectionValidationRequestInput.shape,
|
|
4456
|
-
async (c, a) => ok("Section validation request claimed.", await data(
|
|
4457
|
-
c,
|
|
4458
|
-
"POST",
|
|
4459
|
-
`/projects/${s(a.projectId)}/section-evidence/implementation-requests/${s(a.requestId)}/claim`,
|
|
4460
|
-
{
|
|
4461
|
-
commitSha: a.commitSha,
|
|
4462
|
-
providerRunId: a.providerRunId,
|
|
4463
|
-
providerRunUrl: a.providerRunUrl
|
|
4464
|
-
}
|
|
4465
|
-
))
|
|
4466
|
-
),
|
|
4467
|
-
def(
|
|
4468
|
-
"complete_section_validation_request",
|
|
4469
|
-
"Complete a Section validation request",
|
|
4470
|
-
"Complete a claimed request only after submit_section_manifest and submit_section_validation have bound exact, passed evidence to the same requestId and pinned commit. This cannot grant the separate human Visual Approval.",
|
|
4471
|
-
completeSectionValidationRequestInput.shape,
|
|
4472
|
-
async (c, a) => ok("Section validation request completed.", await data(
|
|
4473
|
-
c,
|
|
4474
|
-
"POST",
|
|
4475
|
-
`/projects/${s(a.projectId)}/section-evidence/implementation-requests/${s(a.requestId)}/complete`,
|
|
4476
|
-
{ manifestId: a.manifestId, validationRunId: a.validationRunId }
|
|
4477
|
-
))
|
|
4478
|
-
),
|
|
4479
|
-
def(
|
|
4480
|
-
"fail_section_validation_request",
|
|
4481
|
-
"Fail a Section validation request",
|
|
4482
|
-
"Truthfully close a claimed request when the renderer, validation command, app shell, or required evidence is missing or fails. This path intentionally works without a manifest so 'not implemented' is representable instead of being reported as passed or left queued forever.",
|
|
4483
|
-
failSectionValidationRequestInput.shape,
|
|
4484
|
-
async (c, a) => ok("Section validation request failed.", await data(
|
|
4485
|
-
c,
|
|
4486
|
-
"POST",
|
|
4487
|
-
`/projects/${s(a.projectId)}/section-evidence/implementation-requests/${s(a.requestId)}/fail`,
|
|
4488
|
-
{
|
|
4489
|
-
errorCode: a.errorCode,
|
|
4490
|
-
errorMessage: a.errorMessage,
|
|
4491
|
-
providerRunId: a.providerRunId,
|
|
4492
|
-
providerRunUrl: a.providerRunUrl
|
|
4493
|
-
}
|
|
4494
|
-
))
|
|
4495
|
-
),
|
|
4496
|
-
def(
|
|
4497
|
-
"submit_section_manifest",
|
|
4498
|
-
"Submit a Section implementation manifest",
|
|
4499
|
-
"Record the implementation manifest produced for one exact Section schema version by CI or an agent running INSIDE the user's repository. BetterCMS does not execute customer code: inspect/build the real renderer in the user's app shell first, then submit its exact API ID, schema hash, commit SHA, loader, preview adapter, and native responsive viewports. Requires a project-scoped artifact:write credential. This records evidence only; it does not validate the implementation and cannot create human Visual Approval.",
|
|
4500
|
-
submitSectionManifestInput.shape,
|
|
4501
|
-
async (c, a) => ok("Section implementation manifest recorded.", await data(
|
|
4502
|
-
c,
|
|
4503
|
-
"POST",
|
|
4504
|
-
`/projects/${s(a.projectId)}/section-evidence/sections/${s(a.sectionId)}/versions/${a.version}/manifests`,
|
|
4505
|
-
{
|
|
4506
|
-
requestId: a.requestId,
|
|
4507
|
-
apiId: a.apiId,
|
|
4508
|
-
schemaHash: a.schemaHash,
|
|
4509
|
-
commitSha: a.commitSha,
|
|
4510
|
-
loader: a.loader,
|
|
4511
|
-
previewAdapter: a.previewAdapter,
|
|
4512
|
-
nativeViewports: a.nativeViewports
|
|
4513
|
-
}
|
|
4514
|
-
))
|
|
4515
|
-
),
|
|
4516
|
-
def(
|
|
4517
|
-
"submit_section_validation",
|
|
4518
|
-
"Submit a Section validation result",
|
|
4519
|
-
"Record a real validation result for one exact Section schema version and manifest. Run the component in the user's repository and real app shell across the canonical preview dataset, AI stress fixtures, and required/native viewports BEFORE calling this tool; BetterCMS never runs that customer code. Pass status 'passed' only when those checks actually passed, otherwise 'failed'. The manifest pins schemaHash and commitSha; fixtureHash and evidenceDigest pin the tested data and immutable evidence bundle. Requires project-scoped artifact:write and cannot create the separate human Visual Approval required for publication.",
|
|
4520
|
-
submitSectionValidationInput.shape,
|
|
4521
|
-
async (c, a) => ok("Section validation result recorded.", await data(
|
|
4522
|
-
c,
|
|
4523
|
-
"POST",
|
|
4524
|
-
`/projects/${s(a.projectId)}/section-evidence/sections/${s(a.sectionId)}/versions/${a.version}/validations`,
|
|
4525
|
-
{
|
|
4526
|
-
requestId: a.requestId,
|
|
4527
|
-
manifestId: a.manifestId,
|
|
4528
|
-
status: a.status,
|
|
4529
|
-
fixtureHash: a.fixtureHash,
|
|
4530
|
-
evidenceDigest: a.evidenceDigest,
|
|
4531
|
-
appShell: a.appShell,
|
|
4532
|
-
viewportResults: a.viewportResults
|
|
4533
|
-
}
|
|
4534
|
-
))
|
|
4535
|
-
),
|
|
4536
4935
|
def(
|
|
4537
4936
|
"get_deploy_status",
|
|
4538
4937
|
"Get deploy/build status",
|
|
@@ -4582,7 +4981,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4582
4981
|
name: "list_pages",
|
|
4583
4982
|
config: {
|
|
4584
4983
|
title: "List pages in the current project",
|
|
4585
|
-
description: "List the pages in the project this MCP key is bound to, each with its full field SCHEMA, pageType (singleton|dynamic), and status. Use it to verify WHERE content lands and to read field keys/types before set_page_content /
|
|
4984
|
+
description: "List the pages in the project this MCP key is bound to, each with its full field SCHEMA, pageType (singleton|dynamic), and status. Use it to verify WHERE content lands and to read field keys/types before set_page_content / add_field.",
|
|
4586
4985
|
inputSchema: {}
|
|
4587
4986
|
},
|
|
4588
4987
|
handler: guard(
|
|
@@ -4606,7 +5005,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4606
5005
|
name: "get_page",
|
|
4607
5006
|
config: {
|
|
4608
5007
|
title: "Get a page (with its field schema)",
|
|
4609
|
-
description: "Get one page by id INCLUDING its full field schema (keys, types, nested group/repeater children) and pageType. Read this before set_page_content so you write correctly-keyed values, or before
|
|
5008
|
+
description: "Get one page by id INCLUDING its full field schema (keys, types, nested group/repeater children) and pageType. Read this before set_page_content so you write correctly-keyed values, or before add_field so you know the existing keys.",
|
|
4610
5009
|
inputSchema: getPageInput.shape
|
|
4611
5010
|
},
|
|
4612
5011
|
handler: guard(
|
|
@@ -4640,7 +5039,9 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4640
5039
|
...args.fields ? { fields: args.fields.map(toField) } : {},
|
|
4641
5040
|
...args.data !== void 0 ? { data: args.data } : {},
|
|
4642
5041
|
...args.metaTitle !== void 0 ? { metaTitle: args.metaTitle } : {},
|
|
4643
|
-
...args.metaDescription !== void 0 ? { metaDescription: args.metaDescription } : {}
|
|
5042
|
+
...args.metaDescription !== void 0 ? { metaDescription: args.metaDescription } : {},
|
|
5043
|
+
...args.locale !== void 0 ? { locale: args.locale } : {},
|
|
5044
|
+
...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {}
|
|
4644
5045
|
});
|
|
4645
5046
|
const { warnings, seedErrors, ...created } = page;
|
|
4646
5047
|
const summary = `Created ${created.pageType ?? "page"} page '${created.title}' (id ${created.id}, slug ${created.slug}) with ${created.fields.length} field(s).`;
|
|
@@ -4683,13 +5084,24 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4683
5084
|
{
|
|
4684
5085
|
name: "add_field",
|
|
4685
5086
|
config: {
|
|
4686
|
-
title: "Add a field to a content model",
|
|
4687
|
-
description:
|
|
5087
|
+
title: "Add a field to a content model or a page",
|
|
5088
|
+
description: ADD_FIELD_DESCRIPTION + " " + RICH_TEXT_NOTE,
|
|
4688
5089
|
inputSchema: addFieldInput.shape
|
|
4689
5090
|
},
|
|
4690
5091
|
handler: guard(
|
|
4691
5092
|
async (args) => withClient(async (client) => {
|
|
4692
|
-
|
|
5093
|
+
if (!args.modelId === !args.pageId) return fail("Pass exactly one of modelId (a content model) or pageId (a page).");
|
|
5094
|
+
if (args.pageId) {
|
|
5095
|
+
const page = await client.addPageFields(args.pageId, {
|
|
5096
|
+
// Cast: the SDK types `options` as string[], narrower than the API, which also takes
|
|
5097
|
+
// {label, value} (backend selectFieldSchema).
|
|
5098
|
+
addFields: [toField(args)],
|
|
5099
|
+
...args.after !== void 0 ? { after: args.after } : {}
|
|
5100
|
+
});
|
|
5101
|
+
return ok(`Added field '${args.key}' to page '${page.title}'. Page now has ${page.fields.length} field(s).`, page);
|
|
5102
|
+
}
|
|
5103
|
+
const modelId = args.modelId;
|
|
5104
|
+
const model = await client.getModel(modelId);
|
|
4693
5105
|
const dupes = duplicateKeys([...flatFieldKeys(model.fields), args.key]);
|
|
4694
5106
|
if (dupes.length > 0) return fail(duplicateKeyFailure(dupes));
|
|
4695
5107
|
const existingFields = model.fields;
|
|
@@ -4707,9 +5119,8 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4707
5119
|
}
|
|
4708
5120
|
const nextFields = [...existingFields];
|
|
4709
5121
|
nextFields.splice(at, 0, toField(args));
|
|
4710
|
-
const
|
|
4711
|
-
|
|
4712
|
-
});
|
|
5122
|
+
const input = { fields: nextFields };
|
|
5123
|
+
const updated = model.optimisticVersion !== void 0 ? await client.updateModel(modelId, input, { ifMatch: model.optimisticVersion }) : await client.updateModel(modelId, input);
|
|
4713
5124
|
return ok(
|
|
4714
5125
|
`Added field '${args.key}' to '${updated.name}'${args.after ? ` after '${args.after}'` : ""}. Model now has ${updated.fields.length} field(s).`,
|
|
4715
5126
|
updated
|
|
@@ -4717,33 +5128,11 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4717
5128
|
})
|
|
4718
5129
|
)
|
|
4719
5130
|
},
|
|
4720
|
-
{
|
|
4721
|
-
name: "add_page_field",
|
|
4722
|
-
config: {
|
|
4723
|
-
title: "Add a field to a page",
|
|
4724
|
-
description: "Append a field to an existing page's schema (Home, About, a blog template, etc.). Additive \u2014 the API rejects a key that already exists and never overwrites or retypes existing fields. Use this when the target is a page (singleton or dynamic); use add_field when the target is a content model. 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. A 'group' is stored as type 'array' with `config.zones.nonRepeatable`: entry and delivery data carry `<key>.nonRepeatable.<sub>`, while data-bcms-field paths are `<key>.<sub>`. Schema only \u2014 never places a section; use componentize_sections to turn a group into a section.",
|
|
4725
|
-
inputSchema: addPageFieldInput.shape
|
|
4726
|
-
},
|
|
4727
|
-
handler: guard(
|
|
4728
|
-
async (args) => withClient(async (client) => {
|
|
4729
|
-
const page = await client.addPageFields(args.pageId, {
|
|
4730
|
-
// Cast: same as create_page. The SDK types `options` as string[], narrower than the
|
|
4731
|
-
// API, which also takes {label, value} (backend selectFieldSchema).
|
|
4732
|
-
addFields: [toField(args)],
|
|
4733
|
-
...args.after !== void 0 ? { after: args.after } : {}
|
|
4734
|
-
});
|
|
4735
|
-
return ok(
|
|
4736
|
-
`Added field '${args.key}' to page '${page.title}'. Page now has ${page.fields.length} field(s).`,
|
|
4737
|
-
page
|
|
4738
|
-
);
|
|
4739
|
-
})
|
|
4740
|
-
)
|
|
4741
|
-
},
|
|
4742
5131
|
{
|
|
4743
5132
|
name: "create_content_entry",
|
|
4744
5133
|
config: {
|
|
4745
5134
|
title: "Create a content entry",
|
|
4746
|
-
description: "Create a content entry under a model. " + PAGES_NOT_COLLECTIONS_NOTE + " Pass its field VALUES in `data`, keyed by field key \u2014 INCLUDE ALL REQUIRED FIELDS (create validates them). New entries are drafts; pass status:'published' to take it live. Read get_content_model first for the field keys and which are required.",
|
|
5135
|
+
description: "Create a content entry under a model. " + PAGES_NOT_COLLECTIONS_NOTE + " Pass its field VALUES in `data`, keyed by field key \u2014 INCLUDE ALL REQUIRED FIELDS (create validates them). New entries are drafts; pass status:'published' to take it live. Read get_content_model first for the field keys and which are required. One entry per call: for volume, run code against the Management API.",
|
|
4747
5136
|
inputSchema: createEntryInput.shape
|
|
4748
5137
|
},
|
|
4749
5138
|
handler: guard(
|
|
@@ -4751,7 +5140,10 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4751
5140
|
const created = await client.createEntry({
|
|
4752
5141
|
contentModelId: args.contentModelId,
|
|
4753
5142
|
...args.slug !== void 0 ? { slug: args.slug } : {},
|
|
4754
|
-
...args.data !== void 0 ? { data: args.data } : {}
|
|
5143
|
+
...args.data !== void 0 ? { data: args.data } : {},
|
|
5144
|
+
// The SDK's input type predates localization; its body is the input as given.
|
|
5145
|
+
...args.locale !== void 0 ? { locale: args.locale } : {},
|
|
5146
|
+
...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {}
|
|
4755
5147
|
});
|
|
4756
5148
|
const published = args.status !== void 0 && args.status !== "draft" ? await client.updateEntry(created.id, { status: args.status }) : created;
|
|
4757
5149
|
const { warnings, ...entry } = published;
|
|
@@ -4767,7 +5159,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4767
5159
|
name: "set_page_content",
|
|
4768
5160
|
config: {
|
|
4769
5161
|
title: "Set a page's field values (content)",
|
|
4770
|
-
description: "Set a page's field VALUES \u2014 the actual content. For a SINGLETON page (Home, About, Site Settings) this creates or updates its one entry, so call it again to edit. `data` is keyed by field key: a nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value }, repeatable: [ { childKey: value } ] }; a primitive 'array' is a plain list; an 'image' value is an asset URL. Read the schema first with get_page. This is how you populate Home/About/Settings \u2014 create_content_entry is for dynamic collections only. Merges by top-level key (a zone value replaces that whole zone); pass replace:true to remove omitted fields.",
|
|
5162
|
+
description: "Set a page's field VALUES \u2014 the actual content. For a SINGLETON page (Home, About, Site Settings) this creates or updates its one entry, so call it again to edit. `data` is keyed by field key: a nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value }, repeatable: [ { childKey: value } ] }; a primitive 'array' is a plain list; an 'image' value is an asset URL or { url, altText?, focal?, crop? } \u2014 focal {x,y} and crop {top,right,bottom,left} (0\u20131, cut from each edge) frame THIS placement only. Read the schema first with get_page. This is how you populate Home/About/Settings \u2014 create_content_entry is for dynamic collections only. Merges by top-level key (a zone value replaces that whole zone); pass replace:true to remove omitted fields.",
|
|
4771
5163
|
inputSchema: setPageContentInput.shape
|
|
4772
5164
|
},
|
|
4773
5165
|
handler: guard(
|
|
@@ -4788,17 +5180,26 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4788
5180
|
name: "list_content_entries",
|
|
4789
5181
|
config: {
|
|
4790
5182
|
title: "List content entries (incl. drafts)",
|
|
4791
|
-
description: "List content entries \u2014 including drafts \u2014 filtered by model and/or page. Use it to SEE existing content before editing. For a singleton page, pass its pageId to get its single entry. A project-scoped connection sees its own project's entries plus workspace-level ones, which it can read but not change.",
|
|
5183
|
+
description: "List content entries \u2014 including drafts \u2014 filtered by model and/or page, by field values (`where`), sorted and paged. Use it to SEE existing content before editing. For a singleton page, pass its pageId to get its single entry. A project-scoped connection sees its own project's entries plus workspace-level ones, which it can read but not change. Returns `limit` entries per `page` (50 by default); the summary says which page of how many.",
|
|
4792
5184
|
inputSchema: listEntriesInput.shape
|
|
4793
5185
|
},
|
|
4794
5186
|
handler: guard(
|
|
4795
5187
|
async (args) => withClient(async (client) => {
|
|
4796
|
-
const
|
|
5188
|
+
const filter = {
|
|
4797
5189
|
...args.modelId ? { modelId: args.modelId } : {},
|
|
4798
5190
|
...args.pageId ? { pageId: args.pageId } : {},
|
|
4799
5191
|
...args.status ? { status: args.status } : {}
|
|
4800
|
-
}
|
|
4801
|
-
|
|
5192
|
+
};
|
|
5193
|
+
if ([args.limit, args.where, args.sort, args.page].every((v) => v === void 0)) {
|
|
5194
|
+
const entries = await client.listEntries(filter);
|
|
5195
|
+
return ok(`${entries.length} entr(y/ies).`, entries);
|
|
5196
|
+
}
|
|
5197
|
+
const res = await client.fetchJSON(
|
|
5198
|
+
client.url(`/management/content/entries${entryListSearch(args)}`),
|
|
5199
|
+
{ method: "GET" }
|
|
5200
|
+
);
|
|
5201
|
+
const paged = res.meta ? ` Page ${res.meta.page} of ${res.meta.totalPages} (${res.meta.total} in all).` : "";
|
|
5202
|
+
return ok(`${res.data.length} entr(y/ies).${paged}`, res.data);
|
|
4802
5203
|
})
|
|
4803
5204
|
)
|
|
4804
5205
|
},
|
|
@@ -4820,17 +5221,24 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4820
5221
|
name: "update_content_entry",
|
|
4821
5222
|
config: {
|
|
4822
5223
|
title: "Update a content entry's values",
|
|
4823
|
-
description: "Update a content entry's `data` (field values) and/or status by id. `data` is keyed by field key; a nested 'array' (zone) value is an object { nonRepeatable: {\u2026}, repeatable: [{\u2026}] }, a primitive 'array' is a plain list. Use this to edit an existing entry; for a singleton page prefer set_page_content. \u{1F534} `data` REPLACES the entry's whole value object: an optional field you leave out is DELETED, and a required one you leave out fails the whole write. To change one field, read the entry (get_content_entry), change that key
|
|
5224
|
+
description: "Update a content entry's `data` (field values) and/or status by id. `data` is keyed by field key; a nested 'array' (zone) value is an object { nonRepeatable: {\u2026}, repeatable: [{\u2026}] }, a primitive 'array' is a plain list; an image value may carry this placement's focal {x,y} and crop {top,right,bottom,left} (0\u20131). Use this to edit an existing entry; for a singleton page prefer set_page_content. \u{1F534} `data` REPLACES the entry's whole value object: an optional field you leave out is DELETED, and a required one you leave out fails the whole write. To change one field, pass merge:true, or read the entry (get_content_entry), change that key and send the whole object back. " + ENTRY_META_NOTE,
|
|
4824
5225
|
inputSchema: updateEntryInput.shape
|
|
4825
5226
|
},
|
|
4826
5227
|
handler: guard(
|
|
4827
5228
|
async (args) => withClient(async (client) => {
|
|
4828
|
-
const
|
|
4829
|
-
|
|
5229
|
+
const write = args.merge === true && args.data !== void 0 ? planEntryMerge(await client.getEntry(args.entryId), args.data, args.ifMatch) : { data: args.data, ifMatch: args.ifMatch };
|
|
5230
|
+
const input = {
|
|
5231
|
+
...write.data !== void 0 ? { data: write.data } : {},
|
|
4830
5232
|
...args.status !== void 0 ? { status: args.status } : {},
|
|
4831
5233
|
...args.slug !== void 0 ? { slug: args.slug } : {},
|
|
4832
|
-
...args.meta !== void 0 ? { meta: args.meta } : {}
|
|
4833
|
-
|
|
5234
|
+
...args.meta !== void 0 ? { meta: args.meta } : {},
|
|
5235
|
+
// Not on the SDK's input type yet; the route accepts both (null cancels).
|
|
5236
|
+
...args.scheduledAt !== void 0 ? { scheduledAt: args.scheduledAt } : {},
|
|
5237
|
+
...args.unpublishAt !== void 0 ? { unpublishAt: args.unpublishAt } : {},
|
|
5238
|
+
...args.locale !== void 0 ? { locale: args.locale } : {},
|
|
5239
|
+
...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {}
|
|
5240
|
+
};
|
|
5241
|
+
const { warnings, ...entry } = write.ifMatch === void 0 ? await client.updateEntry(args.entryId, input) : await client.updateEntry(args.entryId, input, { ifMatch: write.ifMatch });
|
|
4834
5242
|
return ok([`Updated entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, ...warnings ?? []].join("\n"), entry);
|
|
4835
5243
|
})
|
|
4836
5244
|
)
|
|
@@ -4839,12 +5247,15 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4839
5247
|
name: "upload_asset",
|
|
4840
5248
|
config: {
|
|
4841
5249
|
title: "Upload an asset to the Media Library",
|
|
4842
|
-
description: "Upload an image/asset from a local file path or a remote URL into the project's Media Library. Returns the asset's stable CDN URL \u2014 put that URL into a content entry's image field. Use this BEFORE creating entries that reference images.",
|
|
5250
|
+
description: "Upload an image/asset from a local file path or a remote URL into the project's Media Library, or have the server fetch a public `sourceUrl` itself. Returns the asset's stable CDN URL \u2014 put that URL into a content entry's image field. Use this BEFORE creating entries that reference images.",
|
|
4843
5251
|
inputSchema: uploadAssetInput.shape
|
|
4844
5252
|
},
|
|
4845
5253
|
handler: guard(
|
|
4846
5254
|
async (args) => withClient(async (client) => {
|
|
4847
|
-
const asset = await client.
|
|
5255
|
+
const asset = args.sourceUrl && !args.localPath && !args.url ? (await client.fetchJSON(client.url(`/management/media/import`), {
|
|
5256
|
+
method: "POST",
|
|
5257
|
+
body: JSON.stringify({ url: args.sourceUrl, altText: args.altText, caption: args.caption, folderId: args.folderId })
|
|
5258
|
+
})).data : await client.uploadAsset(args);
|
|
4848
5259
|
return ok(
|
|
4849
5260
|
`Uploaded '${asset.filename}' (id ${asset.id}). Put this URL (or the id ${asset.id}) into an image field to attach it: ${asset.url}`,
|
|
4850
5261
|
asset
|
|
@@ -5070,7 +5481,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5070
5481
|
name: "get_component",
|
|
5071
5482
|
config: {
|
|
5072
5483
|
title: "Get a component (with its blockJson)",
|
|
5073
|
-
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.",
|
|
5484
|
+
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. " + VARIANT_GROUP_NOTE,
|
|
5074
5485
|
inputSchema: getComponentInput.shape
|
|
5075
5486
|
},
|
|
5076
5487
|
handler: guard(
|
|
@@ -5084,7 +5495,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5084
5495
|
name: "create_component",
|
|
5085
5496
|
config: {
|
|
5086
5497
|
title: "Create a reusable component",
|
|
5087
|
-
description: "Create a reusable component from a blockJson tree. THIS IS ALSO HOW A PAGE GETS ITS SECTIONS: set `sectionType` (e.g. 'Hero') and the component becomes a placeable section, selectable in the editor's 'Add a section' picker. Components sharing a `sectionType` are its layout VARIANTS \u2014 one Hero with a 'Centered' and a 'Two-column' variant, same prop keys, so a swap keeps the content. CONFIRM the structure with the user first. blockJson is an array of blocks \u2014 the same set create_page accepts (heading, text/richtext, image, button, spacer, video, columns, section, slider, tabs, navbar, footer, form, component, collection), NOT a narrower one; `section` nests child blocks in props.children and `columns` in props.columns. `props` declares overridable fields. Returns the new id \u2014 render with `<BcmsBlocks>`. Always lands as a DRAFT \u2014 call publish_component to put it on the live site; until then it renders as NOTHING there, with no error. Buttons auto-join the project's Button family; section buttons link to its looks." + SECTION_DOCTRINE,
|
|
5498
|
+
description: "Create a reusable component from a blockJson tree. THIS IS ALSO HOW A PAGE GETS ITS SECTIONS: set `sectionType` (e.g. 'Hero') and the component becomes a placeable section, selectable in the editor's 'Add a section' picker. Components sharing a `sectionType` are its layout VARIANTS \u2014 one Hero with a 'Centered' and a 'Two-column' variant, same prop keys, so a swap keeps the content. CONFIRM the structure with the user first. blockJson is an array of blocks \u2014 the same set create_page accepts (heading, text/richtext, image, button, spacer, video, columns, section, slider, tabs, navbar, footer, form, component, collection), NOT a narrower one; `section` nests child blocks in props.children and `columns` in props.columns. `props` declares overridable fields. Returns the new id \u2014 render with `<BcmsBlocks>`. Always lands as a DRAFT \u2014 call publish_component to put it on the live site; until then it renders as NOTHING there, with no error. Buttons auto-join the project's Button family; section buttons link to its looks." + SECTION_DOCTRINE + " " + UNPROPPED_COPY_NOTE,
|
|
5088
5499
|
inputSchema: createComponentInput.shape
|
|
5089
5500
|
},
|
|
5090
5501
|
handler: guard(
|
|
@@ -5108,32 +5519,6 @@ ${notes.join("\n")}` : summary, created);
|
|
|
5108
5519
|
* no receipt mentions, and the agent's only safe move is to re-run it and duplicate them.
|
|
5109
5520
|
* Every item reports its own outcome instead, and the summary line says how many failed.
|
|
5110
5521
|
*/
|
|
5111
|
-
{
|
|
5112
|
-
name: "create_components",
|
|
5113
|
-
config: {
|
|
5114
|
-
title: "Create many components in one call",
|
|
5115
|
-
description: "Create up to 50 components in ONE call \u2014 the same input as create_component, once per item. Prefer it over create_component for more than three: a whole-site componentize run (playbook \xA712) is dozens. 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: only the leaves a declared prop TARGETS are click-to-edit; copy no prop points at is unreachable from canvas and dock alike. A page is composed of SECTIONS; the full doctrine is in the bettercms://playbook/schema resource, \xA712.",
|
|
5116
|
-
inputSchema: createComponentsInput.shape
|
|
5117
|
-
},
|
|
5118
|
-
handler: guard(
|
|
5119
|
-
async (args) => withClient(async (client) => {
|
|
5120
|
-
const results = [];
|
|
5121
|
-
for (const input of args.components) {
|
|
5122
|
-
try {
|
|
5123
|
-
const cmp = await client.createComponent(input);
|
|
5124
|
-
results.push({ ok: true, slug: cmp.slug, id: cmp.id, name: cmp.name });
|
|
5125
|
-
} catch (err) {
|
|
5126
|
-
results.push({ ok: false, slug: input.slug, error: errorText(err) });
|
|
5127
|
-
}
|
|
5128
|
-
}
|
|
5129
|
-
const failed = results.filter((r) => !r.ok).length;
|
|
5130
|
-
return ok(
|
|
5131
|
-
`Created ${results.length - failed} of ${args.components.length} component(s)${failed ? `; ${failed} failed \u2014 see the per-item errors` : ""}. All are DRAFTS: publish_components once the owner has approved them, or they render as empty strings.`,
|
|
5132
|
-
results
|
|
5133
|
-
);
|
|
5134
|
-
})
|
|
5135
|
-
)
|
|
5136
|
-
},
|
|
5137
5522
|
{
|
|
5138
5523
|
name: "publish_components",
|
|
5139
5524
|
config: {
|
|
@@ -5262,7 +5647,7 @@ ${lines.join("\n")}`, found);
|
|
|
5262
5647
|
name: "update_component",
|
|
5263
5648
|
config: {
|
|
5264
5649
|
title: "Update a reusable component",
|
|
5265
|
-
description: "Update a component by id \u2014 blockJson, props, name, or category. Read with get_component, then pass `ifMatch` = the version from that read. Passing `blockJson`/`props` REPLACES them. This writes the DRAFT: call publish_component to push the change live.",
|
|
5650
|
+
description: "Update a component by id \u2014 blockJson, props, name, or category. Read with get_component, then pass `ifMatch` = the version from that read. Passing `blockJson`/`props` REPLACES them. This writes the DRAFT: call publish_component to push the change live. " + UNPROPPED_COPY_NOTE,
|
|
5266
5651
|
inputSchema: updateComponentInput.shape
|
|
5267
5652
|
},
|
|
5268
5653
|
handler: guard(
|
|
@@ -5395,24 +5780,6 @@ ${lines.join("\n")}`, found);
|
|
|
5395
5780
|
})
|
|
5396
5781
|
)
|
|
5397
5782
|
},
|
|
5398
|
-
{
|
|
5399
|
-
name: "duplicate_component",
|
|
5400
|
-
config: {
|
|
5401
|
-
title: "Add a look to a component family",
|
|
5402
|
-
description: "Add a new LOOK to a component's variant family (\"+ New variant\"), e.g. a Ghost button the site does not have: copy the component into the same family and folder under `name`, with a new unique slug. `preset` restyles its button block: primary / secondary fill it with the brand kit's primary / accent colour; ghost drops the fill and border, keeps the padding and colours the text with the old fill; link is a ghost with no padding. Omit `preset` for a plain copy. Starts a family if none. outline drops the fill, keeps the padding and draws a border all round in the old fill's colour, which becomes the text colour. A look the style vocabulary cannot express is refused with 422 and the reason \u2014 relay it, never approximate it with update_component. 409 means that name is already a look in the family. When the family's contract is published and this connection may publish, the new look is published at once and is choosable in every section's style select; otherwise it is a draft: call publish_component.",
|
|
5403
|
-
inputSchema: duplicateComponentInput.shape
|
|
5404
|
-
},
|
|
5405
|
-
handler: guard(
|
|
5406
|
-
async (args) => withClient(async (client) => {
|
|
5407
|
-
const { componentId, ...body } = args;
|
|
5408
|
-
const res = await client.fetchJSON(
|
|
5409
|
-
client.url(`/management/components/${encodeURIComponent(componentId)}/duplicate`),
|
|
5410
|
-
{ method: "POST", body: JSON.stringify(body) }
|
|
5411
|
-
);
|
|
5412
|
-
return ok(`Added look '${res.data.name}' (id ${res.data.id}, ${res.data.status}).`, res.data);
|
|
5413
|
-
})
|
|
5414
|
-
)
|
|
5415
|
-
},
|
|
5416
5783
|
// ── AI content + SEO actions (Option B) ────────────────────────────────────
|
|
5417
5784
|
{
|
|
5418
5785
|
name: "write_content",
|
|
@@ -5467,30 +5834,6 @@ ${lines.join("\n")}`, found);
|
|
|
5467
5834
|
})
|
|
5468
5835
|
)
|
|
5469
5836
|
},
|
|
5470
|
-
{
|
|
5471
|
-
name: "native_bridge_heartbeat",
|
|
5472
|
-
config: {
|
|
5473
|
-
title: "Register as a native bridge and report liveness",
|
|
5474
|
-
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.",
|
|
5475
|
-
inputSchema: nativeBridgeHeartbeatInput.shape
|
|
5476
|
-
},
|
|
5477
|
-
handler: guard(
|
|
5478
|
-
async (args) => withClient(async (client) => {
|
|
5479
|
-
const result = await client.fetchJSON(client.url("/management/orchestration/bridge/heartbeat"), {
|
|
5480
|
-
method: "POST",
|
|
5481
|
-
body: JSON.stringify({
|
|
5482
|
-
protocolVersion: args.protocolVersion,
|
|
5483
|
-
capabilities: args.capabilities,
|
|
5484
|
-
...args.session ? { session: args.session } : {}
|
|
5485
|
-
})
|
|
5486
|
-
});
|
|
5487
|
-
return ok(
|
|
5488
|
-
`Bridge ${result.liveness}. Call again within ${Math.round(result.nextHeartbeatMs / 1e3)}s to stay live.`,
|
|
5489
|
-
result
|
|
5490
|
-
);
|
|
5491
|
-
})
|
|
5492
|
-
)
|
|
5493
|
-
},
|
|
5494
5837
|
// ── Lifecycle tools (parity with the remote /mcp surface) ──────────────────
|
|
5495
5838
|
// Media management, form submissions (leads), redirects, SEO, AEO site-files,
|
|
5496
5839
|
// promote, and entry version history. These call the management endpoints straight
|
|
@@ -5502,12 +5845,13 @@ ${lines.join("\n")}`, found);
|
|
|
5502
5845
|
...defs.filter((d) => LISTING_TAIL.has(d.name))
|
|
5503
5846
|
].map(withProjectId);
|
|
5504
5847
|
}
|
|
5505
|
-
function registerTools(server, deps) {
|
|
5848
|
+
function registerTools(server, deps, tools = process.env.BETTERCMS_MCP_TOOLS) {
|
|
5506
5849
|
const withElicit = {
|
|
5507
5850
|
...deps,
|
|
5508
5851
|
elicit: deps.elicit ?? ((params) => server.server.elicitInput(params))
|
|
5509
5852
|
};
|
|
5510
|
-
|
|
5853
|
+
const sets = toolsetsOf(tools);
|
|
5854
|
+
for (const def of buildToolDefs(withElicit).filter((d) => listsTool(d.name, sets))) {
|
|
5511
5855
|
server.registerTool(def.name, def.config, def.handler);
|
|
5512
5856
|
}
|
|
5513
5857
|
}
|
|
@@ -5541,7 +5885,7 @@ DERIVED at import, where the componentize lane in \xA710 reuses every field.
|
|
|
5541
5885
|
A stack of mixed, reorderable content? -> kind:'block' models + a \`modular\` field
|
|
5542
5886
|
A row in a list (post, author, tier)? -> a collection (kind:'model'); rows one template
|
|
5543
5887
|
route renders get urlPattern, e.g. '/blog/:slug'
|
|
5544
|
-
Copy for ONE page (home, about, legal)?-> page fields in sections (
|
|
5888
|
+
Copy for ONE page (home, about, legal)?-> page fields in sections (add_field {pageId} /
|
|
5545
5889
|
set_page_content) \u2014 never a one-entry collection
|
|
5546
5890
|
The one body of an article? -> type:'document' (exactly one, top level)
|
|
5547
5891
|
A fixed cluster of fields? -> group (never a 1-item repeater)
|
|
@@ -5726,7 +6070,7 @@ written to the wrong project can only be demoted (\`sectionType: null\`) and lef
|
|
|
5726
6070
|
|
|
5727
6071
|
## 10. You imported a site, or you are about to deploy one
|
|
5728
6072
|
|
|
5729
|
-
\`deploy_project
|
|
6073
|
+
\`deploy_project\` and \`promote_project\` both answer **409
|
|
5730
6074
|
AUTHORING_DECISION_REQUIRED** until a human has chosen this project's architecture. This is
|
|
5731
6075
|
asked ONCE per project, ever. It is not an error to retry or route around: read the message
|
|
5732
6076
|
out, let the user pick, call \`set_authoring_preference\`, then deploy again.
|
|
@@ -5861,7 +6205,7 @@ renders. Three consequences, each load-bearing:
|
|
|
5861
6205
|
route, in this order (get_next_steps reports the state until it is done):
|
|
5862
6206
|
|
|
5863
6207
|
a. create_page one per route, slug matching the route
|
|
5864
|
-
b.
|
|
6208
|
+
b. add_field {pageId} the fields its content needs (or create_component +
|
|
5865
6209
|
publish_component + component blocks, per your \xA710 answer)
|
|
5866
6210
|
c. set_page_content values EXACTLY equal to the text the site renders \u2014
|
|
5867
6211
|
binding matches by value, so a paraphrase binds nothing
|
|
@@ -6030,8 +6374,8 @@ assembled from registered components?": per page its blocks and every component
|
|
|
6030
6374
|
that component's status, plus site totals \u2014 unpublished, awaiting evidence, awaiting approval,
|
|
6031
6375
|
missing from the picker, placed nowhere \u2014 and \`verdict.everyPageComposed\` with the reasons it
|
|
6032
6376
|
is false. A component reaches a visitor only once PUBLISHED, and publishing needs the project
|
|
6033
|
-
owner's approval in the DASHBOARD: evidence
|
|
6034
|
-
|
|
6377
|
+
owner's approval in the DASHBOARD: evidence comes from \`request_component_validation\` and a
|
|
6378
|
+
validation run in the repository, the owner approves there, then \`publish_components\`. There is no
|
|
6035
6379
|
bulk approve, and no tool here can grant that approval. Finish with \`get_next_steps\`.
|
|
6036
6380
|
|
|
6037
6381
|
**Order of operations.**
|
|
@@ -6041,10 +6385,10 @@ bulk approve, and no tool here can grant that approval. Finish with \`get_next_s
|
|
|
6041
6385
|
3. componentize_sections dryRun first, then for real
|
|
6042
6386
|
3b. npx @bettercms-ai/convert --componentize --receipt componentize.json, push,
|
|
6043
6387
|
then submit_componentize_receipt { receipt } every section's Output source, ONE call
|
|
6044
|
-
4.
|
|
6388
|
+
4. create_component each section the plan did not cover, each with a group
|
|
6045
6389
|
5. compose_pages every page's blockJson, ONE call
|
|
6046
6390
|
6. update_layout { commands } nav/footer chrome + add-component, ONE call
|
|
6047
|
-
7. evidence
|
|
6391
|
+
7. evidence request_component_validation -> the repo's validation run
|
|
6048
6392
|
8. owner approves in the dashboard, then publish_components ONE call, per-item receipts
|
|
6049
6393
|
9. update_page status:'published', publish_layout
|
|
6050
6394
|
10. get_site_composition, then get_next_steps
|
|
@@ -6093,7 +6437,7 @@ either way.
|
|
|
6093
6437
|
**If you know Sanity, this is the same shape under different names:**
|
|
6094
6438
|
|
|
6095
6439
|
defineType schema in code -> content models / page fields (create_content_model,
|
|
6096
|
-
|
|
6440
|
+
add_field, or bcms-content.json "schema")
|
|
6097
6441
|
TypeGen -> @bettercms-ai/codegen
|
|
6098
6442
|
GROQ query in the page -> @bettercms-ai/sdk read client, or the bcms-content.json
|
|
6099
6443
|
build snapshot
|
|
@@ -6127,8 +6471,9 @@ hand. THE ORDER, and every step of it matters:
|
|
|
6127
6471
|
fallback, and declares each binding.
|
|
6128
6472
|
2b. IF THE BRIEF CARRIES \`forms\`, wire them too:
|
|
6129
6473
|
\`npx @bettercms-ai/convert --forms --brief brief.json --root . --receipt forms-receipt.json\`
|
|
6130
|
-
\u2014 ITS OWN receipt file, submitted with submit_conversion_receipt as a second call
|
|
6131
|
-
|
|
6474
|
+
\u2014 ITS OWN receipt file, submitted with submit_conversion_receipt as a second call under the
|
|
6475
|
+
SAME \`briefDigest\` (its \`paths\` are all zero): the server stores its \`forms\` block
|
|
6476
|
+
beside the binding receipt (never counted in coverage), and
|
|
6132
6477
|
the next release reads \`wiredForms\` to say which component renders each form.
|
|
6133
6478
|
A site imported by phase 1 has a DRAFT form row per \`<form>\` in its build, so the Forms tab
|
|
6134
6479
|
is full while the repository's markup still posts wherever it always did \u2014 to nothing, or to
|
|
@@ -6149,6 +6494,11 @@ hand. THE ORDER, and every step of it matters:
|
|
|
6149
6494
|
in the data file or collection entry it names) or \`manual\` (do what its \`why\` says). A row
|
|
6150
6495
|
with no \`fix\` is still yours. Then RERUN step 2 and repeat until \`paths.pending\` is empty \u2014
|
|
6151
6496
|
the codemod is idempotent, so a second run over converted source rewrites nothing.
|
|
6497
|
+
3b. THE SITE'S DESIGN IS NOT YOURS TO CHANGE. The \`--verify-styles\` pass of the same codemod
|
|
6498
|
+
(\`--root .\`, with \`--base\` set to the last commit before you started converting) lists every
|
|
6499
|
+
\`class\`, \`className\` and \`style\` the conversion altered as \`styleEdits\`; they travel
|
|
6500
|
+
inside the receipt, and a non-empty list is refused with 409 \`style-edits\` naming every
|
|
6501
|
+
token. Restore them in the source, rerun, resubmit.
|
|
6152
6502
|
4. \`submit_conversion_receipt { briefDigest, receipt }\` with the receipt file, verbatim. It
|
|
6153
6503
|
answers \`complete\`, \`pendingTotal\` and the first 40 pending rows with their \`fix\`. \`complete: true\` \u2014 nothing
|
|
6154
6504
|
pending \u2014 is the ONLY receipt that ends a conversion; on anything less go back to step 3. It
|
|
@@ -6180,7 +6530,7 @@ conversion is yours to write.
|
|
|
6180
6530
|
reconstruct content from the deployed HTML \u2014 that is how a site ends up bound to a copy of
|
|
6181
6531
|
its own stale build.
|
|
6182
6532
|
2. Decide the architecture WITH the human (\xA710) and record it: \`set_authoring_preference\`.
|
|
6183
|
-
3. Register the schema. Per route: \`create_page\` + \`
|
|
6533
|
+
3. Register the schema. Per route: \`create_page\` + \`add_field\` { pageId } (text / longtext /
|
|
6184
6534
|
richtext / image / array groups). Per repeated content type: \`create_content_model\`, with a
|
|
6185
6535
|
\`document\` body for articles. Site chrome goes through \`update_layout\`; images through
|
|
6186
6536
|
\`create_media_upload\` / \`upload_asset\`, then reference the CMS URL. The repo-owned
|
|
@@ -6237,7 +6587,7 @@ conversion is yours to write.
|
|
|
6237
6587
|
\`BCMS_API_KEY_PROD\`, is the validation workflows' key; they fall back to the shared
|
|
6238
6588
|
\`BCMS_API_KEY\` only for a repo whose last connect predates it.) Read that file, with in-code
|
|
6239
6589
|
fallbacks so a local or CI build without it still renders.
|
|
6240
|
-
- ARCHIVE (\`deploy_project
|
|
6590
|
+
- ARCHIVE (\`deploy_project\`, inline or by \`uploadKey\`): the sandbox build runs with no env
|
|
6241
6591
|
and no network content step, by design. So the archive MUST SHIP its own
|
|
6242
6592
|
\`bcms-content.json\` \u2014 generate it locally with a delivery key and commit it. Without
|
|
6243
6593
|
one the site renders its fallbacks and the report says \`no-element\` for every path.
|
|
@@ -6278,7 +6628,7 @@ conversion is yours to write.
|
|
|
6278
6628
|
|
|
6279
6629
|
You can create the schema. You can also say how it PRESENTS to the person filling it in. These
|
|
6280
6630
|
three props ride on any field, on any field-creating tool (\`create_content_model\`,
|
|
6281
|
-
\`create_page\`, \`add_field
|
|
6631
|
+
\`create_page\`, \`add_field\`), and none of them can move, reshape or hide a
|
|
6282
6632
|
delivered value \u2014 they change editor chrome only.
|
|
6283
6633
|
|
|
6284
6634
|
\`\`\`jsonc
|
|
@@ -6417,7 +6767,7 @@ Importance orders a screen: required fields first, then array order.
|
|
|
6417
6767
|
Name props from the code (\`plan.cents\` \u2192 Cents), help text as \`e.g. "49"\`; repeated cards are one \`table\` prop.
|
|
6418
6768
|
They write the DRAFT props; no publish is needed for the editor to show them.
|
|
6419
6769
|
- Always send \`ifMatch\`, the \`optimisticVersion\` you read.
|
|
6420
|
-
- A NEW field carries its chrome in the same \`add_field\`
|
|
6770
|
+
- A NEW field carries its chrome in the same \`add_field\` call.
|
|
6421
6771
|
|
|
6422
6772
|
**The dock's refusals, each named in the message.**
|
|
6423
6773
|
- **428** \`precondition-required\` \u2014 a chrome key without \`ifMatch\`. Read, then send it.
|
|
@@ -6429,6 +6779,9 @@ Importance orders a screen: required fields first, then array order.
|
|
|
6429
6779
|
fieldset its fields still name.
|
|
6430
6780
|
- \`row_member_type\`, \`row_member_count\`, \`row_member_control\` \u2014 the row rule.
|
|
6431
6781
|
- \`pair_split\` \u2014 a button's label and href in different fieldsets.
|
|
6782
|
+
- \`cta_pair\` \u2014 a NEW text field beside a link keyed like it plus \`-href\` (\`hero-span-go\` + \`hero-a-go-href\`): one
|
|
6783
|
+
call to action. Add ONE \`type: "button"\` field (value \`{ label, href }\`); on a component, point both props
|
|
6784
|
+
at one button block.
|
|
6432
6785
|
- \`ui.icon\` or \`ui.itemLabel\` on a leaf; an icon name not in \`bettercms://icons/dock\`.
|
|
6433
6786
|
- \`layout_grid\` \u2014 a grid with no image or file child. \`layout_table\` \u2014 more than 3 columns, or a
|
|
6434
6787
|
column that is not a scalar.
|
|
@@ -6444,8 +6797,8 @@ stored, whatever the write returned \u2014 \xA711 RECEIPTS rule 3, applied to th
|
|
|
6444
6797
|
and \`dock-showif-order\` must all be gone. Declare, then read back, then say it works.
|
|
6445
6798
|
|
|
6446
6799
|
**No \`order\` property. Ever.** A field's order IS its index in \`fields\`. To put a new field
|
|
6447
|
-
somewhere other than the end, pass \`after: '<existing top-level field key>'\` to \`add_field
|
|
6448
|
-
|
|
6800
|
+
somewhere other than the end, pass \`after: '<existing top-level field key>'\` to \`add_field\`;
|
|
6801
|
+
omit it and the field appends, exactly as before. \`after\` is an argument
|
|
6449
6802
|
applied once at the write \u2014 a stored ordering prop would be a second source of truth that
|
|
6450
6803
|
disagrees with the array the moment anyone drags a row in the builder.
|
|
6451
6804
|
|
|
@@ -6511,7 +6864,7 @@ Do not stop at the plan or at a partial receipt \u2014 finish the whole site, ne
|
|
|
6511
6864
|
with no \`fix\` is still yours. Then rerun step 4 until \`paths.pending\` is EMPTY.
|
|
6512
6865
|
6. \`npx @bettercms-ai/convert --verify-styles --base <commit before step 4>\`, restore what it lists, then
|
|
6513
6866
|
\`submit_conversion_receipt { briefDigest, receipt }\` with its \`styleEdits\`: only \`complete: true\` ends it (else step 5).
|
|
6514
|
-
7. Deploy the working tree (\`create_deploy_upload\`
|
|
6867
|
+
7. Deploy the working tree (\`create_deploy_upload\`, then \`deploy_project\` with its \`uploadKey\`); poll \`get_deploy_status\` until live. No git yet.
|
|
6515
6868
|
8. \`get_binding_report\`: \`unmatched\` 0, \`unaddressable\` under 2% per route; fix the SOURCE, deploy, re-read (playbook \xA711).
|
|
6516
6869
|
9. \`set_binding_mode { declaredBindings: true }\`, deploy AGAIN and poll, then confirm \`get_binding_report\`
|
|
6517
6870
|
reads mode "declared", unmatched 0, bound above 0, \`coverage.pending\` empty. Send me that report.
|
|
@@ -6539,7 +6892,7 @@ shape, then ask me, once and batched). Icon names are in bettercms://icons/dock.
|
|
|
6539
6892
|
fieldHelpText by field path, plus \`fieldsets\`. A singleton page: send \`pageId\` instead of
|
|
6540
6893
|
modelId, with its backingModel's version;
|
|
6541
6894
|
- component props: \`update_component\` propUi / propGroup / propLabel / propHelpText;
|
|
6542
|
-
- a NEW field: ui / helpText / showIf in the same \`add_field\`
|
|
6895
|
+
- a NEW field: ui / helpText / showIf in the same \`add_field\` call, with \`after\`.
|
|
6543
6896
|
A 409 \`ui_human_owned\` names chrome a person set: leave it, or ask me and send \`override: true\`
|
|
6544
6897
|
only on my word.
|
|
6545
6898
|
4. **RECEIPT, non-negotiable.** Re-read every target and confirm the stored chrome matches what you
|
|
@@ -6581,15 +6934,15 @@ Author a page (the page-first schema). Ask, in order, via AskUserQuestion:
|
|
|
6581
6934
|
4. **Review** the assembled tree, then call \`create_page\` with
|
|
6582
6935
|
{ title, slug, pageType, fields, metaTitle?, metaDescription? }. Field object:
|
|
6583
6936
|
{ key, label, type, required?, options?, config?, fields? }.`;
|
|
6584
|
-
var FIELD_FLOW = `### Add a field \u2192 \`add_field\` (
|
|
6937
|
+
var FIELD_FLOW = `### Add a field \u2192 \`add_field\` (a model or a page)
|
|
6585
6938
|
Append a field to an existing schema. First decide the target: a **content model**
|
|
6586
6939
|
or a **page** (Home, About, a blog template). Ask: which target (id \u2014 use
|
|
6587
6940
|
\`list_pages\` to find a page id), then the field (key, label, type \u2014 any of the 13
|
|
6588
6941
|
above, including nested group/repeater), required?. Confirm, then call:
|
|
6589
6942
|
- a model \u2192 \`add_field\` { modelId, key, label, type, ... }
|
|
6590
|
-
- a page \u2192 \`
|
|
6591
|
-
|
|
6592
|
-
an existing field
|
|
6943
|
+
- a page \u2192 \`add_field\` { pageId, key, label, type, ... }
|
|
6944
|
+
It is additive: it rejects a key that already exists and never retypes/overwrites
|
|
6945
|
+
an existing field \u2014 change one of those with \`update_field\` { path, \u2026 }.`;
|
|
6593
6946
|
var ENTRY_FLOW = `### Create an entry \u2192 \`create_content_entry\` tool
|
|
6594
6947
|
Create a content entry under a model. Ask: which model (id), the field values (data),
|
|
6595
6948
|
status (draft/published). Then call \`create_content_entry\` { contentModelId, data?, status?, slug? }.`;
|
|
@@ -6662,11 +7015,11 @@ Read \`bettercms://playbook/schema\` \xA712 first \u2014 this is the short form
|
|
|
6662
7015
|
restyle. Dry-run first.
|
|
6663
7016
|
6. **Validation** = \`get_site_composition\`: every page's placements, each component's status,
|
|
6664
7017
|
and \`verdict.everyPageComposed\` with its reasons. Publishing needs the OWNER's approval in
|
|
6665
|
-
the dashboard (evidence via \`
|
|
7018
|
+
the dashboard (evidence via \`request_component_validation\` and a run in the repository), then
|
|
6666
7019
|
\`publish_components\`. No tool here can grant that approval, and there is no bulk approve.
|
|
6667
7020
|
|
|
6668
7021
|
**Order:** get_site_composition \u2192 (get_componentize_plan \u2192 componentize_sections) \u2192
|
|
6669
|
-
|
|
7022
|
+
create_component per uncovered section \u2192 compose_pages \u2192 update_layout { commands } \u2192 evidence + owner approval \u2192
|
|
6670
7023
|
publish_components \u2192 publish the pages / publish_layout \u2192 get_site_composition \u2192 get_next_steps.
|
|
6671
7024
|
Prefer the batch tools whenever there is more than three of anything.`;
|
|
6672
7025
|
var LAYOUT_FLOW = `### Project/page Layout authoring \u2192 \`get_layout\` / \`update_layout\` / \`publish_layout\`
|
|
@@ -7070,7 +7423,7 @@ Read bettercms://playbook/schema section 11 first, then work this order:
|
|
|
7070
7423
|
|
|
7071
7424
|
1. AUTHORING GATE \u2014 if any deploy answered 409 AUTHORING_DECISION_REQUIRED, ask ME
|
|
7072
7425
|
components-or-fields (do not choose), then set_authoring_preference.
|
|
7073
|
-
2. CONTENT INTO THE CMS, per route: create_page (slug = route) ->
|
|
7426
|
+
2. CONTENT INTO THE CMS, per route: create_page (slug = route) -> add_field {pageId} (or
|
|
7074
7427
|
create_component + publish_component + component blocks) -> set_page_content with values
|
|
7075
7428
|
EXACTLY equal to the rendered text (binding matches by value; a paraphrase binds nothing)
|
|
7076
7429
|
-> update_page status 'published' (the canvas binds the PUBLISHED copy).
|
|
@@ -7100,6 +7453,67 @@ On 401/403, the MCP key needs (re)authorizing.`
|
|
|
7100
7453
|
);
|
|
7101
7454
|
}
|
|
7102
7455
|
|
|
7456
|
+
// src/skill-docs.ts
|
|
7457
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
7458
|
+
import { join } from "path";
|
|
7459
|
+
import { fileURLToPath } from "url";
|
|
7460
|
+
var SKILL_URI_PREFIX = "bettercms://skill/";
|
|
7461
|
+
var CORE_SKILL = "bettercms";
|
|
7462
|
+
var CANDIDATES = ["./skills/bettercms/", "../../skills/bettercms/"];
|
|
7463
|
+
function defaultSkillDir() {
|
|
7464
|
+
for (const rel of CANDIDATES) {
|
|
7465
|
+
const dir = fileURLToPath(new URL(rel, import.meta.url));
|
|
7466
|
+
if (existsSync(join(dir, "SKILL.md"))) return dir;
|
|
7467
|
+
}
|
|
7468
|
+
return null;
|
|
7469
|
+
}
|
|
7470
|
+
function frontmatterOf(text2) {
|
|
7471
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(text2);
|
|
7472
|
+
if (!m) return {};
|
|
7473
|
+
const out = {};
|
|
7474
|
+
const lines = m[1].split(/\r?\n/);
|
|
7475
|
+
for (let i = 0; i < lines.length; i++) {
|
|
7476
|
+
const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(lines[i]);
|
|
7477
|
+
if (!kv) continue;
|
|
7478
|
+
let value = kv[2].trim();
|
|
7479
|
+
if (value === ">" || value === "|" || value === ">-" || value === "|-") {
|
|
7480
|
+
const block = [];
|
|
7481
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) block.push(lines[++i].trim());
|
|
7482
|
+
value = block.join(value.startsWith(">") ? " " : "\n");
|
|
7483
|
+
} else if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
7484
|
+
value = value.slice(1, -1).replace(/\\"/g, '"');
|
|
7485
|
+
}
|
|
7486
|
+
if (value) out[kv[1]] = value;
|
|
7487
|
+
}
|
|
7488
|
+
return out;
|
|
7489
|
+
}
|
|
7490
|
+
function skillDocOf(stem, text2) {
|
|
7491
|
+
const fm = frontmatterOf(text2);
|
|
7492
|
+
const heading = /^#\s+(.+?)\s*$/m.exec(text2)?.[1];
|
|
7493
|
+
const title = fm.title ?? heading ?? stem;
|
|
7494
|
+
const description = fm.description ?? heading;
|
|
7495
|
+
return {
|
|
7496
|
+
uri: `${SKILL_URI_PREFIX}${stem}`,
|
|
7497
|
+
name: `skill-${stem}`,
|
|
7498
|
+
title,
|
|
7499
|
+
...description ? { description } : {},
|
|
7500
|
+
mimeType: "text/markdown",
|
|
7501
|
+
text: text2
|
|
7502
|
+
};
|
|
7503
|
+
}
|
|
7504
|
+
function loadSkillDocs(dir = defaultSkillDir()) {
|
|
7505
|
+
if (!dir) return [];
|
|
7506
|
+
const docs = [skillDocOf(CORE_SKILL, readFileSync(join(dir, "SKILL.md"), "utf8"))];
|
|
7507
|
+
const refs = join(dir, "references");
|
|
7508
|
+
if (!existsSync(refs)) return docs;
|
|
7509
|
+
for (const file of readdirSync(refs).filter((f) => f.endsWith(".md")).sort()) {
|
|
7510
|
+
const stem = file.slice(0, -3);
|
|
7511
|
+
if (stem === CORE_SKILL) continue;
|
|
7512
|
+
docs.push(skillDocOf(stem, readFileSync(join(refs, file), "utf8")));
|
|
7513
|
+
}
|
|
7514
|
+
return docs;
|
|
7515
|
+
}
|
|
7516
|
+
|
|
7103
7517
|
// src/server.ts
|
|
7104
7518
|
var SERVER_NAME = "bettercms";
|
|
7105
7519
|
var SERVER_VERSION = "1.5.0";
|
|
@@ -7111,6 +7525,12 @@ var SERVER_DISPLAY = {
|
|
|
7111
7525
|
{ src: "https://api.bettercms.ai/brand/mark.svg", mimeType: "image/svg+xml", sizes: ["any"] }
|
|
7112
7526
|
]
|
|
7113
7527
|
};
|
|
7528
|
+
function withBranch(client, branch) {
|
|
7529
|
+
if (!branch) return client;
|
|
7530
|
+
const base = client.headers.bind(client);
|
|
7531
|
+
client.headers = () => ({ ...base(), "X-Branch-Id": branch });
|
|
7532
|
+
return client;
|
|
7533
|
+
}
|
|
7114
7534
|
function buildServer(deps) {
|
|
7115
7535
|
const server = new McpServer(
|
|
7116
7536
|
{ name: SERVER_NAME, version: SERVER_VERSION, ...SERVER_DISPLAY },
|
|
@@ -7119,7 +7539,7 @@ function buildServer(deps) {
|
|
|
7119
7539
|
// 🔴 The plain-language ask, routed — the stdio twin of the hosted connector's line
|
|
7120
7540
|
// (src/routes/mcp/index.ts MCP_INSTRUCTIONS). "Make my site editable" has one recipe and
|
|
7121
7541
|
// one definition of done; without this an agent converts the page it landed on and stops.
|
|
7122
|
-
instructions: RECEIPT_FIRST_INSTRUCTION + " When the user asks to make a site or all of its pages editable, to convert it, or to bind its fields: this is playbook \xA713. Read `bettercms://playbook/schema` \xA713, call get_binding_report and get_conversion_brief { complete: true }, convert EVERY route the brief lists, and finish only when get_binding_report shows coverage.pending empty on every route \u2014 not when the first page works. On a workspace-wide connection pass projectId on every call; never ask the user to re-scope the connection. " + LAYOUT_CHROME_INSTRUCTION + " When the user asks to componentize the whole site, to turn every section into a component, or to build a component library from the site: this is playbook \xA712. Read `bettercms://playbook/schema` \xA712, start with get_site_composition, and use the batch tools \u2014
|
|
7542
|
+
instructions: RECEIPT_FIRST_INSTRUCTION + " When the user asks to make a site or all of its pages editable, to convert it, or to bind its fields: this is playbook \xA713. Read `bettercms://playbook/schema` \xA713, call get_binding_report and get_conversion_brief { complete: true }, convert EVERY route the brief lists, and finish only when get_binding_report shows coverage.pending empty on every route \u2014 not when the first page works. On a workspace-wide connection pass projectId on every call; never ask the user to re-scope the connection. " + LAYOUT_CHROME_INSTRUCTION + " When the user asks to componentize the whole site, to turn every section into a component, or to build a component library from the site: this is playbook \xA712. Read `bettercms://playbook/schema` \xA712, start with get_site_composition, and use the batch tools \u2014 componentize_sections, compose_pages, update_layout with `commands`, publish_components \u2014 rather than one call per component. Finish with get_site_composition and tell the user what the platform does not model (cookie banners, modals, breadcrumbs, pagination) and which components still need the owner's approval in the dashboard before they can be published. " + // The default after authoring (the structure standard): organise what you made. Same
|
|
7123
7543
|
// sentence as the hosted connector's MCP_INSTRUCTIONS.
|
|
7124
7544
|
STRUCTURE_DEFAULT_INSTRUCTION + " " + SKILLS_ROUTING_INSTRUCTION + " " + DOCK_UI_ROUTING_INSTRUCTION
|
|
7125
7545
|
}
|
|
@@ -7152,12 +7572,15 @@ function buildServer(deps) {
|
|
|
7152
7572
|
server.registerResource(dockIconsName, dockIconsUri, dockIconsMeta, () => ({
|
|
7153
7573
|
contents: [{ uri: dockIconsUri, mimeType: dockIconsMeta.mimeType, text: DOCK_ICONS_TEXT }]
|
|
7154
7574
|
}));
|
|
7575
|
+
for (const { uri, name, text: text2, ...meta } of loadSkillDocs()) {
|
|
7576
|
+
server.registerResource(name, uri, meta, () => ({ contents: [{ uri, mimeType: meta.mimeType, text: text2 }] }));
|
|
7577
|
+
}
|
|
7155
7578
|
registerTools(server, {
|
|
7156
7579
|
auth: deps.auth,
|
|
7157
7580
|
// `project` is the per-call target of a workspace-wide grant; the SDK sends it as
|
|
7158
7581
|
// X-BCMS-Project. Omitted entirely when absent so a project-scoped key's request is
|
|
7159
7582
|
// byte-identical to before.
|
|
7160
|
-
createClient: (apiKey, project) => BetterCMS.management({ apiKey, baseUrl: deps.managementBaseUrl, ...project ? { project } : {} })
|
|
7583
|
+
createClient: (apiKey, project, branch) => withBranch(BetterCMS.management({ apiKey, baseUrl: deps.managementBaseUrl, ...project ? { project } : {} }), branch)
|
|
7161
7584
|
});
|
|
7162
7585
|
registerPrompts(server);
|
|
7163
7586
|
return server;
|
|
@@ -7179,7 +7602,7 @@ function isMainModule() {
|
|
|
7179
7602
|
const argv1 = process.argv[1];
|
|
7180
7603
|
if (!argv1) return false;
|
|
7181
7604
|
try {
|
|
7182
|
-
return realpathSync(argv1) ===
|
|
7605
|
+
return realpathSync(argv1) === fileURLToPath2(import.meta.url);
|
|
7183
7606
|
} catch {
|
|
7184
7607
|
return false;
|
|
7185
7608
|
}
|