@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 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, and it ends in a
4807
- receipt you can read: \`get_binding_report\` says \`mode: "declared"\` with zero unmatched paths.
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. REVIEW THE PENDING LIST. Every path the codemod could not do is in \`paths.pending\` with a
4882
- reason (\`IN_EXPRESSION\`, \`AMBIGUOUS_LITERAL\`, \`REPEATER_FIXED_LENGTH\`, \`PARSE_ERROR\`, \u2026).
4883
- Do those by hand, or decide they are genuinely not convertible \u2014 do not skip past them.
4884
- 4. \`submit_conversion_receipt { briefDigest, receipt }\` with the receipt file, verbatim. This is
4885
- what lets the coverage meter say WHY a path is undeclared instead of only that it is; without
4886
- it every one of them reads \`not-declared\`, which looks like a broken site rather than work
4887
- with a reason. It records and releases nothing.
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. Fix what it
4895
- names and release again until \`coverage.pending\` is empty.
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
  {