elementor-mcp-agent 1.0.0 → 1.2.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/server.js CHANGED
@@ -448,8 +448,8 @@ async function restoreBackup(siteId, postId, data_meta_key, settings_meta_key) {
448
448
  const r2 = await sshWpCli(site, `post meta get ${postId} ${settings_meta_key}`, { timeout_ms: 3e4 });
449
449
  if (r2.exitCode === 0 && r2.stdout) settings_value = r2.stdout;
450
450
  }
451
- const writeData = await sshWpCli(site, `post meta update ${postId} _elementor_data ${shellQuote(data_value)}`, { timeout_ms: 3e4 });
452
- if (writeData.exitCode !== 0) throw new Error(`Restore write failed: ${writeData.stderr}`);
451
+ const writeData2 = await sshWpCli(site, `post meta update ${postId} _elementor_data ${shellQuote(data_value)}`, { timeout_ms: 3e4 });
452
+ if (writeData2.exitCode !== 0) throw new Error(`Restore write failed: ${writeData2.stderr}`);
453
453
  if (settings_value !== void 0) {
454
454
  await sshWpCli(site, `post meta update ${postId} _elementor_page_settings ${shellQuote(settings_value)}`, { timeout_ms: 3e4 });
455
455
  }
@@ -854,6 +854,12 @@ function* walkElements(data, path = [], depth = 0) {
854
854
  }
855
855
  }
856
856
  }
857
+ function findElementById(data, id) {
858
+ for (const { element } of walkElements(data)) {
859
+ if (element.id === id) return element;
860
+ }
861
+ return null;
862
+ }
857
863
  function findReplaceInWidgets(data, find, replace, options = {}) {
858
864
  let count = 0;
859
865
  const flags = options.caseSensitive ? "g" : "gi";
@@ -2115,6 +2121,851 @@ var checkElementorVersionsTool = defineTool({
2115
2121
  }
2116
2122
  });
2117
2123
 
2124
+ // src/tools/widgets.ts
2125
+ import { z as z9 } from "zod";
2126
+ init_wp_rest();
2127
+
2128
+ // src/elementor/widget-ops.ts
2129
+ function readWidget(data, widgetId) {
2130
+ return findElementById(data, widgetId);
2131
+ }
2132
+ function updateWidgetSettings(data, widgetId, patch) {
2133
+ const widget = findElementById(data, widgetId);
2134
+ if (!widget || widget.elType !== "widget") return false;
2135
+ widget.settings = { ...widget.settings, ...patch };
2136
+ return true;
2137
+ }
2138
+ function deleteWidget(data, widgetId) {
2139
+ function removeFrom(arr) {
2140
+ for (let i = 0; i < arr.length; i++) {
2141
+ if (arr[i].id === widgetId) {
2142
+ arr.splice(i, 1);
2143
+ return true;
2144
+ }
2145
+ if (arr[i].elements && removeFrom(arr[i].elements)) return true;
2146
+ }
2147
+ return false;
2148
+ }
2149
+ return removeFrom(data);
2150
+ }
2151
+ function findParent(data, widgetId) {
2152
+ for (const { element } of walkElements(data)) {
2153
+ if (element.elements?.some((e) => e.id === widgetId)) return element;
2154
+ }
2155
+ return null;
2156
+ }
2157
+ function duplicateWidget(data, widgetId) {
2158
+ const parent = findParent(data, widgetId);
2159
+ if (!parent || !parent.elements) return { ok: false };
2160
+ const idx = parent.elements.findIndex((e) => e.id === widgetId);
2161
+ if (idx < 0) return { ok: false };
2162
+ const clone = JSON.parse(JSON.stringify(parent.elements[idx]));
2163
+ clone.id = generateId();
2164
+ parent.elements.splice(idx + 1, 0, clone);
2165
+ return { ok: true, new_widget_id: clone.id };
2166
+ }
2167
+ function swapWidgetType(data, widgetId, newType, newSettings = {}) {
2168
+ const widget = findElementById(data, widgetId);
2169
+ if (!widget || widget.elType !== "widget") return false;
2170
+ widget.widgetType = newType;
2171
+ widget.settings = newSettings;
2172
+ return true;
2173
+ }
2174
+ function addWidget(data, parentId, widgetType, settings = {}) {
2175
+ const parent = findElementById(data, parentId);
2176
+ if (!parent) return { ok: false };
2177
+ if (!parent.elements) parent.elements = [];
2178
+ const newWidget = {
2179
+ id: generateId(),
2180
+ elType: "widget",
2181
+ widgetType,
2182
+ settings,
2183
+ elements: [],
2184
+ isInner: false
2185
+ };
2186
+ parent.elements.push(newWidget);
2187
+ return { ok: true, new_widget_id: newWidget.id };
2188
+ }
2189
+ function moveWidget(data, widgetId, newParentId, position = -1) {
2190
+ const widget = findElementById(data, widgetId);
2191
+ if (!widget) return false;
2192
+ const oldParent = findParent(data, widgetId);
2193
+ if (!oldParent || !oldParent.elements) return false;
2194
+ const newParent = findElementById(data, newParentId);
2195
+ if (!newParent) return false;
2196
+ if (!newParent.elements) newParent.elements = [];
2197
+ const idx = oldParent.elements.findIndex((e) => e.id === widgetId);
2198
+ if (idx < 0) return false;
2199
+ oldParent.elements.splice(idx, 1);
2200
+ if (position < 0 || position >= newParent.elements.length) {
2201
+ newParent.elements.push(widget);
2202
+ } else {
2203
+ newParent.elements.splice(position, 0, widget);
2204
+ }
2205
+ return true;
2206
+ }
2207
+ function generateId() {
2208
+ return Math.random().toString(16).slice(2, 9).padEnd(7, "0");
2209
+ }
2210
+
2211
+ // src/tools/widgets.ts
2212
+ init_backup();
2213
+ init_css_flush();
2214
+ init_policies();
2215
+ init_confirmation();
2216
+
2217
+ // src/elementor/verify.ts
2218
+ init_wp_rest();
2219
+ import { z as z8 } from "zod";
2220
+ var VerificationSchema = z8.object({
2221
+ /** Plain-English description of what we re-read and compared. */
2222
+ method: z8.string(),
2223
+ /** Whether a canonical re-read of the page succeeded. */
2224
+ reread_ok: z8.boolean(),
2225
+ /** Op-specific: did the change we requested actually persist? */
2226
+ matches_requested: z8.boolean(),
2227
+ /** Op-specific extra data (e.g. the actual persisted widget settings). */
2228
+ persisted: z8.record(z8.any()).optional(),
2229
+ /** Free-text notes / explanation when matches_requested is false. */
2230
+ notes: z8.string().optional()
2231
+ });
2232
+ async function verifyWrite(args) {
2233
+ try {
2234
+ const page = await wpRequest(
2235
+ `/wp/v2/pages/${args.pageId}?context=edit&_fields=meta`,
2236
+ { siteId: args.siteId }
2237
+ );
2238
+ const v = page.meta?._elementor_data;
2239
+ const raw = typeof v === "string" ? v : JSON.stringify(v ?? []);
2240
+ const data = parseElementorData(raw);
2241
+ const result = args.predicate(data);
2242
+ return {
2243
+ method: args.description,
2244
+ reread_ok: true,
2245
+ matches_requested: result.ok,
2246
+ persisted: result.persisted,
2247
+ notes: result.notes
2248
+ };
2249
+ } catch (err) {
2250
+ return {
2251
+ method: args.description,
2252
+ reread_ok: false,
2253
+ matches_requested: false,
2254
+ notes: `Canonical re-read failed: ${err.message}`
2255
+ };
2256
+ }
2257
+ }
2258
+ function deepEqual(a, b) {
2259
+ if (a === b) return true;
2260
+ if (typeof a !== typeof b) return false;
2261
+ if (a === null || b === null) return a === b;
2262
+ if (Array.isArray(a) || Array.isArray(b)) {
2263
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
2264
+ return a.every((x, i) => deepEqual(x, b[i]));
2265
+ }
2266
+ if (typeof a === "object" && typeof b === "object") {
2267
+ const ka = Object.keys(a);
2268
+ const kb = Object.keys(b);
2269
+ if (ka.length !== kb.length) return false;
2270
+ return ka.every(
2271
+ (k) => deepEqual(a[k], b[k])
2272
+ );
2273
+ }
2274
+ return false;
2275
+ }
2276
+
2277
+ // src/tools/widgets.ts
2278
+ var MutationResponseShape = {
2279
+ mode: z9.enum(["dry_run", "applied"]),
2280
+ page_id: z9.number(),
2281
+ confirmation_token: z9.string().optional(),
2282
+ backup_meta_key: z9.string().optional(),
2283
+ css_flush: z9.string().optional(),
2284
+ mutated: z9.boolean().optional(),
2285
+ warnings: z9.array(z9.string()).optional(),
2286
+ verification: VerificationSchema.optional()
2287
+ };
2288
+ async function fetchData(siteId, pageId) {
2289
+ const page = await wpRequest(
2290
+ `/wp/v2/pages/${pageId}?context=edit&_fields=meta`,
2291
+ { siteId }
2292
+ );
2293
+ const v = page.meta?._elementor_data;
2294
+ const raw = typeof v === "string" ? v : JSON.stringify(v ?? []);
2295
+ return { raw, data: parseElementorData(raw) };
2296
+ }
2297
+ async function writeData(siteId, pageId, data) {
2298
+ const ser = serializeElementorData(data);
2299
+ const validation = validateElementorData(ser);
2300
+ if (!validation.valid) {
2301
+ throw new Error("Validation failed after edit: " + validation.errors.join("; "));
2302
+ }
2303
+ await wpRequest(`/wp/v2/pages/${pageId}`, {
2304
+ siteId,
2305
+ method: "PUT",
2306
+ body: { meta: { _elementor_data: ser } }
2307
+ });
2308
+ const flush = await flushCSS(siteId, pageId);
2309
+ return { method: flush.method, serialized: ser };
2310
+ }
2311
+ var readWidgetTool = defineTool({
2312
+ name: "read_widget",
2313
+ description: "Fetch a single widget's full settings by id. Use list_widgets_in_page to find the id first.",
2314
+ inputSchema: z9.object({
2315
+ site_id: z9.string().optional(),
2316
+ page_id: z9.number().int().positive(),
2317
+ widget_id: z9.string().min(1)
2318
+ }),
2319
+ outputSchema: z9.object({
2320
+ widget_id: z9.string(),
2321
+ widget_type: z9.string().optional(),
2322
+ settings: z9.record(z9.any())
2323
+ }),
2324
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2325
+ async handler(input) {
2326
+ const { data } = await fetchData(input.site_id, input.page_id);
2327
+ const w = readWidget(data, input.widget_id);
2328
+ if (!w) throw new Error(`Widget ${input.widget_id} not found on page ${input.page_id}`);
2329
+ return { widget_id: w.id, widget_type: w.widgetType, settings: w.settings };
2330
+ }
2331
+ });
2332
+ var updateWidgetSettingsTool = defineTool({
2333
+ name: "update_widget_settings",
2334
+ description: "Shallow-merge a partial settings object into one widget. Backs up the page first, validates the result, auto-flushes CSS, then re-reads the page and verifies the patch persisted (matches_requested in the response). Two-call confirmation.",
2335
+ inputSchema: z9.object({
2336
+ site_id: z9.string().optional(),
2337
+ page_id: z9.number().int().positive(),
2338
+ widget_id: z9.string().min(1),
2339
+ settings_patch: z9.record(z9.any()),
2340
+ confirmation: z9.string().optional()
2341
+ }),
2342
+ outputSchema: z9.object({
2343
+ ...MutationResponseShape,
2344
+ widget_id: z9.string(),
2345
+ keys_changed: z9.array(z9.string())
2346
+ }),
2347
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2348
+ async handler(input) {
2349
+ if (!input.confirmation) {
2350
+ const token = issueConfirmation("update_widget_settings", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2351
+ return {
2352
+ mode: "dry_run",
2353
+ page_id: input.page_id,
2354
+ widget_id: input.widget_id,
2355
+ keys_changed: Object.keys(input.settings_patch),
2356
+ confirmation_token: token
2357
+ };
2358
+ }
2359
+ const conf = consumeConfirmation(input.confirmation, "update_widget_settings");
2360
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2361
+ const { raw: rawBefore, data } = await fetchData(input.site_id, input.page_id);
2362
+ if (!updateWidgetSettings(data, input.widget_id, input.settings_patch)) {
2363
+ throw new Error(`Widget ${input.widget_id} not found`);
2364
+ }
2365
+ const backup = await fullBackup(input.site_id, input.page_id);
2366
+ const w = await writeData(input.site_id, input.page_id, data);
2367
+ const verification = await verifyWrite({
2368
+ siteId: input.site_id,
2369
+ pageId: input.page_id,
2370
+ description: `Re-read /wp/v2/pages/${input.page_id} and check widget ${input.widget_id} settings include the requested patch`,
2371
+ predicate: (canonical) => {
2372
+ const widget = findElementById(canonical, input.widget_id);
2373
+ if (!widget) return { ok: false, notes: "Widget no longer present after write" };
2374
+ const persisted = widget.settings;
2375
+ const mismatches = [];
2376
+ for (const [k, want] of Object.entries(input.settings_patch)) {
2377
+ if (!deepEqual(persisted[k], want)) mismatches.push(k);
2378
+ }
2379
+ return {
2380
+ ok: mismatches.length === 0,
2381
+ persisted,
2382
+ notes: mismatches.length === 0 ? void 0 : `Persisted state diverges from requested patch on key(s): ${mismatches.join(", ")}`
2383
+ };
2384
+ }
2385
+ });
2386
+ return {
2387
+ mode: "applied",
2388
+ page_id: input.page_id,
2389
+ widget_id: input.widget_id,
2390
+ keys_changed: Object.keys(input.settings_patch),
2391
+ backup_meta_key: backup.meta_key,
2392
+ css_flush: w.method,
2393
+ mutated: rawBefore !== w.serialized,
2394
+ warnings: [],
2395
+ verification
2396
+ };
2397
+ }
2398
+ });
2399
+ var deleteWidgetTool = defineTool({
2400
+ name: "delete_widget",
2401
+ description: "Remove a widget from a page by id. Two-call confirmation. Backs up before deleting; re-reads to confirm the widget is gone.",
2402
+ inputSchema: z9.object({
2403
+ site_id: z9.string().optional(),
2404
+ page_id: z9.number().int().positive(),
2405
+ widget_id: z9.string().min(1),
2406
+ confirmation: z9.string().optional()
2407
+ }),
2408
+ outputSchema: z9.object({
2409
+ ...MutationResponseShape,
2410
+ widget_id: z9.string()
2411
+ }),
2412
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2413
+ async handler(input) {
2414
+ if (!input.confirmation) {
2415
+ const token = issueConfirmation("delete_widget", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2416
+ return { mode: "dry_run", page_id: input.page_id, widget_id: input.widget_id, confirmation_token: token };
2417
+ }
2418
+ const conf = consumeConfirmation(input.confirmation, "delete_widget");
2419
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2420
+ const { raw: rawBefore, data } = await fetchData(input.site_id, input.page_id);
2421
+ if (!deleteWidget(data, input.widget_id)) throw new Error(`Widget ${input.widget_id} not found`);
2422
+ const backup = await fullBackup(input.site_id, input.page_id);
2423
+ const w = await writeData(input.site_id, input.page_id, data);
2424
+ const verification = await verifyWrite({
2425
+ siteId: input.site_id,
2426
+ pageId: input.page_id,
2427
+ description: `Re-read /wp/v2/pages/${input.page_id} and assert widget ${input.widget_id} is gone`,
2428
+ predicate: (canonical) => {
2429
+ const found = findElementById(canonical, input.widget_id);
2430
+ return {
2431
+ ok: found === null,
2432
+ notes: found ? "Widget still present in canonical re-read \u2014 delete did not persist" : void 0
2433
+ };
2434
+ }
2435
+ });
2436
+ return {
2437
+ mode: "applied",
2438
+ page_id: input.page_id,
2439
+ widget_id: input.widget_id,
2440
+ backup_meta_key: backup.meta_key,
2441
+ css_flush: w.method,
2442
+ mutated: rawBefore !== w.serialized,
2443
+ warnings: [],
2444
+ verification
2445
+ };
2446
+ }
2447
+ });
2448
+ var duplicateWidgetTool = defineTool({
2449
+ name: "duplicate_widget",
2450
+ description: "Duplicate a widget in place (right after the original). The clone gets a new id. Re-reads to confirm the clone persisted. Two-call confirmation.",
2451
+ inputSchema: z9.object({
2452
+ site_id: z9.string().optional(),
2453
+ page_id: z9.number().int().positive(),
2454
+ widget_id: z9.string().min(1),
2455
+ confirmation: z9.string().optional()
2456
+ }),
2457
+ outputSchema: z9.object({
2458
+ ...MutationResponseShape,
2459
+ source_widget_id: z9.string(),
2460
+ new_widget_id: z9.string().optional()
2461
+ }),
2462
+ annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true },
2463
+ async handler(input) {
2464
+ if (!input.confirmation) {
2465
+ const token = issueConfirmation("duplicate_widget", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2466
+ return { mode: "dry_run", page_id: input.page_id, source_widget_id: input.widget_id, confirmation_token: token };
2467
+ }
2468
+ const conf = consumeConfirmation(input.confirmation, "duplicate_widget");
2469
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2470
+ const { raw: rawBefore, data } = await fetchData(input.site_id, input.page_id);
2471
+ const r = duplicateWidget(data, input.widget_id);
2472
+ if (!r.ok || !r.new_widget_id) throw new Error(`Widget ${input.widget_id} not found`);
2473
+ const newId = r.new_widget_id;
2474
+ const backup = await fullBackup(input.site_id, input.page_id);
2475
+ const w = await writeData(input.site_id, input.page_id, data);
2476
+ const verification = await verifyWrite({
2477
+ siteId: input.site_id,
2478
+ pageId: input.page_id,
2479
+ description: `Re-read /wp/v2/pages/${input.page_id} and assert the clone (${newId}) exists alongside the source`,
2480
+ predicate: (canonical) => {
2481
+ const source = findElementById(canonical, input.widget_id);
2482
+ const clone = findElementById(canonical, newId);
2483
+ return {
2484
+ ok: source !== null && clone !== null,
2485
+ notes: !clone ? "Clone not present in canonical re-read" : !source ? "Original is missing after duplicate" : void 0
2486
+ };
2487
+ }
2488
+ });
2489
+ return {
2490
+ mode: "applied",
2491
+ page_id: input.page_id,
2492
+ source_widget_id: input.widget_id,
2493
+ new_widget_id: newId,
2494
+ backup_meta_key: backup.meta_key,
2495
+ css_flush: w.method,
2496
+ mutated: rawBefore !== w.serialized,
2497
+ warnings: [],
2498
+ verification
2499
+ };
2500
+ }
2501
+ });
2502
+ var swapWidgetTypeTool = defineTool({
2503
+ name: "swap_widget_type",
2504
+ description: "Replace a widget's type (e.g., heading \u2192 button) while preserving its id and position. Provide full new_settings \u2014 the old settings are NOT carried over (different widget types have incompatible schemas). Re-reads to confirm. Two-call confirmation.",
2505
+ inputSchema: z9.object({
2506
+ site_id: z9.string().optional(),
2507
+ page_id: z9.number().int().positive(),
2508
+ widget_id: z9.string().min(1),
2509
+ new_widget_type: z9.string().min(1),
2510
+ new_settings: z9.record(z9.any()).default({}),
2511
+ confirmation: z9.string().optional()
2512
+ }),
2513
+ outputSchema: z9.object({
2514
+ ...MutationResponseShape,
2515
+ widget_id: z9.string(),
2516
+ new_widget_type: z9.string()
2517
+ }),
2518
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2519
+ async handler(input) {
2520
+ if (!input.confirmation) {
2521
+ const token = issueConfirmation("swap_widget_type", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2522
+ return { mode: "dry_run", page_id: input.page_id, widget_id: input.widget_id, new_widget_type: input.new_widget_type, confirmation_token: token };
2523
+ }
2524
+ const conf = consumeConfirmation(input.confirmation, "swap_widget_type");
2525
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2526
+ const { raw: rawBefore, data } = await fetchData(input.site_id, input.page_id);
2527
+ if (!swapWidgetType(data, input.widget_id, input.new_widget_type, input.new_settings)) {
2528
+ throw new Error(`Widget ${input.widget_id} not found`);
2529
+ }
2530
+ const backup = await fullBackup(input.site_id, input.page_id);
2531
+ const w = await writeData(input.site_id, input.page_id, data);
2532
+ const verification = await verifyWrite({
2533
+ siteId: input.site_id,
2534
+ pageId: input.page_id,
2535
+ description: `Re-read /wp/v2/pages/${input.page_id} and assert widget ${input.widget_id} now has widgetType="${input.new_widget_type}"`,
2536
+ predicate: (canonical) => {
2537
+ const widget = findElementById(canonical, input.widget_id);
2538
+ if (!widget) return { ok: false, notes: "Widget missing after swap" };
2539
+ return {
2540
+ ok: widget.widgetType === input.new_widget_type,
2541
+ persisted: { widgetType: widget.widgetType, settings: widget.settings },
2542
+ notes: widget.widgetType === input.new_widget_type ? void 0 : `Expected widgetType="${input.new_widget_type}", canonical state has "${widget.widgetType}"`
2543
+ };
2544
+ }
2545
+ });
2546
+ return {
2547
+ mode: "applied",
2548
+ page_id: input.page_id,
2549
+ widget_id: input.widget_id,
2550
+ new_widget_type: input.new_widget_type,
2551
+ backup_meta_key: backup.meta_key,
2552
+ css_flush: w.method,
2553
+ mutated: rawBefore !== w.serialized,
2554
+ warnings: [],
2555
+ verification
2556
+ };
2557
+ }
2558
+ });
2559
+ var addWidgetTool = defineTool({
2560
+ name: "add_widget",
2561
+ description: "Append a new widget to a parent container (section, column, or container) on a page. Re-reads to confirm the new widget exists under the parent. Two-call confirmation.",
2562
+ inputSchema: z9.object({
2563
+ site_id: z9.string().optional(),
2564
+ page_id: z9.number().int().positive(),
2565
+ parent_id: z9.string().min(1).describe("Id of the section/column/container that will receive the widget."),
2566
+ widget_type: z9.string().min(1).describe("e.g., 'heading', 'text-editor', 'button', 'image'."),
2567
+ settings: z9.record(z9.any()).default({}),
2568
+ confirmation: z9.string().optional()
2569
+ }),
2570
+ outputSchema: z9.object({
2571
+ ...MutationResponseShape,
2572
+ parent_id: z9.string(),
2573
+ widget_type: z9.string(),
2574
+ new_widget_id: z9.string().optional()
2575
+ }),
2576
+ annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true },
2577
+ async handler(input) {
2578
+ if (!input.confirmation) {
2579
+ const token = issueConfirmation("add_widget", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2580
+ return { mode: "dry_run", page_id: input.page_id, parent_id: input.parent_id, widget_type: input.widget_type, confirmation_token: token };
2581
+ }
2582
+ const conf = consumeConfirmation(input.confirmation, "add_widget");
2583
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2584
+ const { raw: rawBefore, data } = await fetchData(input.site_id, input.page_id);
2585
+ const r = addWidget(data, input.parent_id, input.widget_type, input.settings);
2586
+ if (!r.ok || !r.new_widget_id) throw new Error(`Parent ${input.parent_id} not found`);
2587
+ const newId = r.new_widget_id;
2588
+ const backup = await fullBackup(input.site_id, input.page_id);
2589
+ const w = await writeData(input.site_id, input.page_id, data);
2590
+ const verification = await verifyWrite({
2591
+ siteId: input.site_id,
2592
+ pageId: input.page_id,
2593
+ description: `Re-read /wp/v2/pages/${input.page_id} and assert new widget ${newId} exists under parent ${input.parent_id} with widgetType="${input.widget_type}"`,
2594
+ predicate: (canonical) => {
2595
+ const widget = findElementById(canonical, newId);
2596
+ if (!widget) return { ok: false, notes: "New widget not found after add" };
2597
+ const parent = findElementById(canonical, input.parent_id);
2598
+ const isUnderParent = parent?.elements?.some((e) => e.id === newId) === true;
2599
+ return {
2600
+ ok: widget.widgetType === input.widget_type && isUnderParent,
2601
+ notes: !isUnderParent ? `Widget exists but is not under expected parent ${input.parent_id}` : widget.widgetType !== input.widget_type ? `Widget exists with wrong widgetType "${widget.widgetType}"` : void 0
2602
+ };
2603
+ }
2604
+ });
2605
+ return {
2606
+ mode: "applied",
2607
+ page_id: input.page_id,
2608
+ parent_id: input.parent_id,
2609
+ widget_type: input.widget_type,
2610
+ new_widget_id: newId,
2611
+ backup_meta_key: backup.meta_key,
2612
+ css_flush: w.method,
2613
+ mutated: rawBefore !== w.serialized,
2614
+ warnings: [],
2615
+ verification
2616
+ };
2617
+ }
2618
+ });
2619
+ var moveWidgetTool = defineTool({
2620
+ name: "move_widget",
2621
+ description: "Move a widget to a different parent (or different position in the same parent). Re-reads to confirm new parent. Two-call confirmation.",
2622
+ inputSchema: z9.object({
2623
+ site_id: z9.string().optional(),
2624
+ page_id: z9.number().int().positive(),
2625
+ widget_id: z9.string().min(1),
2626
+ new_parent_id: z9.string().min(1),
2627
+ position: z9.number().int().default(-1).describe("0-based position in the new parent. -1 = append."),
2628
+ confirmation: z9.string().optional()
2629
+ }),
2630
+ outputSchema: z9.object({
2631
+ ...MutationResponseShape,
2632
+ widget_id: z9.string(),
2633
+ new_parent_id: z9.string()
2634
+ }),
2635
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2636
+ async handler(input) {
2637
+ if (!input.confirmation) {
2638
+ const token = issueConfirmation("move_widget", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2639
+ return { mode: "dry_run", page_id: input.page_id, widget_id: input.widget_id, new_parent_id: input.new_parent_id, confirmation_token: token };
2640
+ }
2641
+ const conf = consumeConfirmation(input.confirmation, "move_widget");
2642
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2643
+ const { raw: rawBefore, data } = await fetchData(input.site_id, input.page_id);
2644
+ if (!moveWidget(data, input.widget_id, input.new_parent_id, input.position)) {
2645
+ throw new Error(`Widget or parent not found`);
2646
+ }
2647
+ const backup = await fullBackup(input.site_id, input.page_id);
2648
+ const w = await writeData(input.site_id, input.page_id, data);
2649
+ const verification = await verifyWrite({
2650
+ siteId: input.site_id,
2651
+ pageId: input.page_id,
2652
+ description: `Re-read /wp/v2/pages/${input.page_id} and assert widget ${input.widget_id} is now a direct child of ${input.new_parent_id}`,
2653
+ predicate: (canonical) => {
2654
+ const parent = findElementById(canonical, input.new_parent_id);
2655
+ const isUnderNewParent = parent?.elements?.some((e) => e.id === input.widget_id) === true;
2656
+ return {
2657
+ ok: isUnderNewParent,
2658
+ notes: isUnderNewParent ? void 0 : `Widget ${input.widget_id} not found under ${input.new_parent_id} after move`
2659
+ };
2660
+ }
2661
+ });
2662
+ return {
2663
+ mode: "applied",
2664
+ page_id: input.page_id,
2665
+ widget_id: input.widget_id,
2666
+ new_parent_id: input.new_parent_id,
2667
+ backup_meta_key: backup.meta_key,
2668
+ css_flush: w.method,
2669
+ mutated: rawBefore !== w.serialized,
2670
+ warnings: [],
2671
+ verification
2672
+ };
2673
+ }
2674
+ });
2675
+
2676
+ // src/tools/bulk.ts
2677
+ import { z as z10 } from "zod";
2678
+ init_wp_rest();
2679
+ init_config();
2680
+ init_backup();
2681
+ init_css_flush();
2682
+ init_policies();
2683
+ init_confirmation();
2684
+ async function listElementorPageIds(siteId) {
2685
+ const out = [];
2686
+ let page = 1;
2687
+ for (; ; ) {
2688
+ const items = await wpRequest("/wp/v2/pages", {
2689
+ siteId,
2690
+ query: {
2691
+ meta_key: "_elementor_edit_mode",
2692
+ meta_value: "builder",
2693
+ context: "edit",
2694
+ per_page: 100,
2695
+ page,
2696
+ _fields: "id,title"
2697
+ }
2698
+ });
2699
+ if (items.length === 0) break;
2700
+ out.push(...items.map((p) => ({ id: p.id, title: p.title.rendered })));
2701
+ if (items.length < 100) break;
2702
+ page++;
2703
+ if (page > 50) break;
2704
+ }
2705
+ return out;
2706
+ }
2707
+ var bulkFindReplaceSiteTool = defineTool({
2708
+ name: "bulk_find_replace_site",
2709
+ description: "Find/replace plain text in every Elementor page on a single site. TWO-CALL FLOW: dry-run returns per-page match_count + total + confirmation_token. Apply iterates each page (auto-backup + validate + flush per page). Slower than wp_search_replace but works without SSH and gives per-page granularity.",
2710
+ inputSchema: z10.object({
2711
+ site_id: z10.string().optional(),
2712
+ find: z10.string().min(1),
2713
+ replace: z10.string(),
2714
+ widget_type: z10.string().optional(),
2715
+ case_sensitive: z10.boolean().default(false),
2716
+ confirmation: z10.string().optional()
2717
+ }),
2718
+ outputSchema: z10.object({
2719
+ mode: z10.enum(["dry_run", "applied"]),
2720
+ site_id: z10.string(),
2721
+ pages_scanned: z10.number(),
2722
+ total_match_count: z10.number(),
2723
+ pages_with_matches: z10.array(z10.object({
2724
+ page_id: z10.number(),
2725
+ title: z10.string(),
2726
+ match_count: z10.number()
2727
+ })),
2728
+ pages_applied: z10.array(z10.object({
2729
+ page_id: z10.number(),
2730
+ backup_meta_key: z10.string().optional(),
2731
+ css_flush: z10.string().optional(),
2732
+ mode: z10.enum(["applied", "rolled_back", "skipped"]),
2733
+ error: z10.string().optional()
2734
+ })).optional(),
2735
+ confirmation_token: z10.string().optional(),
2736
+ expires_in_seconds: z10.number().optional()
2737
+ }),
2738
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2739
+ async handler(input) {
2740
+ const pages = await listElementorPageIds(input.site_id);
2741
+ const matches = [];
2742
+ for (const p of pages) {
2743
+ try {
2744
+ const page = await wpRequest(`/wp/v2/pages/${p.id}?context=edit&_fields=meta`, { siteId: input.site_id });
2745
+ const v = page.meta?._elementor_data;
2746
+ const raw = typeof v === "string" ? v : JSON.stringify(v ?? []);
2747
+ const data = parseElementorData(raw);
2748
+ const dry = findReplaceInWidgets(JSON.parse(JSON.stringify(data)), input.find, input.replace, {
2749
+ widgetType: input.widget_type,
2750
+ caseSensitive: input.case_sensitive
2751
+ });
2752
+ if (dry.replacementCount > 0) matches.push({ page_id: p.id, title: p.title, match_count: dry.replacementCount });
2753
+ } catch {
2754
+ }
2755
+ }
2756
+ const total = matches.reduce((s, m) => s + m.match_count, 0);
2757
+ if (!input.confirmation) {
2758
+ if (total === 0) {
2759
+ return {
2760
+ mode: "dry_run",
2761
+ site_id: loadConfig().default_site_id ?? "default",
2762
+ pages_scanned: pages.length,
2763
+ total_match_count: 0,
2764
+ pages_with_matches: []
2765
+ };
2766
+ }
2767
+ const token = issueConfirmation("bulk_find_replace_site", { find: input.find, replace: input.replace, page_ids: matches.map((m) => m.page_id) }, POLICIES.CONFIRMATION_TTL_SECONDS);
2768
+ return {
2769
+ mode: "dry_run",
2770
+ site_id: input.site_id ?? loadConfig().default_site_id ?? "default",
2771
+ pages_scanned: pages.length,
2772
+ total_match_count: total,
2773
+ pages_with_matches: matches,
2774
+ confirmation_token: token,
2775
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
2776
+ };
2777
+ }
2778
+ const conf = consumeConfirmation(input.confirmation, "bulk_find_replace_site");
2779
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2780
+ const applied = [];
2781
+ for (const m of matches) {
2782
+ try {
2783
+ const page = await wpRequest(`/wp/v2/pages/${m.page_id}?context=edit&_fields=meta`, { siteId: input.site_id });
2784
+ const v = page.meta?._elementor_data;
2785
+ const raw = typeof v === "string" ? v : JSON.stringify(v ?? []);
2786
+ const data = parseElementorData(raw);
2787
+ findReplaceInWidgets(data, input.find, input.replace, { widgetType: input.widget_type, caseSensitive: input.case_sensitive });
2788
+ const ser = serializeElementorData(data);
2789
+ const validation = validateElementorData(ser);
2790
+ if (!validation.valid) {
2791
+ applied.push({ page_id: m.page_id, mode: "rolled_back", error: validation.errors.join("; ") });
2792
+ continue;
2793
+ }
2794
+ const backup = await fullBackup(input.site_id, m.page_id);
2795
+ await wpRequest(`/wp/v2/pages/${m.page_id}`, {
2796
+ siteId: input.site_id,
2797
+ method: "PUT",
2798
+ body: { meta: { _elementor_data: ser } }
2799
+ });
2800
+ const flush = await flushCSS(input.site_id, m.page_id);
2801
+ applied.push({ page_id: m.page_id, backup_meta_key: backup.meta_key, css_flush: flush.method, mode: "applied" });
2802
+ } catch (e) {
2803
+ applied.push({ page_id: m.page_id, mode: "skipped", error: e.message });
2804
+ }
2805
+ }
2806
+ return {
2807
+ mode: "applied",
2808
+ site_id: input.site_id ?? loadConfig().default_site_id ?? "default",
2809
+ pages_scanned: pages.length,
2810
+ total_match_count: total,
2811
+ pages_with_matches: matches,
2812
+ pages_applied: applied
2813
+ };
2814
+ }
2815
+ });
2816
+ var fleetFindReplaceTool = defineTool({
2817
+ name: "fleet_find_replace",
2818
+ description: "Find/replace plain text across every Elementor page of every site in the pool. Same flow as bulk_find_replace_site but iterates across sites. Returns per-site + grand-total summary. Dry-run first; second call applies. Use sparingly \u2014 this is the nuclear option.",
2819
+ inputSchema: z10.object({
2820
+ find: z10.string().min(1),
2821
+ replace: z10.string(),
2822
+ site_ids: z10.array(z10.string()).optional().describe("Subset of sites to hit. Defaults to all."),
2823
+ widget_type: z10.string().optional(),
2824
+ case_sensitive: z10.boolean().default(false),
2825
+ confirmation: z10.string().optional()
2826
+ }),
2827
+ outputSchema: z10.object({
2828
+ mode: z10.enum(["dry_run", "applied"]),
2829
+ sites_scanned: z10.number(),
2830
+ total_match_count: z10.number(),
2831
+ by_site: z10.array(z10.object({
2832
+ site_id: z10.string(),
2833
+ url: z10.string(),
2834
+ pages_scanned: z10.number(),
2835
+ matches: z10.number(),
2836
+ error: z10.string().optional()
2837
+ })),
2838
+ confirmation_token: z10.string().optional(),
2839
+ expires_in_seconds: z10.number().optional()
2840
+ }),
2841
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2842
+ async handler(input) {
2843
+ const cfg = loadConfig();
2844
+ const targets = input.site_ids ? cfg.sites.filter((s) => input.site_ids?.includes(s.id)) : cfg.sites;
2845
+ const by_site = [];
2846
+ let total = 0;
2847
+ for (const site of targets) {
2848
+ try {
2849
+ const pages = await listElementorPageIds(site.id);
2850
+ let siteMatches = 0;
2851
+ for (const p of pages) {
2852
+ try {
2853
+ const page = await wpRequest(`/wp/v2/pages/${p.id}?context=edit&_fields=meta`, { siteId: site.id });
2854
+ const v = page.meta?._elementor_data;
2855
+ const raw = typeof v === "string" ? v : JSON.stringify(v ?? []);
2856
+ const data = parseElementorData(raw);
2857
+ const dry = findReplaceInWidgets(JSON.parse(JSON.stringify(data)), input.find, input.replace, {
2858
+ widgetType: input.widget_type,
2859
+ caseSensitive: input.case_sensitive
2860
+ });
2861
+ siteMatches += dry.replacementCount;
2862
+ } catch {
2863
+ }
2864
+ }
2865
+ by_site.push({ site_id: site.id, url: site.url, pages_scanned: pages.length, matches: siteMatches });
2866
+ total += siteMatches;
2867
+ } catch (e) {
2868
+ by_site.push({ site_id: site.id, url: site.url, pages_scanned: 0, matches: 0, error: e.message });
2869
+ }
2870
+ }
2871
+ if (!input.confirmation) {
2872
+ if (total === 0) {
2873
+ return {
2874
+ mode: "dry_run",
2875
+ sites_scanned: by_site.length,
2876
+ total_match_count: 0,
2877
+ by_site
2878
+ };
2879
+ }
2880
+ const token = issueConfirmation("fleet_find_replace", { find: input.find, replace: input.replace }, POLICIES.CONFIRMATION_TTL_SECONDS);
2881
+ return {
2882
+ mode: "dry_run",
2883
+ sites_scanned: by_site.length,
2884
+ total_match_count: total,
2885
+ by_site,
2886
+ confirmation_token: token,
2887
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
2888
+ };
2889
+ }
2890
+ const conf = consumeConfirmation(input.confirmation, "fleet_find_replace");
2891
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2892
+ for (const site of targets) {
2893
+ const pages = await listElementorPageIds(site.id);
2894
+ for (const p of pages) {
2895
+ try {
2896
+ const page = await wpRequest(`/wp/v2/pages/${p.id}?context=edit&_fields=meta`, { siteId: site.id });
2897
+ const v = page.meta?._elementor_data;
2898
+ const raw = typeof v === "string" ? v : JSON.stringify(v ?? []);
2899
+ const data = parseElementorData(raw);
2900
+ const r = findReplaceInWidgets(data, input.find, input.replace, { widgetType: input.widget_type, caseSensitive: input.case_sensitive });
2901
+ if (r.replacementCount === 0) continue;
2902
+ const ser = serializeElementorData(data);
2903
+ const validation = validateElementorData(ser);
2904
+ if (!validation.valid) continue;
2905
+ await fullBackup(site.id, p.id);
2906
+ await wpRequest(`/wp/v2/pages/${p.id}`, {
2907
+ siteId: site.id,
2908
+ method: "PUT",
2909
+ body: { meta: { _elementor_data: ser } }
2910
+ });
2911
+ await flushCSS(site.id, p.id);
2912
+ } catch {
2913
+ }
2914
+ }
2915
+ }
2916
+ return {
2917
+ mode: "applied",
2918
+ sites_scanned: by_site.length,
2919
+ total_match_count: total,
2920
+ by_site
2921
+ };
2922
+ }
2923
+ });
2924
+ var restoreFromFileTool = defineTool({
2925
+ name: "restore_from_file",
2926
+ description: "Restore a page from a JSON backup file (created by ANY earlier op with backup_to_file=true or by direct fullBackup with to_file). Requires the file_path returned by that backup. Two-call confirmation.",
2927
+ inputSchema: z10.object({
2928
+ site_id: z10.string().optional(),
2929
+ page_id: z10.number().int().positive(),
2930
+ file_path: z10.string().min(1),
2931
+ confirmation: z10.string().optional()
2932
+ }),
2933
+ outputSchema: z10.object({
2934
+ mode: z10.enum(["dry_run", "restored"]),
2935
+ page_id: z10.number(),
2936
+ file_path: z10.string(),
2937
+ method: z10.enum(["wp-cli", "rest"]).optional(),
2938
+ pre_restore_backup_meta_key: z10.string().optional(),
2939
+ css_flush: z10.string().optional(),
2940
+ confirmation_token: z10.string().optional()
2941
+ }),
2942
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2943
+ async handler(input) {
2944
+ if (!input.confirmation) {
2945
+ const token = issueConfirmation("restore_from_file", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2946
+ return {
2947
+ mode: "dry_run",
2948
+ page_id: input.page_id,
2949
+ file_path: input.file_path,
2950
+ confirmation_token: token
2951
+ };
2952
+ }
2953
+ const conf = consumeConfirmation(input.confirmation, "restore_from_file");
2954
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2955
+ const pre = await fullBackup(input.site_id, input.page_id);
2956
+ const r = await restoreFromFile(input.site_id, input.page_id, input.file_path);
2957
+ const flush = await flushCSS(input.site_id, input.page_id);
2958
+ return {
2959
+ mode: "restored",
2960
+ page_id: input.page_id,
2961
+ file_path: input.file_path,
2962
+ method: r.method,
2963
+ pre_restore_backup_meta_key: pre.meta_key,
2964
+ css_flush: flush.method
2965
+ };
2966
+ }
2967
+ });
2968
+
2118
2969
  // src/tools/index.ts
2119
2970
  var tools = [
2120
2971
  // Sites & health
@@ -2131,11 +2982,23 @@ var tools = [
2131
2982
  listElementorBackupsTool,
2132
2983
  restoreElementorBackupTool,
2133
2984
  duplicateElementorPageTool,
2985
+ // Widget-level CRUD (v1.1)
2986
+ readWidgetTool,
2987
+ updateWidgetSettingsTool,
2988
+ deleteWidgetTool,
2989
+ duplicateWidgetTool,
2990
+ swapWidgetTypeTool,
2991
+ addWidgetTool,
2992
+ moveWidgetTool,
2134
2993
  // Templates
2135
2994
  listTemplatesTool,
2136
2995
  exportTemplateTool,
2137
2996
  importTemplateTool,
2138
2997
  applyTemplateToPageTool,
2998
+ // Bulk + fleet (v1.1)
2999
+ bulkFindReplaceSiteTool,
3000
+ fleetFindReplaceTool,
3001
+ restoreFromFileTool,
2139
3002
  // WP-CLI escape
2140
3003
  wpCliRunTool,
2141
3004
  wpSearchReplaceTool,