@bettercms-ai/mcp 0.57.1 → 0.59.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 +44 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2443,7 +2443,7 @@ function structureResult(d) {
|
|
|
2443
2443
|
}
|
|
2444
2444
|
var GIT_TEXT = {
|
|
2445
2445
|
list_github_repos: "Every GitHub repository this workspace's connected GitHub App installations can reach, grouped by account. Use it to find the repository to import: each ACCOUNT carries the `installationId` that import_github_repo and fork_github_repo take, and each repository under it carries `owner`, `repo` and `defaultBranch`. An empty `accounts` list means nobody has connected GitHub to this workspace yet \u2014 tell the user to connect it from the dashboard (Project \u2192 Hosting \u2192 Connect GitHub) and call this again.",
|
|
2446
|
-
import_github_repo: "Connect an existing GitHub repository to the connected project \u2014 the agent's equivalent of the dashboard's Import from GitHub, and the thing to do BEFORE writing any code for a user who already has a repo. It
|
|
2446
|
+
import_github_repo: "Connect an existing GitHub repository to the connected project \u2014 the agent's equivalent of the dashboard's Import from GitHub, and the thing to do BEFORE writing any code for a user who already has a repo. It wires the project API key and variables into the repository, records the connection, assigns the site handle, imports the repo's bcms-content.json as content models when it has one, and queues the first build. Pass `installationId`, `owner` and `repo` from list_github_repos; `branch` defaults to the repository's own default branch and becomes the branch every deploy builds from. It only works on a repository one of this workspace's installations can already reach \u2014 for anyone else's repository (a public starter, a template, another account's site) use fork_github_repo instead. Read `warning` / `contentWarning` / `envWarning` back to the user verbatim when they come back (`envWarning` names the environment variables the repo documents in .env.example and the project lacks \u2014 the site renders empty or crashes until they are set) \u2014 they are the cases where the repository connected perfectly and still cannot build, or will leave the Pages tab empty. Re-importing replaces the project's existing connection.",
|
|
2447
2447
|
fork_github_repo: "Fork ANY GitHub repository into the user's own account and import it in one step \u2014 how a user starts from a repository that is not theirs (a public starter, a template, another account's site). BetterCMS cannot wire a repository it has no installation on, so the copy has to live on their account first; this makes that copy, keeps the upstream link, and then connects it exactly as import_github_repo does. `installationId` names the DESTINATION account (from list_github_repos); `owner` and `repo` name the SOURCE, anywhere on GitHub. `name` renames the fork; `connect: false` forks without connecting. Forking is asynchronous: a 202 means the fork exists but GitHub is still copying it, and the answer tells you to call import_github_repo with the fork's owner and repo a moment later \u2014 never fork a second time.",
|
|
2448
2448
|
list_github_branches: "The connected repository's branches, plus `buildBranch` \u2014 the one the provisioned Action builds from. A commit on any other branch never reaches the live site, so check this before you push.",
|
|
2449
2449
|
list_github_commits: "The connected repository's commit history, newest first: sha, message, author, date and url. Defaults to the build branch; pass `branch` for another, `path` to narrow it to one file or directory, and `limit` (1-100, default 20). Read the head sha here before a push you want to be safe, and hand it back as push_to_github's `expectedHeadSha`.",
|
|
@@ -2452,6 +2452,7 @@ var GIT_TEXT = {
|
|
|
2452
2452
|
list_github_pull_requests: "Pull requests on the connected repository: number, title, state, whether it merged, draft, head, base and url. `state` is 'open' (the default), 'closed' or 'all'. Poll it to find out whether the pull request you opened has landed."
|
|
2453
2453
|
};
|
|
2454
2454
|
var HOSTING_TEXT = {
|
|
2455
|
+
list_env_vars: "The project's environment variables by NAME and scope (never a value), and `missing`: the ones the repository's .env.example declares that the project does not set. A site missing them builds and serves, then renders empty or crashes in the browser. Resolve `missing` by running `npx @bettercms-ai/cli@latest env push` in the repository: it uploads those values from the user's own .env files straight to BetterCMS, so they never pass through this conversation. Never read, print or ask the user to paste a value. When the values do not exist locally because the site reads its content from another CMS, move that content into BetterCMS instead (the bettercms skill's external-content reference). BetterCMS's own BCMS_* identifiers are supplied to every build and never need setting.",
|
|
2455
2456
|
list_hosting_connections: "The Vercel, Netlify and Cloudflare accounts connected to this workspace (id, provider, accountLabel, status; never a token), plus `connectUrl`, the dashboard page where a person connects one. Call it before deploy_to_host. If the provider the user wants has no connection with status `ok`, give the user `connectUrl` to connect it in their browser and stop until they have: you never handle OAuth or a host token yourself.",
|
|
2456
2457
|
deploy_to_host: "Deploy this project to the user's own Vercel or Netlify account through their connection. BetterCMS builds the site and the host serves that build, so there is no host CLI to run, no token to paste and no vercel.json rewrite to write; forms keep working because they post to the BetterCMS API. The first call creates the site on the host and makes it the project's host; a later call for the same provider redeploys that site (`redeployed: true`) instead of creating another. `connectionId` (from list_hosting_connections) defaults to the newest working one, `siteName` to the project's handle. A refusal carries a `code` and a `fixUrl` (not_connected, reconnect, pick_existing_site, grant_required, ADMIN_REQUIRED): each is fixed by the user at `fixUrl`, so relay it and stop. `deploymentQueued: false` means the project has no live build yet: deploy_project first, and that release reaches the host on its own. Never run the Vercel or Netlify CLI while a connection exists. Then poll get_host_deploy_status. Needs artifact:write, like deploy_project.",
|
|
2457
2458
|
get_host_deploy_status: "The connected project's recent deployments to Vercel, Netlify or Cloudflare, newest first: state (queued|deploying|live|failed), providerUrl, error, sha and targetName. Poll it after deploy_to_host until the newest one is `live`, whose `providerUrl` is the live URL to hand the user, or `failed`, whose `error` you read back to them."
|
|
@@ -2660,7 +2661,7 @@ function buildToolDefs(deps) {
|
|
|
2660
2661
|
"per-type props: heading {text, level}; text/richtext {html} (NOT {text}), and both also take level 1-6, rendering the html AS that <hN> with inline marks kept (one line of inline copy only) \u2014 use it for a title that carries a styled span; image {src, alt}; button {text, href}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may NOT hold columns/section/slider/tabs; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>"
|
|
2661
2662
|
),
|
|
2662
2663
|
style: z.record(z.string(), z.unknown()).optional().describe(
|
|
2663
|
-
"design tokens: theme, bg (none|surface|muted|accent|dark|custom), bgCustom hex, paddingTop/paddingBottom/paddingSides px, contentWidth (narrow|default|wide|full), align, corner, shadow, borderTop/borderBottom. A real marketing band is a `section` block carrying bg + padding + contentWidth."
|
|
2664
|
+
"design tokens: theme, bg (none|surface|muted|accent|dark|custom), bgCustom hex, paddingTop/paddingBottom/paddingSides px, contentWidth (narrow|default|wide|full), align, corner, shadow, borderTop/borderBottom, border (all four sides, in the text colour). A real marketing band is a `section` block carrying bg + padding + contentWidth."
|
|
2664
2665
|
)
|
|
2665
2666
|
});
|
|
2666
2667
|
const createPageInput = z.object({
|
|
@@ -3020,6 +3021,11 @@ function buildToolDefs(deps) {
|
|
|
3020
3021
|
const getComponentInput = z.object({
|
|
3021
3022
|
componentId: z.string().min(1).describe("component id (from list_components)")
|
|
3022
3023
|
});
|
|
3024
|
+
const duplicateComponentInput = z.object({
|
|
3025
|
+
componentId: z.string().min(1).describe("the look to copy (from list_components)"),
|
|
3026
|
+
name: z.string().min(1).max(255).describe("the new look's name, unique in its family, e.g. 'Ghost'"),
|
|
3027
|
+
preset: z.enum(["primary", "secondary", "outline", "ghost", "link"]).optional().describe("restyle the copy's button (see above); omit for a plain copy")
|
|
3028
|
+
});
|
|
3023
3029
|
const createComponentsInput = z.object({
|
|
3024
3030
|
components: z.array(createComponentInput).min(1).max(50).describe("up to 50 components, each exactly the create_component input")
|
|
3025
3031
|
});
|
|
@@ -3828,14 +3834,18 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3828
3834
|
def(
|
|
3829
3835
|
"get_componentize_plan",
|
|
3830
3836
|
"Get the plan for turning this site's sections into components",
|
|
3831
|
-
"What this site's SECTIONS would become as components \u2014 a proposal that creates nothing, changes nothing and is computed live on every call. For a site whose pages were DERIVED at import (the site get_conversion_brief describes), each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import already folded (`group-*`). Per page it returns each section's `groupKey`, its `sectionType` family (Hero, FAQ, CTA, Features, Social proof\u2026), its leaf `fields` (key, path, type, the value the CMS holds), a `shapeHash`, and either the component that already renders it (`reuse.componentId`) or the one this plan proposes (`reuse.proposedSlug`) \u2014 and the components themselves under `components`: a NEW one carries the exact `props` and `blockJson` create_component would take, while a row for a component that ALREADY EXISTS carries its `componentId`, `slug` and `name` and no definition, because nothing will be written for it.
|
|
3832
|
-
z.object({
|
|
3833
|
-
|
|
3837
|
+
"What this site's SECTIONS would become as components \u2014 a proposal that creates nothing, changes nothing and is computed live on every call. For a site whose pages were DERIVED at import (the site get_conversion_brief describes), each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import already folded (`group-*`). Per page it returns each section's `groupKey`, its `sectionType` family (Hero, FAQ, CTA, Features, Social proof\u2026), its leaf `fields` (key, path, type, the value the CMS holds), a `shapeHash`, and either the component that already renders it (`reuse.componentId`) or the one this plan proposes (`reuse.proposedSlug`) \u2014 and the components themselves under `components`: a NEW one carries the exact `props` and `blockJson` create_component would take, while a row for a component that ALREADY EXISTS carries its `componentId`, `slug` and `name` and no definition, because nothing will be written for it. Every page gets its OWN components (`home-hero`, `about-hero`), never one shared across pages; a locale copy (`/fr`) reuses its default-locale page's. A COLLECTION TEMPLATE (a dynamic page, `pattern: \"{slug}\"`) is planned ONCE, from one entry, and every prop reads the entry being rendered: only its prose body and lists of object rows are bands, while its frontmatter (author, images, tags, dates) stays the entry's own fields. `pending` says why a group is not offered: `NO_GROUP_ROOT` (the page's field keys are still the derive lane's own \u2014 `h1-welcome`, `p-we-build-things` \u2014 so there is no family to group by; rename them into families first), `NOT_A_SECTION` (a lone scalar with no family, or the page's own metadata \u2014 a section is a group field, a repeater, or a family two or more leaves share, so a legal page of `title`/`metaDescription`/`intro` proposes nothing; `detail: ENTRY_METADATA` is a collection template's frontmatter, `detail: DECLINED` a group somebody declined with componentize_sections { decline: true }), `IMAGE_AS_TEXT` (a text field holding an image path \u2014 it plans itself once the field is an image), `ALREADY_COMPONENTIZED`, `EMPTY_GROUP`, `NESTED_REPEATER` (a repeater THREE deep; TWO levels are expressed exactly \u2014 the group's `table` prop gains a nested `table` sub-field, and each row's nested column is an array of row objects), `PAGE_NOT_EMPTY` (the page holds blocks this lane does not own and will not overwrite). Chrome is NEVER a section: `nav-`/`footer-` keys and everything promoted into the project Layout are edited through the Layout. READ `summary: true` FIRST \u2014 groups, outcomes and slugs, no values (the full plan runs to hundreds of KB). `paged: true` returns 10 pages at a time with a `cursor` for the next; a 409 `PLAN_CHANGED` means the plan moved, start again. The unpaged full read is what `--plan plan.json` takes. Keep the `digest` \u2014 componentize_sections refuses any other.",
|
|
3838
|
+
z.object({
|
|
3839
|
+
summary: z.boolean().optional().describe("true = no values: each section's groupKey, family, pending and component slug. Read this first."),
|
|
3840
|
+
paged: z.boolean().optional().describe("true = 10 pages at a time; the response carries `page`, `pageCount` and a `cursor` for the next."),
|
|
3841
|
+
cursor: z.string().optional().describe("The `cursor` from the previous page. Implies paged. A 409 `PLAN_CHANGED` means start again with no cursor.")
|
|
3842
|
+
}).shape,
|
|
3843
|
+
async (c, a) => ok("Componentize plan.", await data(c, "GET", `/management/projects/current/componentize-plan${q({ summary: a.summary, paged: a.paged, cursor: a.cursor })}`))
|
|
3834
3844
|
),
|
|
3835
3845
|
def(
|
|
3836
3846
|
"componentize_sections",
|
|
3837
3847
|
"Turn this site's derived sections into components",
|
|
3838
|
-
"Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections and say how many components it will create and which pages it will rewrite. It creates each proposed component as a DRAFT and replaces each page's DRAFT blocks with an ordered list of `component` instances \u2014 one per group, each carrying `props.bind: \"<groupKey>\"`, which points at the page field group that already holds the copy. So nothing is copied and nothing moves: the page keeps its `fields`, its values and its bindings, click-to-edit keeps working and the coverage meter does not change. Pass the plan's `digest`; a 409 `stale-plan` means the site changed since you read that plan, so read it again, show the user what changed and confirm again. Read its `components.wouldDuplicate` before applying: each id is an existing component of the same family this run would sit a NEW one beside, because reuse is by identity and slug, never by family. Running it twice is safe: a group that already has a placement comes back in `sections.pending` as ALREADY_COMPONENTIZED and no second component is created. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages before this reaches a visitor. Then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. The default `bind` is the above: the words stay in the page's field group. `copy: \"instance\"` moves the words onto each placement and marks the page's fields `origin: \"componentized\"` \u2014 kept, never deleted (see `copy`). Every component it CREATES is filed into a Component Group (the folders of the dashboard's Components tab): the one you name in `group` (found or created), else its section family \u2014 nav, header, footer or menu \u2192 Layout, an unnamed section \u2192 Sections, otherwise the family name (Hero, FAQ). A component it reuses keeps the Group it has; the receipt's `components.groups` lists the Groups it filed into.",
|
|
3848
|
+
"Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections and say how many components it will create and which pages it will rewrite. It creates each proposed component as a DRAFT and replaces each page's DRAFT blocks with an ordered list of `component` instances \u2014 one per group, each carrying `props.bind: \"<groupKey>\"`, which points at the page field group that already holds the copy. So nothing is copied and nothing moves: the page keeps its `fields`, its values and its bindings, click-to-edit keeps working and the coverage meter does not change. Pass the plan's `digest`; a 409 `stale-plan` means the site changed since you read that plan, so read it again, show the user what changed and confirm again. Read its `components.wouldDuplicate` before applying: each id is an existing component of the same family this run would sit a NEW one beside, because reuse is by identity and slug, never by family. Running it twice is safe: a group that already has a placement comes back in `sections.pending` as ALREADY_COMPONENTIZED and no second component is created. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages before this reaches a visitor. Then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. The default `bind` is the above: the words stay in the page's field group. `copy: \"instance\"` moves the words onto each placement and marks the page's fields `origin: \"componentized\"` \u2014 kept, never deleted (see `copy`). Every component it CREATES is filed into a Component Group (the folders of the dashboard's Components tab): the one you name in `group` (found or created), else its section family \u2014 nav, header, footer or menu \u2192 Layout, an unnamed section \u2192 Sections, otherwise the family name (Hero, FAQ). A component it reuses keeps the Group it has; the receipt's `components.groups` lists the Groups it filed into. When the user says a proposed group is NOT a section, pass `decline: true` with those `sections` (and `pageIds`): nothing is componentized, the plan reports them `NOT_A_SECTION` / `DECLINED` and stops proposing them, and a plan that proposes nothing that was not declined is done; `decline: false` offers them again.",
|
|
3839
3849
|
z.object({
|
|
3840
3850
|
digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
|
|
3841
3851
|
pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
|
|
@@ -3843,9 +3853,10 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3843
3853
|
sections: z.array(z.string().min(1)).optional().describe("Act only on these section groupKeys (from the plan). Every other section on the page keeps the placement it already has."),
|
|
3844
3854
|
copy: z.enum(["bind", "instance"]).optional().describe("Who owns each section's copy. 'bind' (default) leaves it in the page fields. 'instance' is the page-builder model: each placement takes that group's CURRENT draft values into its own `props.overrides` (a repeater becomes one table prop holding every row), records which page path each came from in `props.source`, and drops `props.bind`; the page's fields are KEPT and marked `origin: \"componentized\"`, so nothing is lost and the editor stops showing a second place to type the same words."),
|
|
3845
3855
|
dryRun: z.boolean().optional().describe("true = return the receipt without writing anything. Do this first."),
|
|
3846
|
-
group: z.string().min(1).max(100).optional().describe("File the components this run creates into this Component Group (by NAME; created when missing). Omit to file each under its section family.")
|
|
3856
|
+
group: z.string().min(1).max(100).optional().describe("File the components this run creates into this Component Group (by NAME; created when missing). Omit to file each under its section family."),
|
|
3857
|
+
decline: z.boolean().optional().describe("true = record `sections` as NOT sections (nothing is componentized; the plan stops proposing them). false = offer them again. Needs `sections`.")
|
|
3847
3858
|
}).shape,
|
|
3848
|
-
async (c, a) => ok("Componentized the sections.", await data(c, "POST", `/management/projects/current/componentize`, { digest: a.digest, pageIds: a.pageIds, pages: a.pages, sections: a.sections, copy: a.copy, dryRun: a.dryRun, group: a.group }))
|
|
3859
|
+
async (c, a) => ok("Componentized the sections.", await data(c, "POST", `/management/projects/current/componentize`, { digest: a.digest, pageIds: a.pageIds, pages: a.pages, sections: a.sections, copy: a.copy, dryRun: a.dryRun, group: a.group, decline: a.decline }))
|
|
3849
3860
|
),
|
|
3850
3861
|
def(
|
|
3851
3862
|
"get_analytics_overview",
|
|
@@ -4164,6 +4175,13 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
4164
4175
|
// ── Deploy to the user's own host (parity with remote /mcp) ──
|
|
4165
4176
|
// A person connects Vercel or Netlify in the dashboard; nothing here touches OAuth or a host
|
|
4166
4177
|
// token. deploy_to_host takes artifact:write like deploy_project. @see management/deploy-targets.ts
|
|
4178
|
+
def(
|
|
4179
|
+
"list_env_vars",
|
|
4180
|
+
"List environment variables",
|
|
4181
|
+
HOSTING_TEXT.list_env_vars,
|
|
4182
|
+
z.object({}).shape,
|
|
4183
|
+
async (c) => ok("Environment variables (names only).", await data(c, "GET", `/management/hosting/env`))
|
|
4184
|
+
),
|
|
4167
4185
|
def(
|
|
4168
4186
|
"list_hosting_connections",
|
|
4169
4187
|
"List hosting connections",
|
|
@@ -5008,6 +5026,24 @@ ${lines.join("\n")}`, found);
|
|
|
5008
5026
|
})
|
|
5009
5027
|
)
|
|
5010
5028
|
},
|
|
5029
|
+
{
|
|
5030
|
+
name: "duplicate_component",
|
|
5031
|
+
config: {
|
|
5032
|
+
title: "Add a look to a component family",
|
|
5033
|
+
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. 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.",
|
|
5034
|
+
inputSchema: duplicateComponentInput.shape
|
|
5035
|
+
},
|
|
5036
|
+
handler: guard(
|
|
5037
|
+
async (args) => withClient(async (client) => {
|
|
5038
|
+
const { componentId, ...body } = args;
|
|
5039
|
+
const res = await client.fetchJSON(
|
|
5040
|
+
client.url(`/management/components/${encodeURIComponent(componentId)}/duplicate`),
|
|
5041
|
+
{ method: "POST", body: JSON.stringify(body) }
|
|
5042
|
+
);
|
|
5043
|
+
return ok(`Added look '${res.data.name}' (id ${res.data.id}, ${res.data.status}).`, res.data);
|
|
5044
|
+
})
|
|
5045
|
+
)
|
|
5046
|
+
},
|
|
5011
5047
|
// ── AI content + SEO actions (Option B) ────────────────────────────────────
|
|
5012
5048
|
{
|
|
5013
5049
|
name: "write_content",
|