@bettercms-ai/mcp 0.41.1 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +158 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2273,6 +2273,11 @@ function toField(f) {
|
|
|
2273
2273
|
...f.config ? { config: f.config } : {}
|
|
2274
2274
|
};
|
|
2275
2275
|
}
|
|
2276
|
+
var STRUCTURE_NOTE = 'ORGANISATION ONLY: folders never change a URL, slug, content or publish state, and nothing is rebuilt or delivered. Folders nest at most 4 levels deep (a page or collection may sit in a level-4 folder). Icons are lucide icon names, e.g. "folder", "file-text", "rows-3", "house" (a-z, 0-9 and dashes); null clears one.';
|
|
2277
|
+
function structureResult(d) {
|
|
2278
|
+
const message = d?.message;
|
|
2279
|
+
return ok(typeof message === "string" ? message : "Updated the Content structure.", d);
|
|
2280
|
+
}
|
|
2276
2281
|
function ok(summary, data) {
|
|
2277
2282
|
return {
|
|
2278
2283
|
content: [
|
|
@@ -2927,6 +2932,94 @@ function buildToolDefs(deps) {
|
|
|
2927
2932
|
z.object({ formId: z.string().min(1), submissionId: z.string().min(1) }).shape,
|
|
2928
2933
|
async (c, a) => ok("Deleted submission.", await data(c, "DELETE", `/management/forms/${s(a.formId)}/submissions/${s(a.submissionId)}`))
|
|
2929
2934
|
),
|
|
2935
|
+
// ── Content structure: the Content sidebar's folders and icons (CPO-127 f) ──
|
|
2936
|
+
def(
|
|
2937
|
+
"get_content_structure",
|
|
2938
|
+
"Get the Content sidebar structure",
|
|
2939
|
+
`Read the Content sidebar's structure for the connected project: its folders, which pages and collections sit in each, and their icons. 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. ${STRUCTURE_NOTE}`,
|
|
2940
|
+
z.object({}).shape,
|
|
2941
|
+
async (c) => {
|
|
2942
|
+
const d = await data(c, "GET", `/management/content-structure`);
|
|
2943
|
+
return ok(d?.summary?.outline ? `Content structure:
|
|
2944
|
+
${d.summary.outline}` : "Content structure.", d);
|
|
2945
|
+
}
|
|
2946
|
+
),
|
|
2947
|
+
def(
|
|
2948
|
+
"set_content_structure",
|
|
2949
|
+
"Replace the Content sidebar structure",
|
|
2950
|
+
`Replace the whole Content sidebar document in one write. Prefer the single-action tools (create_folder, rename_folder, move_to_folder, set_icon, delete_folder) for small changes; use this to lay out a full structure at once. Send the complete \`doc\` and the \`version\` get_content_structure returned (0 when the project has none yet). Validated exactly like the dashboard: unique folder ids, every parentId/folderId must be a folder in the doc, no cycles, depth at most 4, names 1-80 chars, lucide icon names. A 412 VERSION_CONFLICT means someone changed it since you read it: call get_content_structure again and re-apply your change. Returns the new version and a diff (folders added, removed, renamed or moved; items moved; icons changed). ${STRUCTURE_NOTE}`,
|
|
2951
|
+
z.object({
|
|
2952
|
+
doc: z.object({
|
|
2953
|
+
schema: z.literal(1),
|
|
2954
|
+
folders: z.array(z.object({
|
|
2955
|
+
id: z.string().min(1).describe("your own stable id, up to 64 chars"),
|
|
2956
|
+
parentId: z.string().nullable().describe("parent folder id; null = top level"),
|
|
2957
|
+
name: z.string(),
|
|
2958
|
+
icon: z.string().nullable().describe("lucide icon name or null"),
|
|
2959
|
+
sort: z.number()
|
|
2960
|
+
})),
|
|
2961
|
+
items: z.array(z.object({
|
|
2962
|
+
kind: z.enum(["page", "collection"]),
|
|
2963
|
+
id: z.string().min(1).describe("page id or collection (content model) id"),
|
|
2964
|
+
folderId: z.string().nullable().describe("folder id; null = its home (Static pages for a page, the Collections list for a collection)"),
|
|
2965
|
+
sort: z.number(),
|
|
2966
|
+
icon: z.string().nullable()
|
|
2967
|
+
}))
|
|
2968
|
+
}).describe("the complete document"),
|
|
2969
|
+
version: z.number().int().min(0).describe("the version get_content_structure returned")
|
|
2970
|
+
}).shape,
|
|
2971
|
+
async (c, a) => structureResult(await data(c, "PUT", `/management/content-structure`, { doc: a.doc, version: a.version }))
|
|
2972
|
+
),
|
|
2973
|
+
def(
|
|
2974
|
+
"create_folder",
|
|
2975
|
+
"Create a Content sidebar folder",
|
|
2976
|
+
`Create a folder in the Content sidebar, at the top level or inside \`parentId\`, placed after what is already there. Returns the new \`folderId\`. Reads, applies and writes with the version check, retrying once on a conflict. ${STRUCTURE_NOTE}`,
|
|
2977
|
+
z.object({
|
|
2978
|
+
name: z.string().min(1).describe("the folder's label, 1-80 chars"),
|
|
2979
|
+
parentId: z.string().min(1).optional().describe("optional parent folder id (from get_content_structure); omit for the top level"),
|
|
2980
|
+
icon: z.string().min(1).optional().describe("optional lucide icon name, e.g. 'folder'")
|
|
2981
|
+
}).shape,
|
|
2982
|
+
async (c, a) => structureResult(await data(c, "POST", `/management/content-structure/folders`, { name: a.name, parentId: a.parentId, icon: a.icon }))
|
|
2983
|
+
),
|
|
2984
|
+
def(
|
|
2985
|
+
"rename_folder",
|
|
2986
|
+
"Rename a Content sidebar folder",
|
|
2987
|
+
`Rename a Content sidebar folder. ${STRUCTURE_NOTE}`,
|
|
2988
|
+
z.object({
|
|
2989
|
+
folderId: z.string().min(1).describe("folder id (from get_content_structure)"),
|
|
2990
|
+
name: z.string().min(1).describe("the new label, 1-80 chars")
|
|
2991
|
+
}).shape,
|
|
2992
|
+
async (c, a) => structureResult(await data(c, "PATCH", `/management/content-structure/folders/${encodeURIComponent(s(a.folderId))}`, { name: a.name }))
|
|
2993
|
+
),
|
|
2994
|
+
def(
|
|
2995
|
+
"move_to_folder",
|
|
2996
|
+
"Move into a Content sidebar folder",
|
|
2997
|
+
`Move a page, a collection or a folder into a folder. folderId null moves it back to its home: Static pages for a page, the Collections 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_NOTE}`,
|
|
2998
|
+
z.object({
|
|
2999
|
+
kind: z.enum(["page", "collection", "folder"]),
|
|
3000
|
+
id: z.string().min(1).describe("the page id, collection (content model) id, or folder id"),
|
|
3001
|
+
folderId: z.string().min(1).nullable().describe("target folder id, or null to move it back to its home (Static pages, the Collections list, or the top level for a folder)")
|
|
3002
|
+
}).shape,
|
|
3003
|
+
async (c, a) => structureResult(await data(c, "POST", `/management/content-structure/move`, { kind: a.kind, id: a.id, folderId: a.folderId }))
|
|
3004
|
+
),
|
|
3005
|
+
def(
|
|
3006
|
+
"set_icon",
|
|
3007
|
+
"Set a Content sidebar icon",
|
|
3008
|
+
`Set or clear the sidebar icon of a page, a collection or a folder. ${STRUCTURE_NOTE}`,
|
|
3009
|
+
z.object({
|
|
3010
|
+
kind: z.enum(["page", "collection", "folder"]),
|
|
3011
|
+
id: z.string().min(1).describe("the page id, collection (content model) id, or folder id"),
|
|
3012
|
+
icon: z.string().min(1).nullable().describe("lucide icon name, e.g. 'file-text'; null clears it")
|
|
3013
|
+
}).shape,
|
|
3014
|
+
async (c, a) => structureResult(await data(c, "POST", `/management/content-structure/icon`, { kind: a.kind, id: a.id, icon: a.icon }))
|
|
3015
|
+
),
|
|
3016
|
+
def(
|
|
3017
|
+
"delete_folder",
|
|
3018
|
+
"Delete a folder (its contents move up; nothing is deleted)",
|
|
3019
|
+
`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_NOTE}`,
|
|
3020
|
+
z.object({ folderId: z.string().min(1).describe("folder id (from get_content_structure)") }).shape,
|
|
3021
|
+
async (c, a) => structureResult(await data(c, "DELETE", `/management/content-structure/folders/${encodeURIComponent(s(a.folderId))}`))
|
|
3022
|
+
),
|
|
2930
3023
|
def(
|
|
2931
3024
|
"list_redirects",
|
|
2932
3025
|
"List redirects",
|
|
@@ -3107,7 +3200,7 @@ function buildToolDefs(deps) {
|
|
|
3107
3200
|
def(
|
|
3108
3201
|
"submit_conversion_receipt",
|
|
3109
3202
|
"Record what the conversion codemod could and could not do",
|
|
3110
|
-
"Hand BetterCMS the codemod's own account of a conversion run, so the coverage meter can say WHY a path is not declared instead of only that it is not. Submit the receipt `npx @bettercms-ai/convert` wrote (`--receipt out.json`) for the SAME `briefDigest` get_conversion_brief { complete: true } returned: `{ briefDigest, receipt }`, where the receipt carries `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message }] }`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length, and each pending `reason` is one of the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026) \u2014 a path with no receipt row simply reads `not-declared`. It is a RECORD, not a release: it changes nothing about the site, and the meter picks it up on the next get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here, so convert against a brief this project actually returned. \u{1F534} SUBMIT THE BINDING RECEIPT, NOT THE `--forms` ONE. A run of `npx @bettercms-ai/convert --forms` writes a receipt whose `paths` are all zero and whose account is in a `forms` block (`{ wired, alreadyWired, pending[{ id, name, reason }], notes }`); it passes this endpoint's arithmetic and would overwrite the real coverage with zeros. Write it to its own file (`--receipt forms-receipt.json`), read `forms.pending` and publish every form `forms.notes` names in the Forms tab, and submit the binding run's receipt here. Requires artifact:write, the same authority as set_binding_mode; on a workspace-wide connection pass `projectId`.",
|
|
3203
|
+
"Hand BetterCMS the codemod's own account of a conversion run, so the coverage meter can say WHY a path is not declared instead of only that it is not. Submit the receipt `npx @bettercms-ai/convert` wrote (`--receipt out.json`) for the SAME `briefDigest` get_conversion_brief { complete: true } returned: `{ briefDigest, receipt }`, where the receipt carries `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message, fix }] }`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length, and each pending `reason` is one of the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026) \u2014 a path with no receipt row simply reads `not-declared`. \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE. The response answers `complete` and `pendingTotal`, and echoes the first 40 pending rows WITH their `fix` \u2014 `{ action, file, line, col?, snippet, why? }`, where `action` is one of `wrap-span` (wrap the literal in a `<span data-bcms-field=\u2026>`), `declare-attr` (add `data-bcms-field=\u2026` to the element at file:line), `bind-expression` (replace the expression with the framework's bcmsField helper), `declare-richtext` (bind the container with the richtext helper), `bind-data` (the literal comes from the data file at file:line \u2014 bind that field) or `manual` (with a one-sentence `why`). Apply every fix in the source, rerun the codemod, resubmit. `complete: true` is the only receipt that ends a conversion \u2014 do not report a site converted on anything less. It is a RECORD, not a release: it changes nothing about the site, and the meter picks it up on the next get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here, so convert against a brief this project actually returned. \u{1F534} SUBMIT THE BINDING RECEIPT, NOT THE `--forms` ONE. A run of `npx @bettercms-ai/convert --forms` writes a receipt whose `paths` are all zero and whose account is in a `forms` block (`{ wired, alreadyWired, pending[{ id, name, reason }], notes }`); it passes this endpoint's arithmetic and would overwrite the real coverage with zeros. Write it to its own file (`--receipt forms-receipt.json`), read `forms.pending` and publish every form `forms.notes` names in the Forms tab, and submit the binding run's receipt here. Requires artifact:write, the same authority as set_binding_mode; on a workspace-wide connection pass `projectId`.",
|
|
3111
3204
|
z.object({
|
|
3112
3205
|
briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
|
|
3113
3206
|
receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim.")
|
|
@@ -4803,8 +4896,14 @@ an empty string on the live site.
|
|
|
4803
4896
|
|
|
4804
4897
|
## 13. Convert an imported repo into a CMS-backed, editable site
|
|
4805
4898
|
|
|
4806
|
-
\xA711 says a deploy does not make a site editable. This is the recipe that does
|
|
4807
|
-
|
|
4899
|
+
\xA711 says a deploy does not make a site editable. This is the recipe that does.
|
|
4900
|
+
|
|
4901
|
+
\u{1F534} **A CONVERSION ENDS ONLY WHEN NOTHING IS PENDING.** The codemod's first pass never
|
|
4902
|
+
declares every path \u2014 measured on a real site, one pass declared 282 of 767 \u2014 and every path it
|
|
4903
|
+
leaves in \`paths.pending\` carries a \`fix\` naming the file, the line and the one edit. Apply
|
|
4904
|
+
them, rerun, resubmit, and repeat until \`submit_conversion_receipt\` answers \`complete: true\`.
|
|
4905
|
+
That boolean is the finish line; a receipt with pending paths is a progress report. The
|
|
4906
|
+
\`convert_site\` MCP prompt walks exactly this loop.
|
|
4808
4907
|
|
|
4809
4908
|
**Scope, before you start.**
|
|
4810
4909
|
|
|
@@ -4878,21 +4977,30 @@ hand. THE ORDER, and every step of it matters:
|
|
|
4878
4977
|
could not match are in \`forms.pending\` with a reason (\`FORM_NOT_IN_SOURCE\`,
|
|
4879
4978
|
\`FIELD_UNMATCHED\`, \`FORM_AMBIGUOUS\`, \u2026) \u2014 it refuses rather than guessing, because a form
|
|
4880
4979
|
wired to the wrong id delivers the customer's leads into another form's inbox.
|
|
4881
|
-
3.
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
with
|
|
4980
|
+
3. APPLY EVERY PENDING FIX, THEN RERUN. Each entry of \`paths.pending\` carries its reason
|
|
4981
|
+
(\`IN_EXPRESSION\`, \`AMBIGUOUS_LITERAL\`, \`REPEATER_FIXED_LENGTH\`, \`PARSE_ERROR\`, \u2026) and,
|
|
4982
|
+
where the codemod could work one out, a \`fix\`: \`{ action, file, line, col?, snippet, why? }\`.
|
|
4983
|
+
Go to \`file\`:\`line\` and do what \`action\` says \u2014 \`wrap-span\` (wrap the literal in
|
|
4984
|
+
\`<span data-bcms-field="\u2026">\`), \`declare-attr\` (add \`data-bcms-field="\u2026"\` to that element),
|
|
4985
|
+
\`bind-expression\` (replace the expression with the framework's bcmsField helper),
|
|
4986
|
+
\`declare-richtext\` (bind the container with the richtext helper), \`bind-data\` (bind the field
|
|
4987
|
+
in the data file or collection entry it names) or \`manual\` (do what its \`why\` says). A row
|
|
4988
|
+
with no \`fix\` is still yours. Then RERUN step 2 and repeat until \`paths.pending\` is empty \u2014
|
|
4989
|
+
the codemod is idempotent, so a second run over converted source rewrites nothing.
|
|
4990
|
+
4. \`submit_conversion_receipt { briefDigest, receipt }\` with the receipt file, verbatim. It
|
|
4991
|
+
answers \`complete\`, \`pendingTotal\` and the first 40 pending rows with their \`fix\`. \`complete: true\` \u2014 nothing
|
|
4992
|
+
pending \u2014 is the ONLY receipt that ends a conversion; on anything less go back to step 3. It
|
|
4993
|
+
is also what lets the coverage meter say WHY a path is undeclared instead of only that it is.
|
|
4994
|
+
It records and releases nothing.
|
|
4888
4995
|
5. Push, or \`deploy_project\`. A PLAIN HTML repo \u2014 no \`package.json\` \u2014 deploys by PUSH ONLY:
|
|
4889
4996
|
\`deploy_project\` refuses such an archive by design, so pushing is the whole lane. Either way,
|
|
4890
4997
|
poll \`get_deploy_status\` until \`slot\` is \`current\` (or \`awaitingPromote\` is false) before you
|
|
4891
4998
|
read the report in step 6 \u2014 a report read against the previous release is the previous
|
|
4892
4999
|
release's verdict.
|
|
4893
5000
|
6. \`get_binding_report\`. Alongside \`unmatched\` it now carries \`coverage\` \u2014 \`declared\` (what the
|
|
4894
|
-
brief listed), \`bound\` (what the build declares) and \`pending\` with the reasons.
|
|
4895
|
-
|
|
5001
|
+
brief listed), \`bound\` (what the build declares) and \`pending\` with the reasons. It must read
|
|
5002
|
+
\`unmatched\` 0 and \`unaddressable\` under 2% of each route's visible text (\xA711). Fix what it
|
|
5003
|
+
names IN THE SOURCE and release again until \`coverage.pending\` is empty.
|
|
4896
5004
|
7. \`set_binding_mode { declaredBindings: true }\` and release ONE MORE TIME \u2014 the mode applies to
|
|
4897
5005
|
the next release, not to the one already out.
|
|
4898
5006
|
|
|
@@ -5121,6 +5229,31 @@ disagrees with the array the moment anyone drags a row in the builder.
|
|
|
5121
5229
|
// src/prompts.ts
|
|
5122
5230
|
var STRUCTURE_RULE = `### Page structure (non-negotiable)
|
|
5123
5231
|
${SECTION_DOCTRINE}`;
|
|
5232
|
+
var CONVERT_SITE_TEXT = (projectId) => `Convert my site so every visible element is editable in BetterCMS${projectId ? `, project ${projectId}` : ""}.
|
|
5233
|
+
Do not stop at the plan or at a partial receipt \u2014 finish the whole site.
|
|
5234
|
+
|
|
5235
|
+
1. \`list_projects\`; pass \`projectId\`${projectId ? ` (${projectId})` : ""} on every project tool from here on. If the project
|
|
5236
|
+
is not listed the grant is on another workspace \u2014 stop and say so.
|
|
5237
|
+
2. \`get_conversion_plan\`. On a 404 \`no-approved-plan\`, call
|
|
5238
|
+
\`get_conversion_brief { complete: true }\` and follow \`cursor\` to the LAST page, keeping every
|
|
5239
|
+
page \u2014 a codemod fed the capped brief converts a truncated site. Write them to \`brief.json\`.
|
|
5240
|
+
3. \`pull_project_source\` (or clone the \`github\` remote it names) and work from the SOURCE, never
|
|
5241
|
+
from the deployed HTML.
|
|
5242
|
+
4. \`npx @bettercms-ai/convert --brief brief.json --root . --receipt receipt.json\`; read \`git diff\`.
|
|
5243
|
+
5. For EVERY entry of \`paths.pending\`, go to its \`fix.file\`:\`fix.line\` and do what \`fix.action\`
|
|
5244
|
+
says: \`wrap-span\` wrap the literal in \`<span data-bcms-field="\u2026">\`; \`declare-attr\` add
|
|
5245
|
+
\`data-bcms-field="\u2026"\` to that element; \`bind-expression\` replace the expression with the
|
|
5246
|
+
framework's bcmsField helper; \`declare-richtext\` bind the container with the richtext helper;
|
|
5247
|
+
\`bind-data\` bind the field in the data file it names; \`manual\` do what its \`why\` says. A row
|
|
5248
|
+
with no \`fix\` is still yours. Then rerun step 4 until \`paths.pending\` is EMPTY.
|
|
5249
|
+
6. \`submit_conversion_receipt { briefDigest, receipt }\`. It must answer \`complete: true\`; if it
|
|
5250
|
+
does not, go back to step 5 \u2014 that is the only receipt that ends a conversion.
|
|
5251
|
+
7. Deploy (push, or \`deploy_project\`) and poll \`get_deploy_status\` until the release is live.
|
|
5252
|
+
8. \`get_binding_report\` must read \`unmatched\` 0 and \`unaddressable\` under 2% of each route's
|
|
5253
|
+
visible text. Fix what it names in the SOURCE, deploy, re-read (playbook section 11).
|
|
5254
|
+
9. \`set_binding_mode { declaredBindings: true }\`, deploy AGAIN and poll \u2014 the mode applies to the
|
|
5255
|
+
NEXT release \u2014 then confirm \`get_binding_report\` reads mode "declared", unmatched 0, bound
|
|
5256
|
+
above 0, \`coverage.pending\` empty. That report is your final answer to me.`;
|
|
5124
5257
|
var SCHEMA_PROPOSAL_FLOW = `### Whole-project design (confirm-first) \u2192 \`create_component\` / \`create_page\` / \`create_content_model\`
|
|
5125
5258
|
Design the WHOLE project from its brief or its code, and **confirm the shape with the user
|
|
5126
5259
|
BEFORE creating anything**. Never silently guess.
|
|
@@ -5500,6 +5633,19 @@ in the dashboard \u2014 those render as an empty string until they are published
|
|
|
5500
5633
|
]
|
|
5501
5634
|
})
|
|
5502
5635
|
);
|
|
5636
|
+
server.registerPrompt(
|
|
5637
|
+
"convert_site",
|
|
5638
|
+
{
|
|
5639
|
+
title: "Convert a site so nothing is left pending (guided)",
|
|
5640
|
+
description: "Run the conversion end to end: pull the source, run @bettercms-ai/convert, apply every fix it lists as pending, rerun until nothing is pending, and finish only when submit_conversion_receipt returns complete and get_binding_report reads unmatched 0.",
|
|
5641
|
+
argsSchema: {
|
|
5642
|
+
projectId: z2.string().optional().describe("the project to convert; omit if this connection is scoped to one")
|
|
5643
|
+
}
|
|
5644
|
+
},
|
|
5645
|
+
({ projectId }) => ({
|
|
5646
|
+
messages: [{ role: "user", content: { type: "text", text: CONVERT_SITE_TEXT(projectId) } }]
|
|
5647
|
+
})
|
|
5648
|
+
);
|
|
5503
5649
|
server.registerPrompt(
|
|
5504
5650
|
"build_site",
|
|
5505
5651
|
{
|