rpg0-dotcms-mcp 0.0.0-stage → 1.1.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/dotcms.js +210 -0
- package/hooks/confirm-write.mjs +40 -0
- package/index.js +446 -0
- package/package.json +16 -4
- package/plans.js +111 -0
- package/README.md +0 -3
package/dotcms.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Shared dotCMS client used by the MCP server (index.js) and the confirmation hook.
|
|
2
|
+
//
|
|
3
|
+
// Config (env):
|
|
4
|
+
// DOTCMS_URL base URL of the dotCMS instance
|
|
5
|
+
// DOTCMS_TOKEN API token
|
|
6
|
+
// DOTCMS_ALLOW_WRITE "true" to expose the create/update tools (default: read-only)
|
|
7
|
+
// DOTCMS_WRITE_ROOTS comma-separated folders the write tools are limited to
|
|
8
|
+
// (default: /application/apivtl/,/application/vtl/custom-fields/)
|
|
9
|
+
|
|
10
|
+
export const BASE_URL = (process.env.DOTCMS_URL || "http://localhost:8080").replace(/\/+$/, "");
|
|
11
|
+
export const TOKEN = process.env.DOTCMS_TOKEN || "";
|
|
12
|
+
export const ALLOW_WRITE = /^(true|1|yes)$/i.test(process.env.DOTCMS_ALLOW_WRITE || "");
|
|
13
|
+
export const WRITE_ROOTS = (
|
|
14
|
+
process.env.DOTCMS_WRITE_ROOTS || "/application/apivtl/,/application/vtl/custom-fields/"
|
|
15
|
+
)
|
|
16
|
+
.split(",")
|
|
17
|
+
.map((root) => root.trim().replace(/^\/+|\/+$/g, ""))
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.map((root) => `/${root}/`);
|
|
20
|
+
|
|
21
|
+
const authHeaders = (extra = {}) => (TOKEN ? { ...extra, Authorization: `Bearer ${TOKEN}` } : extra);
|
|
22
|
+
|
|
23
|
+
async function send(path, init = {}) {
|
|
24
|
+
try {
|
|
25
|
+
return await fetch(BASE_URL + path, { ...init, signal: AbortSignal.timeout(30_000) });
|
|
26
|
+
} catch (err) {
|
|
27
|
+
throw new Error(`Cannot reach dotCMS at ${BASE_URL}: ${err.message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** JSON request against the dotCMS REST API. `body` may be an object (sent as JSON) or FormData. */
|
|
32
|
+
export async function api(path, { method = "GET", params = {}, body } = {}) {
|
|
33
|
+
const query = new URLSearchParams();
|
|
34
|
+
for (const [key, value] of Object.entries(params)) {
|
|
35
|
+
if (value !== undefined && value !== null && value !== "") query.set(key, String(value));
|
|
36
|
+
}
|
|
37
|
+
const url = path + (query.size ? `?${query}` : "");
|
|
38
|
+
|
|
39
|
+
const isForm = body instanceof FormData;
|
|
40
|
+
const headers = authHeaders({ Accept: "application/json" });
|
|
41
|
+
if (body && !isForm) headers["Content-Type"] = "application/json";
|
|
42
|
+
|
|
43
|
+
const res = await send(url, {
|
|
44
|
+
method,
|
|
45
|
+
headers,
|
|
46
|
+
body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
|
|
47
|
+
});
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const hint =
|
|
51
|
+
res.status === 401 || res.status === 403
|
|
52
|
+
? " Check DOTCMS_TOKEN and the token user's permissions."
|
|
53
|
+
: res.status === 404
|
|
54
|
+
? " Check the name/id and DOTCMS_URL."
|
|
55
|
+
: "";
|
|
56
|
+
throw new Error(`dotCMS responded ${res.status} ${res.statusText}.${hint} ${text.slice(0, 400)}`);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(text);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`dotCMS returned a non-JSON response from ${path}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Files
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
// Lucene special characters, except "/" which dotCMS accepts unescaped inside wildcards.
|
|
70
|
+
export const escapeLucene = (s) => s.replace(/[+\-!(){}[\]^"~*?:\\&|]/g, "\\$&");
|
|
71
|
+
|
|
72
|
+
export async function searchFiles(clauses, { limit = 50, offset = 0 } = {}) {
|
|
73
|
+
const data = await api("/api/content/_search", {
|
|
74
|
+
method: "POST",
|
|
75
|
+
body: { query: ["+baseType:4", ...clauses].join(" "), sort: "path asc", limit, offset },
|
|
76
|
+
});
|
|
77
|
+
const entity = data.entity || {};
|
|
78
|
+
return { total: entity.resultsSize ?? 0, files: entity.jsonObjectView?.contentlets || [] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const normalizePath = (path) => "/" + String(path).trim().replace(/^\/+/, "");
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* All versions (one per site/language) of the file at an exact path, or with an identifier.
|
|
85
|
+
*/
|
|
86
|
+
export async function findFiles({ path, identifier, site }) {
|
|
87
|
+
let files;
|
|
88
|
+
if (identifier) {
|
|
89
|
+
({ files } = await searchFiles([`+identifier:${escapeLucene(identifier)}`], { limit: 20 }));
|
|
90
|
+
} else {
|
|
91
|
+
const wanted = normalizePath(path).toLowerCase();
|
|
92
|
+
({ files } = await searchFiles([`+path:*${escapeLucene(wanted)}`], { limit: 50 }));
|
|
93
|
+
files = files.filter((f) => f.path?.toLowerCase() === wanted);
|
|
94
|
+
}
|
|
95
|
+
if (site) files = files.filter((f) => f.hostName?.toLowerCase() === site.toLowerCase());
|
|
96
|
+
return files;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Pick one language version out of findFiles() results; throws a helpful error otherwise. */
|
|
100
|
+
export function pickVersion(files, { what, site, languageId }) {
|
|
101
|
+
if (!files.length) throw new Error(`File not found: ${what}${site ? ` on site ${site}` : ""}.`);
|
|
102
|
+
const sites = [...new Set(files.map((f) => f.hostName))];
|
|
103
|
+
if (sites.length > 1) {
|
|
104
|
+
throw new Error(`This path exists on several sites (${sites.join(", ")}). Pass 'site'.`);
|
|
105
|
+
}
|
|
106
|
+
const languages = [...new Set(files.map((f) => f.languageId))].sort((a, b) => a - b);
|
|
107
|
+
const file = files.find((f) => f.languageId === (languageId ?? languages[0]));
|
|
108
|
+
if (!file) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`No version of this file in language ${languageId}. Available language ids: ${languages.join(", ")}.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return { file, languages };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function downloadFile(file) {
|
|
117
|
+
const name = encodeURIComponent(file.fileName || file.name || "file.vtl");
|
|
118
|
+
// Inode first: it pins the exact language version. The identifier URL
|
|
119
|
+
// would silently return the default-language version instead.
|
|
120
|
+
const candidates = [
|
|
121
|
+
`/dA/${file.inode}/fileAsset/${name}`,
|
|
122
|
+
`/contentAsset/raw-data/${file.inode}/fileAsset`,
|
|
123
|
+
`/dA/${file.identifier}/fileAsset/${name}?language_id=${file.languageId ?? ""}`,
|
|
124
|
+
];
|
|
125
|
+
const tried = [];
|
|
126
|
+
for (const path of candidates) {
|
|
127
|
+
const res = await send(path, { headers: authHeaders({ Accept: "text/plain, */*" }) });
|
|
128
|
+
const type = res.headers.get("content-type") || "";
|
|
129
|
+
// dotCMS answers with its HTML 404 page when the file may not be served.
|
|
130
|
+
if (res.ok && !(type.includes("text/html") && !/\.html?$/i.test(name))) {
|
|
131
|
+
return await res.text();
|
|
132
|
+
}
|
|
133
|
+
tried.push(`${path} -> ${res.status}`);
|
|
134
|
+
await res.body?.cancel();
|
|
135
|
+
}
|
|
136
|
+
throw new Error(
|
|
137
|
+
`dotCMS refused to serve the file contents (${tried.join("; ")}). ` +
|
|
138
|
+
(TOKEN
|
|
139
|
+
? "The token user probably has no read permission on this file."
|
|
140
|
+
: "VTL files are not served to anonymous users: set DOTCMS_TOKEN."),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Writing (API VTL files only)
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/** Validate a path for the write tools and split it into folder + file name. */
|
|
149
|
+
export function parseWritePath(path) {
|
|
150
|
+
const full = normalizePath(path);
|
|
151
|
+
if (!/^[A-Za-z0-9._\-/]+$/.test(full) || full.includes("..") || full.includes("//")) {
|
|
152
|
+
throw new Error(`Invalid path '${path}': use only letters, digits, '.', '_', '-' and '/'.`);
|
|
153
|
+
}
|
|
154
|
+
const root = WRITE_ROOTS.find((r) => full.toLowerCase().startsWith(r.toLowerCase()));
|
|
155
|
+
if (!root) {
|
|
156
|
+
throw new Error(`Writing is limited to ${WRITE_ROOTS.join(" and ")} — '${full}' is outside of them.`);
|
|
157
|
+
}
|
|
158
|
+
if (!full.toLowerCase().endsWith(".vtl")) {
|
|
159
|
+
throw new Error(`Only .vtl files can be written, got '${full}'.`);
|
|
160
|
+
}
|
|
161
|
+
const cut = full.lastIndexOf("/");
|
|
162
|
+
const folder = full.slice(0, cut + 1);
|
|
163
|
+
const fileName = full.slice(cut + 1);
|
|
164
|
+
if (!fileName.replace(/\.vtl$/i, "")) throw new Error(`Missing file name in '${full}'.`);
|
|
165
|
+
if (folder.toLowerCase() === root.toLowerCase()) {
|
|
166
|
+
throw new Error(`Put the file in its own folder, e.g. ${root}my-name/${fileName}.`);
|
|
167
|
+
}
|
|
168
|
+
const kind = /\/apivtl\//i.test(root) ? "api" : /\/custom-fields\//i.test(root) ? "custom-field" : "vtl";
|
|
169
|
+
return { full, folder, fileName, root, kind };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create a new file asset, or a new version of an existing one (when `identifier` is given),
|
|
174
|
+
* through the dotCMS workflow API. Not published unless `publish` is true.
|
|
175
|
+
*/
|
|
176
|
+
export async function saveFile({ site, folder, fileName, content, identifier, languageId, publish }) {
|
|
177
|
+
const contentlet = {
|
|
178
|
+
contentType: "FileAsset",
|
|
179
|
+
title: fileName,
|
|
180
|
+
fileName,
|
|
181
|
+
hostFolder: `${site}:${folder}`,
|
|
182
|
+
};
|
|
183
|
+
if (identifier) contentlet.identifier = identifier;
|
|
184
|
+
if (languageId) contentlet.languageId = languageId;
|
|
185
|
+
|
|
186
|
+
const form = new FormData();
|
|
187
|
+
form.append("json", JSON.stringify({ contentlet }));
|
|
188
|
+
form.append("file", new Blob([content], { type: "text/plain" }), fileName);
|
|
189
|
+
|
|
190
|
+
const action = publish ? "PUBLISH" : identifier ? "EDIT" : "NEW";
|
|
191
|
+
const data = await api(`/api/v1/workflow/actions/default/fire/${action}`, {
|
|
192
|
+
method: "PUT",
|
|
193
|
+
params: { indexPolicy: "WAIT_FOR" },
|
|
194
|
+
body: form,
|
|
195
|
+
});
|
|
196
|
+
return { action, entity: data.entity || {} };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Make sure a folder exists. Returns an error message instead of throwing. */
|
|
200
|
+
export async function ensureFolder(site, folder) {
|
|
201
|
+
try {
|
|
202
|
+
await api(`/api/v1/folder/createfolders/${encodeURIComponent(site)}`, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
body: [folder],
|
|
205
|
+
});
|
|
206
|
+
return null;
|
|
207
|
+
} catch (err) {
|
|
208
|
+
return err.message;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude Code PreToolUse hook: the human check of the plan -> human check -> write flow.
|
|
3
|
+
//
|
|
4
|
+
// apply_vtl_change is the only dotcms MCP tool that writes. This hook never lets it through
|
|
5
|
+
// on its own: it loads the plan the call refers to and answers "ask", so Claude Code shows
|
|
6
|
+
// the plan (target, publish flag, diff, warnings) and the write only happens if the user
|
|
7
|
+
// approves it. A call without a valid, unexpired plan is denied. Other tools are left alone.
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import { loadPlan, renderPlan } from "../plans.js";
|
|
11
|
+
|
|
12
|
+
const decide = (permissionDecision, reason) => {
|
|
13
|
+
process.stdout.write(
|
|
14
|
+
JSON.stringify({
|
|
15
|
+
hookSpecificOutput: {
|
|
16
|
+
hookEventName: "PreToolUse",
|
|
17
|
+
permissionDecision,
|
|
18
|
+
permissionDecisionReason: reason,
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
);
|
|
22
|
+
process.exit(0);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let input;
|
|
26
|
+
try {
|
|
27
|
+
input = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
process.exit(0); // not a hook payload: stay out of the way
|
|
30
|
+
}
|
|
31
|
+
if (input.tool_name !== "mcp__dotcms__apply_vtl_change") process.exit(0);
|
|
32
|
+
|
|
33
|
+
let plan;
|
|
34
|
+
try {
|
|
35
|
+
plan = loadPlan(input.tool_input?.planId);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
decide("deny", `dotCMS write blocked: ${err.message}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
decide("ask", `Approve this dotCMS change?\n\n${renderPlan(plan)}`);
|
package/index.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Local MCP server for dotCMS.
|
|
3
|
+
//
|
|
4
|
+
// Read-only tools (always available):
|
|
5
|
+
// list_content_types, get_content_type_fields, list_vtl_files, read_vtl_file
|
|
6
|
+
// Write tools (only when DOTCMS_ALLOW_WRITE=true, limited to DOTCMS_WRITE_ROOTS):
|
|
7
|
+
// plan_vtl_change (plans only, writes nothing) -> apply_vtl_change (writes, human-confirmed)
|
|
8
|
+
//
|
|
9
|
+
// Config: see dotcms.js.
|
|
10
|
+
|
|
11
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import {
|
|
15
|
+
ALLOW_WRITE,
|
|
16
|
+
BASE_URL,
|
|
17
|
+
TOKEN,
|
|
18
|
+
WRITE_ROOTS,
|
|
19
|
+
api,
|
|
20
|
+
downloadFile,
|
|
21
|
+
ensureFolder,
|
|
22
|
+
escapeLucene,
|
|
23
|
+
findFiles,
|
|
24
|
+
parseWritePath,
|
|
25
|
+
pickVersion,
|
|
26
|
+
saveFile,
|
|
27
|
+
searchFiles,
|
|
28
|
+
} from "./dotcms.js";
|
|
29
|
+
import { deletePlan, diffLines, loadPlan, renderPlan, savePlan } from "./plans.js";
|
|
30
|
+
|
|
31
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
|
|
32
|
+
const MAX_FILE_CHARS = 200_000;
|
|
33
|
+
|
|
34
|
+
// Field classes that only describe the edit-form layout, not real data.
|
|
35
|
+
const LAYOUT_FIELDS = new Set([
|
|
36
|
+
"RowField",
|
|
37
|
+
"ColumnField",
|
|
38
|
+
"TabDividerField",
|
|
39
|
+
"LineDividerField",
|
|
40
|
+
"ColumnBreakField",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const shortClass = (clazz = "") => clazz.split(".").pop().replace(/^Immutable/, "");
|
|
44
|
+
|
|
45
|
+
function mapField(field) {
|
|
46
|
+
const out = {
|
|
47
|
+
name: field.name,
|
|
48
|
+
variable: field.variable,
|
|
49
|
+
type: field.fieldType || shortClass(field.clazz).replace(/Field$/, ""),
|
|
50
|
+
class: shortClass(field.clazz),
|
|
51
|
+
dataType: field.dataType,
|
|
52
|
+
required: !!field.required,
|
|
53
|
+
unique: !!field.unique,
|
|
54
|
+
indexed: !!field.indexed,
|
|
55
|
+
searchable: !!field.searchable,
|
|
56
|
+
listed: !!field.listed,
|
|
57
|
+
system: !!field.fixed,
|
|
58
|
+
};
|
|
59
|
+
if (field.hint) out.hint = field.hint;
|
|
60
|
+
if (field.defaultValue) out.defaultValue = field.defaultValue;
|
|
61
|
+
if (field.values) out.values = field.values;
|
|
62
|
+
if (field.regexCheck) out.regexCheck = field.regexCheck;
|
|
63
|
+
if (field.relationships) out.relationships = field.relationships;
|
|
64
|
+
if (field.categories) out.categories = field.categories;
|
|
65
|
+
if (field.fieldVariables?.length) {
|
|
66
|
+
out.fieldVariables = Object.fromEntries(field.fieldVariables.map((v) => [v.key, v.value]));
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const mapFile = (f) => ({
|
|
72
|
+
path: f.path,
|
|
73
|
+
name: f.fileName || f.name,
|
|
74
|
+
site: f.hostName,
|
|
75
|
+
identifier: f.identifier,
|
|
76
|
+
inode: f.inode,
|
|
77
|
+
languageId: f.languageId,
|
|
78
|
+
size: f.size,
|
|
79
|
+
modDate: f.modDate,
|
|
80
|
+
live: f.live,
|
|
81
|
+
description: f.description || undefined,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const ok = (data) => ({ content: [{ type: "text", text: JSON.stringify(data, null, 2) }] });
|
|
85
|
+
const fail = (err) => ({ isError: true, content: [{ type: "text", text: err.message }] });
|
|
86
|
+
|
|
87
|
+
const server = new McpServer({ name: "dotcms", version: "1.1.0" });
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Read-only tools
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
server.registerTool(
|
|
94
|
+
"list_content_types",
|
|
95
|
+
{
|
|
96
|
+
title: "List dotCMS content types",
|
|
97
|
+
description:
|
|
98
|
+
"Read-only. List content types in dotCMS (name, variable, base type, number of content entries). " +
|
|
99
|
+
"Use it to find the variable name of a content type before reading its fields.",
|
|
100
|
+
inputSchema: {
|
|
101
|
+
filter: z.string().optional().describe("Filter by name or variable (substring)"),
|
|
102
|
+
type: z
|
|
103
|
+
.enum(["CONTENT", "WIDGET", "FORM", "FILEASSET", "HTMLPAGE", "PERSONA", "VANITY_URL", "KEY_VALUE", "DOTASSET"])
|
|
104
|
+
.optional()
|
|
105
|
+
.describe("Only content types of this base type"),
|
|
106
|
+
page: z.number().int().min(1).default(1).describe("Page number"),
|
|
107
|
+
perPage: z.number().int().min(1).max(100).default(50).describe("Items per page"),
|
|
108
|
+
},
|
|
109
|
+
annotations: READ_ONLY,
|
|
110
|
+
},
|
|
111
|
+
async ({ filter, type, page, perPage }) => {
|
|
112
|
+
try {
|
|
113
|
+
const data = await api("/api/v1/contenttype", {
|
|
114
|
+
params: { filter, type, page, per_page: perPage, orderby: "name", direction: "ASC" },
|
|
115
|
+
});
|
|
116
|
+
const types = (data.entity || []).map((ct) => ({
|
|
117
|
+
name: ct.name,
|
|
118
|
+
variable: ct.variable,
|
|
119
|
+
id: ct.id,
|
|
120
|
+
baseType: ct.baseType,
|
|
121
|
+
description: ct.description || undefined,
|
|
122
|
+
entries: ct.nEntries,
|
|
123
|
+
system: !!ct.system,
|
|
124
|
+
}));
|
|
125
|
+
return ok({
|
|
126
|
+
page,
|
|
127
|
+
perPage,
|
|
128
|
+
total: data.pagination?.totalEntries,
|
|
129
|
+
count: types.length,
|
|
130
|
+
contentTypes: types,
|
|
131
|
+
});
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return fail(err);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
server.registerTool(
|
|
139
|
+
"get_content_type_fields",
|
|
140
|
+
{
|
|
141
|
+
title: "Get fields of a dotCMS content type",
|
|
142
|
+
description:
|
|
143
|
+
"Read-only. Return the fields of a dotCMS content type: name, variable, field type, data type, " +
|
|
144
|
+
"required/unique/indexed/searchable/listed flags, hint, default value, allowed values, " +
|
|
145
|
+
"relationships and field variables.",
|
|
146
|
+
inputSchema: {
|
|
147
|
+
contentType: z.string().min(1).describe("Content type variable name (e.g. 'Blog') or its id"),
|
|
148
|
+
includeLayout: z
|
|
149
|
+
.boolean()
|
|
150
|
+
.default(false)
|
|
151
|
+
.describe("Also include layout-only fields (rows, columns, tab dividers)"),
|
|
152
|
+
},
|
|
153
|
+
annotations: READ_ONLY,
|
|
154
|
+
},
|
|
155
|
+
async ({ contentType, includeLayout }) => {
|
|
156
|
+
try {
|
|
157
|
+
const data = await api(`/api/v1/contenttype/id/${encodeURIComponent(contentType)}`);
|
|
158
|
+
const ct = data.entity || {};
|
|
159
|
+
const fields = (ct.fields || [])
|
|
160
|
+
.filter((f) => includeLayout || !LAYOUT_FIELDS.has(shortClass(f.clazz)))
|
|
161
|
+
.map(mapField);
|
|
162
|
+
return ok({
|
|
163
|
+
name: ct.name,
|
|
164
|
+
variable: ct.variable,
|
|
165
|
+
id: ct.id,
|
|
166
|
+
baseType: ct.baseType,
|
|
167
|
+
description: ct.description || undefined,
|
|
168
|
+
urlMapPattern: ct.urlMapPattern || undefined,
|
|
169
|
+
detailPage: ct.detailPage || undefined,
|
|
170
|
+
fieldCount: fields.length,
|
|
171
|
+
fields,
|
|
172
|
+
});
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return fail(err);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
server.registerTool(
|
|
180
|
+
"list_vtl_files",
|
|
181
|
+
{
|
|
182
|
+
title: "List VTL files in dotCMS",
|
|
183
|
+
description:
|
|
184
|
+
"Read-only. Find Velocity (.vtl) files stored in dotCMS as file assets: container, template, " +
|
|
185
|
+
"theme, custom-field and scripting-API VTL files. Returns path, site, identifier, language, size and modification date.",
|
|
186
|
+
inputSchema: {
|
|
187
|
+
folder: z
|
|
188
|
+
.string()
|
|
189
|
+
.optional()
|
|
190
|
+
.describe("Only files whose path contains this folder, e.g. '/application/vtl' or 'containers/banner'"),
|
|
191
|
+
name: z.string().optional().describe("Only files whose file name contains this text, e.g. 'banner'"),
|
|
192
|
+
extension: z.string().default("vtl").describe("File extension to look for (default 'vtl')"),
|
|
193
|
+
page: z.number().int().min(1).default(1).describe("Page number"),
|
|
194
|
+
perPage: z.number().int().min(1).max(100).default(50).describe("Items per page"),
|
|
195
|
+
},
|
|
196
|
+
annotations: READ_ONLY,
|
|
197
|
+
},
|
|
198
|
+
async ({ folder, name, extension, page, perPage }) => {
|
|
199
|
+
try {
|
|
200
|
+
const ext = escapeLucene(extension.replace(/^\./, "").toLowerCase());
|
|
201
|
+
const clauses = [`+path:*.${ext}`];
|
|
202
|
+
if (folder) {
|
|
203
|
+
const f = escapeLucene(folder.toLowerCase().replace(/^\/+|\/+$/g, ""));
|
|
204
|
+
clauses.push(`+path:*${f}/*`);
|
|
205
|
+
}
|
|
206
|
+
if (name) clauses.push(`+title:*${escapeLucene(name.toLowerCase())}*`);
|
|
207
|
+
|
|
208
|
+
const { total, files } = await searchFiles(clauses, {
|
|
209
|
+
limit: perPage,
|
|
210
|
+
offset: (page - 1) * perPage,
|
|
211
|
+
});
|
|
212
|
+
return ok({ page, perPage, total, count: files.length, files: files.map(mapFile) });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return fail(err);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
server.registerTool(
|
|
220
|
+
"read_vtl_file",
|
|
221
|
+
{
|
|
222
|
+
title: "Read a VTL file from dotCMS",
|
|
223
|
+
description:
|
|
224
|
+
"Read-only. Return the source of a Velocity (.vtl) file stored in dotCMS. Pass either the full " +
|
|
225
|
+
"path (e.g. '/application/containers/banner/banner.vtl') or the file identifier from list_vtl_files.",
|
|
226
|
+
inputSchema: {
|
|
227
|
+
path: z.string().optional().describe("Full path of the file inside the site"),
|
|
228
|
+
identifier: z.string().optional().describe("File identifier (alternative to path)"),
|
|
229
|
+
site: z.string().optional().describe("Site host name, if the same path exists on several sites"),
|
|
230
|
+
languageId: z
|
|
231
|
+
.number()
|
|
232
|
+
.int()
|
|
233
|
+
.optional()
|
|
234
|
+
.describe("Language id, if the file has several language versions (default: the lowest id, usually the default language)"),
|
|
235
|
+
},
|
|
236
|
+
annotations: READ_ONLY,
|
|
237
|
+
},
|
|
238
|
+
async ({ path, identifier, site, languageId }) => {
|
|
239
|
+
try {
|
|
240
|
+
if (!path && !identifier) throw new Error("Pass either 'path' or 'identifier'.");
|
|
241
|
+
const files = await findFiles({ path, identifier, site });
|
|
242
|
+
const { file, languages } = pickVersion(files, { what: identifier || path, site, languageId });
|
|
243
|
+
|
|
244
|
+
const content = await downloadFile(file);
|
|
245
|
+
const truncated = content.length > MAX_FILE_CHARS;
|
|
246
|
+
return ok({
|
|
247
|
+
...mapFile(file),
|
|
248
|
+
availableLanguageIds: languages,
|
|
249
|
+
truncated,
|
|
250
|
+
content: truncated ? content.slice(0, MAX_FILE_CHARS) : content,
|
|
251
|
+
});
|
|
252
|
+
} catch (err) {
|
|
253
|
+
return fail(err);
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Write tools — opt-in, limited to WRITE_ROOTS. Plan first, then the
|
|
260
|
+
// human check (hooks/confirm-write.mjs), then the write.
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
if (ALLOW_WRITE) {
|
|
264
|
+
const roots = WRITE_ROOTS.join(" and ");
|
|
265
|
+
|
|
266
|
+
server.registerTool(
|
|
267
|
+
"plan_vtl_change",
|
|
268
|
+
{
|
|
269
|
+
title: "Plan a VTL file change in dotCMS",
|
|
270
|
+
description:
|
|
271
|
+
"Step 1 of 2 for every write. Does NOT change dotCMS: it validates the target, compares the new " +
|
|
272
|
+
"source with the current file and returns a plan (action, target, diff, warnings) with a planId. " +
|
|
273
|
+
`Writable locations: ${roots} — scripting-API files (e.g. /application/apivtl/my-endpoint/get.vtl, ` +
|
|
274
|
+
"file name = HTTP method) and custom fields (e.g. /application/vtl/custom-fields/my-field/code.vtl). " +
|
|
275
|
+
"Whether it is a create or an update is detected from whether the file exists. " +
|
|
276
|
+
"Show the returned plan to the user, then call apply_vtl_change with the planId.",
|
|
277
|
+
inputSchema: {
|
|
278
|
+
path: z.string().min(1).describe(`Full path of the file, inside ${roots}`),
|
|
279
|
+
site: z
|
|
280
|
+
.string()
|
|
281
|
+
.optional()
|
|
282
|
+
.describe("Site host name. Required for a new file, or when the path exists on several sites"),
|
|
283
|
+
content: z
|
|
284
|
+
.string()
|
|
285
|
+
.min(1)
|
|
286
|
+
.max(MAX_FILE_CHARS)
|
|
287
|
+
.describe("Full Velocity source of the file (the whole file, not a patch)"),
|
|
288
|
+
languageId: z.number().int().optional().describe("Language id (default: the default language)"),
|
|
289
|
+
publish: z
|
|
290
|
+
.boolean()
|
|
291
|
+
.default(false)
|
|
292
|
+
.describe("Publish immediately. Default false: save a working version only, the live file stays as it is"),
|
|
293
|
+
},
|
|
294
|
+
annotations: READ_ONLY,
|
|
295
|
+
},
|
|
296
|
+
async ({ path, site, content, languageId, publish }) => {
|
|
297
|
+
try {
|
|
298
|
+
const target = parseWritePath(path);
|
|
299
|
+
const files = await findFiles({ path: target.full, site });
|
|
300
|
+
|
|
301
|
+
let action = "CREATE";
|
|
302
|
+
let base = null;
|
|
303
|
+
let current = "";
|
|
304
|
+
if (files.length) {
|
|
305
|
+
const { file } = pickVersion(files, { what: target.full, site, languageId });
|
|
306
|
+
action = "UPDATE";
|
|
307
|
+
base = { identifier: file.identifier, inode: file.inode, live: !!file.live };
|
|
308
|
+
site = file.hostName;
|
|
309
|
+
languageId = file.languageId;
|
|
310
|
+
current = await downloadFile(file);
|
|
311
|
+
} else if (!site) {
|
|
312
|
+
throw new Error(`${target.full} does not exist yet. Pass 'site' to plan creating it.`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const changes = diffLines(current, content);
|
|
316
|
+
if (action === "UPDATE" && !changes.length) {
|
|
317
|
+
throw new Error("Nothing to do: the new content is identical to the current file.");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const warnings = [];
|
|
321
|
+
if (publish) warnings.push("publish is on — the change goes live as soon as it is applied.");
|
|
322
|
+
if (/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\./.test(content)) {
|
|
323
|
+
warnings.push("the content contains what looks like an API token (JWT).");
|
|
324
|
+
}
|
|
325
|
+
const hints = [];
|
|
326
|
+
if (action === "UPDATE" && !publish && base.live) {
|
|
327
|
+
hints.push("The file has a live version: it keeps serving the old content until this change is published.");
|
|
328
|
+
}
|
|
329
|
+
if (target.kind === "custom-field" && action === "CREATE") {
|
|
330
|
+
hints.push(`To use it, set the Custom-Field value to: #dotParse("//${site}${target.full}")`);
|
|
331
|
+
}
|
|
332
|
+
if (target.kind === "api" && action === "CREATE") {
|
|
333
|
+
const endpoint = target.folder.slice(target.root.length).replace(/\/$/, "");
|
|
334
|
+
hints.push(
|
|
335
|
+
`Once published it answers ${target.fileName.replace(/\.vtl$/i, "").toUpperCase()} ${BASE_URL}/api/vtl/${endpoint}`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const plan = savePlan({
|
|
340
|
+
action,
|
|
341
|
+
kind: target.kind,
|
|
342
|
+
instance: BASE_URL,
|
|
343
|
+
site,
|
|
344
|
+
path: target.full,
|
|
345
|
+
folder: target.folder,
|
|
346
|
+
fileName: target.fileName,
|
|
347
|
+
languageId,
|
|
348
|
+
publish,
|
|
349
|
+
content,
|
|
350
|
+
base,
|
|
351
|
+
changes,
|
|
352
|
+
warnings,
|
|
353
|
+
hints,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
content: [
|
|
358
|
+
{
|
|
359
|
+
type: "text",
|
|
360
|
+
text:
|
|
361
|
+
renderPlan(plan) +
|
|
362
|
+
`\n\nplanId: ${plan.id}\nNothing has been written. Show this plan to the user; to carry it out call ` +
|
|
363
|
+
"apply_vtl_change with this planId — the user will be asked to confirm.",
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
};
|
|
367
|
+
} catch (err) {
|
|
368
|
+
return fail(err);
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
server.registerTool(
|
|
374
|
+
"apply_vtl_change",
|
|
375
|
+
{
|
|
376
|
+
title: "Apply a planned VTL file change to dotCMS",
|
|
377
|
+
description:
|
|
378
|
+
"Step 2 of 2. WRITES to dotCMS: creates or updates the VTL file exactly as described by a plan from " +
|
|
379
|
+
"plan_vtl_change. Takes only the planId, so nothing but the planned content can be written. " +
|
|
380
|
+
"The user is shown the plan and asked to confirm every call. A plan works once and expires after 30 minutes; " +
|
|
381
|
+
"it is rejected if the file changed in dotCMS after the plan was made.",
|
|
382
|
+
inputSchema: {
|
|
383
|
+
planId: z.string().min(1).describe("planId returned by plan_vtl_change"),
|
|
384
|
+
},
|
|
385
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
386
|
+
},
|
|
387
|
+
async ({ planId }) => {
|
|
388
|
+
try {
|
|
389
|
+
const plan = loadPlan(planId);
|
|
390
|
+
if (plan.instance !== BASE_URL) {
|
|
391
|
+
throw new Error(`Plan ${plan.id} was made for ${plan.instance}, but this server is connected to ${BASE_URL}.`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// The plan must still describe reality.
|
|
395
|
+
const files = (await findFiles({ path: plan.path, site: plan.site })).filter(
|
|
396
|
+
(f) => plan.action === "CREATE" || f.languageId === plan.languageId,
|
|
397
|
+
);
|
|
398
|
+
if (plan.action === "CREATE" && files.length) {
|
|
399
|
+
throw new Error(`${plan.path} was created on ${plan.site} after the plan was made. Make a new plan.`);
|
|
400
|
+
}
|
|
401
|
+
if (plan.action === "UPDATE" && files[0]?.inode !== plan.base.inode) {
|
|
402
|
+
throw new Error(`${plan.path} changed in dotCMS after the plan was made. Make a new plan.`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const folderError = plan.action === "CREATE" ? await ensureFolder(plan.site, plan.folder) : null;
|
|
406
|
+
let saved;
|
|
407
|
+
try {
|
|
408
|
+
saved = await saveFile({
|
|
409
|
+
site: plan.site,
|
|
410
|
+
folder: plan.folder,
|
|
411
|
+
fileName: plan.fileName,
|
|
412
|
+
content: plan.content,
|
|
413
|
+
identifier: plan.base?.identifier,
|
|
414
|
+
languageId: plan.languageId,
|
|
415
|
+
publish: plan.publish,
|
|
416
|
+
});
|
|
417
|
+
} catch (err) {
|
|
418
|
+
if (folderError) err.message += ` (creating folder ${plan.folder} also failed: ${folderError})`;
|
|
419
|
+
throw err;
|
|
420
|
+
}
|
|
421
|
+
deletePlan(plan.id);
|
|
422
|
+
|
|
423
|
+
return ok({
|
|
424
|
+
result: `${plan.action === "CREATE" ? "Created" : "Updated"} ${plan.path} on ${plan.site}`,
|
|
425
|
+
planId: plan.id,
|
|
426
|
+
workflowAction: saved.action,
|
|
427
|
+
published: !!plan.publish,
|
|
428
|
+
note: plan.publish
|
|
429
|
+
? "The file is live."
|
|
430
|
+
: "Saved as a working (unpublished) version. The live file is unchanged until it is published.",
|
|
431
|
+
identifier: saved.entity.identifier,
|
|
432
|
+
inode: saved.entity.inode,
|
|
433
|
+
languageId: saved.entity.languageId,
|
|
434
|
+
});
|
|
435
|
+
} catch (err) {
|
|
436
|
+
return fail(err);
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
await server.connect(new StdioServerTransport());
|
|
443
|
+
console.error(
|
|
444
|
+
`dotcms-mcp running (${BASE_URL}${TOKEN ? "" : ", no DOTCMS_TOKEN set"}, ` +
|
|
445
|
+
`${ALLOW_WRITE ? `writes enabled under ${WRITE_ROOTS.join(", ")}` : "read-only"})`,
|
|
446
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rpg0-dotcms-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "MCP server for inspecting dotCMS content types and their fields, and reading/writing VTL files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"rpg0-dotcms-mcp": "index.js",
|
|
9
|
+
"rpg0-dotcms-mcp-confirm-write": "hooks/confirm-write.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": ["index.js", "dotcms.js", "plans.js", "hooks/"],
|
|
12
|
+
"scripts": { "start": "node index.js" },
|
|
13
|
+
"engines": { "node": ">=18" },
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
16
|
+
"zod": "^3.23.8"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/plans.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Change plans: the "plan first" half of the plan -> human check -> write flow.
|
|
2
|
+
//
|
|
3
|
+
// plan_vtl_change stores a plan here; apply_vtl_change can only write what a stored
|
|
4
|
+
// plan contains, and the confirmation hook shows that same plan to the user.
|
|
5
|
+
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
// Kept outside the package folder: under npx the package lives in the npm cache, and the
|
|
12
|
+
// server and the confirmation hook must agree on one location however each was started.
|
|
13
|
+
const DIR = process.env.DOTCMS_PLANS_DIR || path.join(os.homedir(), ".dotcms-mcp", "plans");
|
|
14
|
+
export const PLAN_TTL_MS = 30 * 60 * 1000;
|
|
15
|
+
const MAX_SHOWN_LINES = 40;
|
|
16
|
+
|
|
17
|
+
const splitLines = (s) => (s === "" ? [] : s.replace(/\r\n/g, "\n").split("\n"));
|
|
18
|
+
|
|
19
|
+
/** Line diff via longest common subsequence; returns only "+ n: text" / "- n: text" lines. */
|
|
20
|
+
export function diffLines(oldText, newText) {
|
|
21
|
+
const a = splitLines(oldText);
|
|
22
|
+
const b = splitLines(newText);
|
|
23
|
+
const lcs = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));
|
|
24
|
+
for (let i = a.length - 1; i >= 0; i--) {
|
|
25
|
+
for (let j = b.length - 1; j >= 0; j--) {
|
|
26
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const out = [];
|
|
30
|
+
let i = 0;
|
|
31
|
+
let j = 0;
|
|
32
|
+
while (i < a.length || j < b.length) {
|
|
33
|
+
if (i < a.length && j < b.length && a[i] === b[j]) {
|
|
34
|
+
i++;
|
|
35
|
+
j++;
|
|
36
|
+
} else if (j < b.length && (i === a.length || lcs[i][j + 1] >= lcs[i + 1][j])) {
|
|
37
|
+
out.push(`+ ${j + 1}: ${b[j++]}`);
|
|
38
|
+
} else {
|
|
39
|
+
out.push(`- ${i + 1}: ${a[i++]}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const planFile = (id) => path.join(DIR, `${id}.json`);
|
|
46
|
+
|
|
47
|
+
function prune() {
|
|
48
|
+
if (!fs.existsSync(DIR)) return;
|
|
49
|
+
for (const name of fs.readdirSync(DIR)) {
|
|
50
|
+
const file = path.join(DIR, name);
|
|
51
|
+
try {
|
|
52
|
+
if (JSON.parse(fs.readFileSync(file, "utf8")).expiresAt < Date.now()) fs.rmSync(file);
|
|
53
|
+
} catch {
|
|
54
|
+
fs.rmSync(file, { force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function savePlan(plan) {
|
|
60
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
61
|
+
prune();
|
|
62
|
+
const id = crypto.randomBytes(6).toString("hex");
|
|
63
|
+
const stored = { ...plan, id, createdAt: Date.now(), expiresAt: Date.now() + PLAN_TTL_MS };
|
|
64
|
+
fs.writeFileSync(planFile(id), JSON.stringify(stored, null, 2), { mode: 0o600 });
|
|
65
|
+
return stored;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function loadPlan(id) {
|
|
69
|
+
if (!/^[a-f0-9]{12}$/.test(String(id ?? ""))) {
|
|
70
|
+
throw new Error("Missing or malformed planId. Create a plan with plan_vtl_change first.");
|
|
71
|
+
}
|
|
72
|
+
if (!fs.existsSync(planFile(id))) {
|
|
73
|
+
throw new Error(`Plan ${id} does not exist (already applied, expired, or never created). Create a new plan with plan_vtl_change.`);
|
|
74
|
+
}
|
|
75
|
+
const plan = JSON.parse(fs.readFileSync(planFile(id), "utf8"));
|
|
76
|
+
if (plan.expiresAt < Date.now()) {
|
|
77
|
+
fs.rmSync(planFile(id), { force: true });
|
|
78
|
+
throw new Error(`Plan ${id} has expired (plans are valid for ${PLAN_TTL_MS / 60000} minutes). Create a new plan with plan_vtl_change.`);
|
|
79
|
+
}
|
|
80
|
+
return plan;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const deletePlan = (id) => fs.rmSync(planFile(id), { force: true });
|
|
84
|
+
|
|
85
|
+
const KIND_LABEL = { api: "API VTL file", "custom-field": "custom field VTL file", vtl: "VTL file" };
|
|
86
|
+
|
|
87
|
+
/** The human-readable plan: shown by plan_vtl_change and again by the confirmation hook. */
|
|
88
|
+
export function renderPlan(plan) {
|
|
89
|
+
const added = plan.changes.filter((l) => l.startsWith("+")).length;
|
|
90
|
+
const shown =
|
|
91
|
+
plan.changes.length > MAX_SHOWN_LINES
|
|
92
|
+
? [...plan.changes.slice(0, MAX_SHOWN_LINES), `… ${plan.changes.length - MAX_SHOWN_LINES} more changed lines`]
|
|
93
|
+
: plan.changes;
|
|
94
|
+
const minutesLeft = Math.max(0, Math.round((plan.expiresAt - Date.now()) / 60000));
|
|
95
|
+
|
|
96
|
+
return [
|
|
97
|
+
`PLAN ${plan.id}: ${plan.action} ${KIND_LABEL[plan.kind] || KIND_LABEL.vtl} in dotCMS`,
|
|
98
|
+
`Instance: ${plan.instance}`,
|
|
99
|
+
`Site: ${plan.site}`,
|
|
100
|
+
`Path: ${plan.path}`,
|
|
101
|
+
`Language: ${plan.languageId ?? "default"}`,
|
|
102
|
+
`Publish: ${plan.publish ? "YES — goes live immediately" : "no — working version only"}`,
|
|
103
|
+
`Size: ${splitLines(plan.content).length} lines, ${Buffer.byteLength(plan.content)} bytes`,
|
|
104
|
+
`Valid: ${minutesLeft} more minutes, one use`,
|
|
105
|
+
...(plan.warnings.length ? ["", ...plan.warnings.map((w) => `WARNING: ${w}`)] : []),
|
|
106
|
+
...(plan.hints.length ? ["", ...plan.hints] : []),
|
|
107
|
+
"",
|
|
108
|
+
plan.action === "CREATE" ? "New file:" : `Changes: +${added} / -${plan.changes.length - added} lines`,
|
|
109
|
+
...shown,
|
|
110
|
+
].join("\n");
|
|
111
|
+
}
|
package/README.md
DELETED