PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / 5.1
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback v5.1
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / doit / abilities / class-avcf-abilities-themes.php
atarim-visual-collaboration / doit / abilities Last commit date
class-avcf-abilities-base.php 3 weeks ago class-avcf-abilities-block-navigation.php 3 weeks ago class-avcf-abilities-cache.php 3 weeks ago class-avcf-abilities-content.php 3 weeks ago class-avcf-abilities-core.php 3 weeks ago class-avcf-abilities-global-styles.php 3 weeks ago class-avcf-abilities-gutenberg.php 3 weeks ago class-avcf-abilities-media.php 3 weeks ago class-avcf-abilities-metadata.php 3 weeks ago class-avcf-abilities-navigation.php 3 weeks ago class-avcf-abilities-patterns.php 3 weeks ago class-avcf-abilities-plugins.php 3 weeks ago class-avcf-abilities-settings.php 3 weeks ago class-avcf-abilities-taxonomies.php 3 weeks ago class-avcf-abilities-templates.php 3 weeks ago class-avcf-abilities-theme-files.php 3 weeks ago class-avcf-abilities-themes.php 3 weeks ago class-avcf-abilities-users.php 3 weeks ago
class-avcf-abilities-themes.php
887 lines
1 <?php
2 /**
3 * Theme management MCP abilities.
4 *
5 * Registers Atarim/* abilities for installing, switching, updating, and
6 * removing WordPress themes via the AI action layer. Mirrors the plugin
7 * lifecycle abilities (class-avcf-abilities-plugins.php) where possible —
8 * same WP_Upgrader pattern, same silent skin, same shape of response.
9 *
10 * Exposed abilities:
11 * atarim/list-themes All installed themes + update info.
12 * atarim/install-theme Install a free theme from WordPress.org.
13 * atarim/activate-theme Switch the active theme (with safety checks).
14 * atarim/update-theme Update an installed theme to latest.
15 * atarim/delete-theme Permanently remove a theme from disk.
16 *
17 * Compatibility checks on activate-theme:
18 * Hard fail — theme missing, broken (errors()), WP version too old,
19 * PHP version too old, child theme with missing parent.
20 * Soft warn — block-vs-classic switch, WooCommerce version, multisite hints.
21 *
22 * Note: ability names registered here must also be added to the $tools array
23 * in doit/class-avcf-mcp.php::avcf_mcp_setup_server() to be exposed by the
24 * MCP server.
25 *
26 * @package atarim-visual-collaboration
27 */
28
29 if ( ! defined('ABSPATH') ) {
30 exit;
31 }
32
33 class AVCF_Abilities_Themes extends AVCF_Abilities_Base {
34
35 /**
36 * Register all theme management abilities.
37 * Called from AVCF_MCP::avcf_mcp_register_abilities() on wp_abilities_api_init.
38 */
39 public function register() {
40
41 // ---- list-themes ----
42 wp_register_ability( 'atarim/list-themes', [
43 'label' => 'List Themes',
44 'description' => 'Returns all installed WordPress themes with their version, author, status (active/inactive), block-theme vs classic flag, parent theme for child themes, and update availability.',
45 'category' => 'atarim',
46 'input_schema' => [
47 'type' => 'object',
48 'properties' => [
49 'status' => [
50 'type' => 'string',
51 'description' => 'Filter by activation status. Omit for all.',
52 'enum' => [ 'active', 'inactive', 'all' ],
53 'default' => 'all',
54 ],
55 ],
56 'additionalProperties' => false,
57 ],
58 'output_schema' => [
59 'type' => 'object',
60 'properties' => [
61 'total' => [ 'type' => 'integer' ],
62 'themes' => [
63 'type' => 'array',
64 'items' => [
65 'type' => 'object',
66 'properties' => [
67 'stylesheet' => [ 'type' => 'string' ],
68 'name' => [ 'type' => 'string' ],
69 'version' => [ 'type' => 'string' ],
70 'author' => [ 'type' => 'string' ],
71 'description' => [ 'type' => 'string' ],
72 'status' => [ 'type' => 'string' ],
73 'is_block_theme' => [ 'type' => 'boolean' ],
74 'is_child_theme' => [ 'type' => 'boolean' ],
75 'parent' => [ 'type' => 'string' ],
76 'requires_wp' => [ 'type' => 'string' ],
77 'requires_php' => [ 'type' => 'string' ],
78 'update_available' => [ 'type' => 'boolean' ],
79 'new_version' => [ 'type' => 'string' ],
80 ],
81 ],
82 ],
83 ],
84 'required' => [ 'total', 'themes' ],
85 ],
86 'execute_callback' => function( $input = [] ) {
87 $status_filter = isset( $input['status'] ) ? $input['status'] : 'all';
88 $all_themes = wp_get_themes();
89 $active_slug = get_stylesheet();
90 $updates = get_site_transient( 'update_themes' );
91 $update_list = ( $updates && ! empty( $updates->response ) ) ? $updates->response : [];
92
93 $themes = [];
94 foreach ( $all_themes as $stylesheet => $theme ) {
95 $is_active = ( $stylesheet === $active_slug );
96 $current_status = $is_active ? 'active' : 'inactive';
97
98 if ( $status_filter !== 'all' && $status_filter !== $current_status ) {
99 continue;
100 }
101
102 $is_child = ( $theme->parent() !== false );
103 $parent = $is_child ? $theme->parent()->get_stylesheet() : '';
104 $has_update = isset( $update_list[ $stylesheet ] );
105 $new_version = $has_update ? $update_list[ $stylesheet ]['new_version'] : '';
106
107 $themes[] = [
108 'stylesheet' => $stylesheet,
109 'name' => $theme->get( 'Name' ),
110 'version' => $theme->get( 'Version' ),
111 'author' => wp_strip_all_tags( (string) $theme->get( 'Author' ) ),
112 'description' => wp_strip_all_tags( (string) $theme->get( 'Description' ) ),
113 'status' => $current_status,
114 'is_block_theme' => method_exists( $theme, 'is_block_theme' ) ? $theme->is_block_theme() : false,
115 'is_child_theme' => $is_child,
116 'parent' => $parent,
117 'requires_wp' => (string) $theme->get( 'RequiresWP' ),
118 'requires_php' => (string) $theme->get( 'RequiresPHP' ),
119 'update_available' => $has_update,
120 'new_version' => $new_version,
121 ];
122 }
123
124 return [
125 'total' => count( $themes ),
126 'themes' => $themes,
127 ];
128 },
129 'permission_callback' => function() {
130 return current_user_can( 'switch_themes' );
131 },
132 'meta' => [
133 'mcp' => [ 'public' => true, 'type' => 'tool' ],
134 'annotations' => [
135 'readonly' => true,
136 'destructive' => false,
137 'idempotent' => true,
138 ],
139 ],
140 ] );
141
142 // ---- install-theme ----
143 wp_register_ability( 'atarim/install-theme', [
144 'label' => 'Install Theme',
145 'description' => 'Installs a free theme from the WordPress.org theme repository. Uses the WordPress upgrader so files are downloaded, verified, and unpacked into wp-content/themes/. The theme is NOT activated by installation — call activate-theme separately. For paid/third-party themes use upload-from-URL or upload-from-ZIP (not yet supported by this ability).',
146 'category' => 'atarim',
147 'input_schema' => [
148 'type' => 'object',
149 'properties' => [
150 'stylesheet' => [
151 'type' => 'string',
152 'description' => 'Theme slug (folder name) as it appears on wordpress.org/themes (e.g. "twentytwentyfour", "astra").',
153 'minLength' => 1,
154 ],
155 ],
156 'required' => [ 'stylesheet' ],
157 'additionalProperties' => false,
158 ],
159 'output_schema' => [
160 'type' => 'object',
161 'properties' => [
162 'success' => [ 'type' => 'boolean' ],
163 'stylesheet' => [ 'type' => 'string' ],
164 'message' => [ 'type' => 'string' ],
165 ],
166 'required' => [ 'success', 'stylesheet', 'message' ],
167 ],
168 'execute_callback' => function( $input = [] ) {
169 $stylesheet = isset( $input['stylesheet'] ) ? sanitize_key( $input['stylesheet'] ) : '';
170 if ( empty( $stylesheet ) ) {
171 return [
172 'success' => false,
173 'stylesheet' => '',
174 'message' => 'Theme stylesheet is required.',
175 ];
176 }
177
178 require_once ABSPATH . 'wp-admin/includes/file.php';
179 require_once ABSPATH . 'wp-admin/includes/misc.php';
180 require_once ABSPATH . 'wp-admin/includes/theme.php';
181 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
182
183 // Query wordpress.org for the theme — confirms free repo and gets verified download.
184 $api = themes_api( 'theme_information', [
185 'slug' => $stylesheet,
186 'fields' => [ 'sections' => false ],
187 ] );
188
189 if ( is_wp_error( $api ) ) {
190 return [
191 'success' => false,
192 'stylesheet' => $stylesheet,
193 'message' => 'Theme not found in WordPress.org repository: ' . $api->get_error_message(),
194 ];
195 }
196
197 if ( empty( $api->download_link ) ) {
198 return [
199 'success' => false,
200 'stylesheet' => $stylesheet,
201 'message' => 'No download link available — only free WordPress.org themes are supported.',
202 ];
203 }
204
205 $skin = new \WP_Ajax_Upgrader_Skin();
206 $upgrader = new \Theme_Upgrader( $skin );
207 $result = $upgrader->install( $api->download_link );
208
209 if ( is_wp_error( $result ) ) {
210 return [
211 'success' => false,
212 'stylesheet' => $stylesheet,
213 'message' => 'Install failed: ' . $result->get_error_message(),
214 ];
215 }
216
217 if ( $result === false ) {
218 $skin_errors = $skin->get_errors();
219 $err_msg = is_wp_error( $skin_errors ) && $skin_errors->has_errors()
220 ? $skin_errors->get_error_message()
221 : 'Unknown installer error (filesystem permissions or unavailable updates).';
222 return [
223 'success' => false,
224 'stylesheet' => $stylesheet,
225 'message' => 'Install failed: ' . $err_msg,
226 ];
227 }
228
229 return [
230 'success' => true,
231 'stylesheet' => $stylesheet,
232 'message' => 'Theme installed successfully. Call activate-theme separately to switch to it.',
233 ];
234 },
235 'permission_callback' => function() {
236 return current_user_can( 'install_themes' );
237 },
238 'meta' => [
239 'mcp' => [ 'public' => true, 'type' => 'tool' ],
240 'annotations' => [
241 'readonly' => false,
242 'destructive' => false,
243 'idempotent' => false,
244 ],
245 ],
246 ] );
247
248 // ---- activate-theme ----
249 wp_register_ability( 'atarim/activate-theme', [
250 'label' => 'Activate Theme',
251 'description' => 'Switches the active theme. Only one theme can be active at a time — the previously active theme is implicitly deactivated. Performs hard-fail compatibility checks (theme exists, not broken, WordPress / PHP version requirements met, child theme parent present) and returns soft warnings for fuzzy compatibility concerns (block-vs-classic switch, plugin compatibility hints).',
252 'category' => 'atarim',
253 'input_schema' => [
254 'type' => 'object',
255 'properties' => [
256 'stylesheet' => [
257 'type' => 'string',
258 'description' => 'Stylesheet slug of the theme to activate.',
259 'minLength' => 1,
260 ],
261 ],
262 'required' => [ 'stylesheet' ],
263 'additionalProperties' => false,
264 ],
265 'output_schema' => [
266 'type' => 'object',
267 'properties' => [
268 'success' => [ 'type' => 'boolean' ],
269 'stylesheet' => [ 'type' => 'string' ],
270 'previous' => [ 'type' => 'string' ],
271 'is_block_theme' => [ 'type' => 'boolean' ],
272 'warnings' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
273 'message' => [ 'type' => 'string' ],
274 ],
275 'required' => [ 'success', 'stylesheet', 'message' ],
276 ],
277 'execute_callback' => function( $input = [] ) {
278 $stylesheet = isset( $input['stylesheet'] ) ? sanitize_key( $input['stylesheet'] ) : '';
279 if ( empty( $stylesheet ) ) {
280 return [
281 'success' => false,
282 'stylesheet' => '',
283 'previous' => '',
284 'warnings' => [],
285 'message' => 'Theme stylesheet is required.',
286 ];
287 }
288
289 $theme = wp_get_theme( $stylesheet );
290
291 // --- Hard fails ---
292
293 if ( ! $theme->exists() ) {
294 return [
295 'success' => false,
296 'stylesheet' => $stylesheet,
297 'previous' => get_stylesheet(),
298 'warnings' => [],
299 'message' => sprintf( 'Theme "%s" is not installed. Use install-theme first.', $stylesheet ),
300 ];
301 }
302
303 if ( $theme->errors() ) {
304 $errors = $theme->errors()->get_error_messages();
305 return [
306 'success' => false,
307 'stylesheet' => $stylesheet,
308 'previous' => get_stylesheet(),
309 'warnings' => [],
310 'message' => 'Theme is broken: ' . implode( '; ', $errors ),
311 ];
312 }
313
314 $requires_wp = (string) $theme->get( 'RequiresWP' );
315 $requires_php = (string) $theme->get( 'RequiresPHP' );
316
317 if ( $requires_wp !== '' ) {
318 global $wp_version;
319 if ( version_compare( $wp_version, $requires_wp, '<' ) ) {
320 return [
321 'success' => false,
322 'stylesheet' => $stylesheet,
323 'previous' => get_stylesheet(),
324 'warnings' => [],
325 'message' => sprintf(
326 'Theme requires WordPress %s; this site runs %s. Update WordPress before activating.',
327 $requires_wp,
328 $wp_version
329 ),
330 ];
331 }
332 }
333
334 if ( $requires_php !== '' ) {
335 if ( version_compare( PHP_VERSION, $requires_php, '<' ) ) {
336 return [
337 'success' => false,
338 'stylesheet' => $stylesheet,
339 'previous' => get_stylesheet(),
340 'warnings' => [],
341 'message' => sprintf(
342 'Theme requires PHP %s; this site runs %s. Upgrade PHP before activating.',
343 $requires_php,
344 PHP_VERSION
345 ),
346 ];
347 }
348 }
349
350 // Child theme: parent must be installed and not broken.
351 if ( $theme->parent() !== false ) {
352 $parent = $theme->parent();
353 if ( ! $parent->exists() || $parent->errors() ) {
354 return [
355 'success' => false,
356 'stylesheet' => $stylesheet,
357 'previous' => get_stylesheet(),
358 'warnings' => [],
359 'message' => sprintf(
360 'Child theme "%s" requires parent theme "%s", which is missing or broken.',
361 $stylesheet,
362 $theme->get_template()
363 ),
364 ];
365 }
366 }
367
368 // --- Soft warnings ---
369 $warnings = [];
370
371 $previous_slug = get_stylesheet();
372 $previous_theme = wp_get_theme( $previous_slug );
373 $previous_is_block = ( $previous_theme->exists() && method_exists( $previous_theme, 'is_block_theme' ) )
374 ? $previous_theme->is_block_theme()
375 : false;
376 $new_is_block = method_exists( $theme, 'is_block_theme' ) ? $theme->is_block_theme() : false;
377
378 if ( $previous_is_block && ! $new_is_block ) {
379 $warnings[] = 'Switching from a block theme to a classic theme. Full Site Editing templates from the previous theme will no longer be editable; the site falls back to PHP templates.';
380 } elseif ( ! $previous_is_block && $new_is_block ) {
381 $warnings[] = 'Switching from a classic theme to a block theme. Existing classic-editor posts will render fine, but template editing moves to the Site Editor (Full Site Editing). Customizer options from the previous theme will not carry over.';
382 }
383
384 // WooCommerce hint — if Woo is active, warn when the theme declares it supports it OR makes no declaration.
385 if ( class_exists( 'WooCommerce' ) ) {
386 $supports_woo = (string) $theme->get( 'WC tested up to' );
387 if ( $supports_woo === '' ) {
388 $warnings[] = 'WooCommerce is active on this site but the new theme does not declare WooCommerce compatibility. Verify product / cart / checkout pages render correctly after switching.';
389 } elseif ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, $supports_woo, '>' ) ) {
390 $warnings[] = sprintf(
391 'Theme declares WooCommerce compatibility up to %s; this site runs WooCommerce %s.',
392 $supports_woo,
393 WC_VERSION
394 );
395 }
396 }
397
398 // --- Switch the theme ---
399 switch_theme( $stylesheet );
400
401 // switch_theme has no return value; verify by reading back.
402 if ( get_stylesheet() !== $stylesheet ) {
403 return [
404 'success' => false,
405 'stylesheet' => $stylesheet,
406 'previous' => $previous_slug,
407 'warnings' => $warnings,
408 'message' => 'Theme switch failed: WordPress did not register the new active theme.',
409 ];
410 }
411
412 return [
413 'success' => true,
414 'stylesheet' => $stylesheet,
415 'previous' => $previous_slug,
416 'is_block_theme' => $new_is_block,
417 'warnings' => $warnings,
418 'message' => sprintf(
419 'Theme switched to "%s" (was "%s"). %s',
420 $theme->get( 'Name' ),
421 $previous_theme->exists() ? $previous_theme->get( 'Name' ) : $previous_slug,
422 empty( $warnings ) ? 'No compatibility warnings.' : sprintf( '%d warning(s) returned.', count( $warnings ) )
423 ),
424 ];
425 },
426 'permission_callback' => function() {
427 return current_user_can( 'switch_themes' );
428 },
429 'meta' => [
430 'mcp' => [ 'public' => true, 'type' => 'tool' ],
431 'annotations' => [
432 'readonly' => false,
433 'destructive' => false,
434 'idempotent' => true,
435 ],
436 ],
437 ] );
438
439 // ---- update-theme ----
440 wp_register_ability( 'atarim/update-theme', [
441 'label' => 'Update Theme',
442 'description' => 'Updates an installed theme to the latest version available from its source (WordPress.org for repo themes, the theme\'s own update server for paid themes that have registered their update mechanism). Uses the WordPress upgrader; falls back gracefully if no update is available.',
443 'category' => 'atarim',
444 'input_schema' => [
445 'type' => 'object',
446 'properties' => [
447 'stylesheet' => [
448 'type' => 'string',
449 'description' => 'Stylesheet slug of the theme to update.',
450 'minLength' => 1,
451 ],
452 ],
453 'required' => [ 'stylesheet' ],
454 'additionalProperties' => false,
455 ],
456 'output_schema' => [
457 'type' => 'object',
458 'properties' => [
459 'success' => [ 'type' => 'boolean' ],
460 'stylesheet' => [ 'type' => 'string' ],
461 'from_version' => [ 'type' => 'string' ],
462 'to_version' => [ 'type' => 'string' ],
463 'message' => [ 'type' => 'string' ],
464 ],
465 'required' => [ 'success', 'stylesheet', 'message' ],
466 ],
467 'execute_callback' => function( $input = [] ) {
468 $stylesheet = isset( $input['stylesheet'] ) ? sanitize_key( $input['stylesheet'] ) : '';
469 if ( empty( $stylesheet ) ) {
470 return [
471 'success' => false,
472 'stylesheet' => '',
473 'from_version' => '',
474 'to_version' => '',
475 'message' => 'Theme stylesheet is required.',
476 ];
477 }
478
479 $theme = wp_get_theme( $stylesheet );
480 if ( ! $theme->exists() ) {
481 return [
482 'success' => false,
483 'stylesheet' => $stylesheet,
484 'from_version' => '',
485 'to_version' => '',
486 'message' => sprintf( 'Theme "%s" is not installed.', $stylesheet ),
487 ];
488 }
489
490 $from_version = (string) $theme->get( 'Version' );
491
492 require_once ABSPATH . 'wp-admin/includes/file.php';
493 require_once ABSPATH . 'wp-admin/includes/misc.php';
494 require_once ABSPATH . 'wp-admin/includes/theme.php';
495 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
496
497 // Refresh the update transient so we have current update info.
498 wp_update_themes();
499
500 $updates = get_site_transient( 'update_themes' );
501 $update_list = ( $updates && ! empty( $updates->response ) ) ? $updates->response : [];
502
503 if ( ! isset( $update_list[ $stylesheet ] ) ) {
504 return [
505 'success' => true,
506 'stylesheet' => $stylesheet,
507 'from_version' => $from_version,
508 'to_version' => $from_version,
509 'message' => 'No update available — theme is already at the latest version.',
510 ];
511 }
512
513 $skin = new \WP_Ajax_Upgrader_Skin();
514 $upgrader = new \Theme_Upgrader( $skin );
515 $result = $upgrader->upgrade( $stylesheet );
516
517 if ( is_wp_error( $result ) ) {
518 return [
519 'success' => false,
520 'stylesheet' => $stylesheet,
521 'from_version' => $from_version,
522 'to_version' => '',
523 'message' => 'Update failed: ' . $result->get_error_message(),
524 ];
525 }
526
527 if ( $result === false ) {
528 $skin_errors = $skin->get_errors();
529 $err_msg = is_wp_error( $skin_errors ) && $skin_errors->has_errors()
530 ? $skin_errors->get_error_message()
531 : 'Unknown updater error (filesystem permissions or download failed).';
532 return [
533 'success' => false,
534 'stylesheet' => $stylesheet,
535 'from_version' => $from_version,
536 'to_version' => '',
537 'message' => 'Update failed: ' . $err_msg,
538 ];
539 }
540
541 // Re-read the theme to confirm the new version.
542 wp_clean_themes_cache();
543 $fresh = wp_get_theme( $stylesheet );
544 $to_version = (string) $fresh->get( 'Version' );
545
546 return [
547 'success' => true,
548 'stylesheet' => $stylesheet,
549 'from_version' => $from_version,
550 'to_version' => $to_version,
551 'message' => sprintf( 'Theme updated from %s to %s.', $from_version, $to_version ),
552 ];
553 },
554 'permission_callback' => function() {
555 return current_user_can( 'update_themes' );
556 },
557 'meta' => [
558 'mcp' => [ 'public' => true, 'type' => 'tool' ],
559 'annotations' => [
560 'readonly' => false,
561 'destructive' => false,
562 'idempotent' => false,
563 ],
564 ],
565 ] );
566
567 // ---- delete-theme ----
568 wp_register_ability( 'atarim/delete-theme', [
569 'label' => 'Delete Theme',
570 'description' => 'Permanently removes a theme from disk (wp-content/themes/{stylesheet}/). Refuses to delete the currently active theme — switch to a different theme first. Refuses to delete a parent theme that has an installed child. Irreversible: no trash, no recovery without reinstalling.',
571 'category' => 'atarim',
572 'input_schema' => [
573 'type' => 'object',
574 'properties' => [
575 'stylesheet' => [
576 'type' => 'string',
577 'description' => 'Stylesheet slug of the theme to delete.',
578 'minLength' => 1,
579 ],
580 ],
581 'required' => [ 'stylesheet' ],
582 'additionalProperties' => false,
583 ],
584 'output_schema' => [
585 'type' => 'object',
586 'properties' => [
587 'success' => [ 'type' => 'boolean' ],
588 'stylesheet' => [ 'type' => 'string' ],
589 'message' => [ 'type' => 'string' ],
590 ],
591 'required' => [ 'success', 'stylesheet', 'message' ],
592 ],
593 'execute_callback' => function( $input = [] ) {
594 $stylesheet = isset( $input['stylesheet'] ) ? sanitize_key( $input['stylesheet'] ) : '';
595 if ( empty( $stylesheet ) ) {
596 return [
597 'success' => false,
598 'stylesheet' => '',
599 'message' => 'Theme stylesheet is required.',
600 ];
601 }
602
603 $theme = wp_get_theme( $stylesheet );
604 if ( ! $theme->exists() ) {
605 return [
606 'success' => false,
607 'stylesheet' => $stylesheet,
608 'message' => sprintf( 'Theme "%s" is not installed; nothing to delete.', $stylesheet ),
609 ];
610 }
611
612 if ( $stylesheet === get_stylesheet() || $stylesheet === get_template() ) {
613 return [
614 'success' => false,
615 'stylesheet' => $stylesheet,
616 'message' => sprintf(
617 'Cannot delete "%s" because it is the currently active theme. Switch to a different theme first.',
618 $stylesheet
619 ),
620 ];
621 }
622
623 // Check if any installed theme has this one as its parent.
624 $all_themes = wp_get_themes();
625 $children = [];
626 foreach ( $all_themes as $other_slug => $other ) {
627 if ( $other_slug === $stylesheet ) {
628 continue;
629 }
630 if ( $other->parent() !== false && $other->get_template() === $stylesheet ) {
631 $children[] = $other_slug;
632 }
633 }
634
635 if ( ! empty( $children ) ) {
636 return [
637 'success' => false,
638 'stylesheet' => $stylesheet,
639 'message' => sprintf(
640 'Cannot delete "%s" because it is the parent of installed child theme(s): %s. Delete the child first or switch dependency.',
641 $stylesheet,
642 implode( ', ', $children )
643 ),
644 ];
645 }
646
647 require_once ABSPATH . 'wp-admin/includes/file.php';
648 require_once ABSPATH . 'wp-admin/includes/theme.php';
649
650 $result = delete_theme( $stylesheet );
651
652 if ( is_wp_error( $result ) ) {
653 return [
654 'success' => false,
655 'stylesheet' => $stylesheet,
656 'message' => 'Delete failed: ' . $result->get_error_message(),
657 ];
658 }
659
660 if ( $result === false ) {
661 return [
662 'success' => false,
663 'stylesheet' => $stylesheet,
664 'message' => 'Delete failed: WordPress reported the operation did not complete (filesystem permissions or theme.php unavailable).',
665 ];
666 }
667
668 if ( $result === null ) {
669 return [
670 'success' => false,
671 'stylesheet' => $stylesheet,
672 'message' => 'Delete failed: filesystem credentials were required and could not be obtained.',
673 ];
674 }
675
676 return [
677 'success' => true,
678 'stylesheet' => $stylesheet,
679 'message' => sprintf( 'Theme "%s" deleted successfully.', $stylesheet ),
680 ];
681 },
682 'permission_callback' => function() {
683 return current_user_can( 'delete_themes' );
684 },
685 'meta' => [
686 'mcp' => [ 'public' => true, 'type' => 'tool' ],
687 'annotations' => [
688 'readonly' => false,
689 'destructive' => true,
690 'idempotent' => false,
691 ],
692 ],
693 ] );
694
695 // ---- get-additional-css ----
696 wp_register_ability( 'atarim/get-additional-css', [
697 'label' => 'Get Additional CSS',
698 'description' => 'Returns the site-wide Additional CSS (the Customizer > Additional CSS panel) for a theme. WordPress stores this as a per-theme custom_css post and prints it inline in the <head> on every front-end page - it is not a file on disk. Defaults to the active theme.',
699 'category' => 'atarim',
700 'input_schema' => [
701 'type' => 'object',
702 'properties' => [
703 'stylesheet' => [
704 'type' => 'string',
705 'description' => 'Theme stylesheet slug (folder name) whose Additional CSS to read. Omit for the active theme.',
706 'minLength' => 1,
707 ],
708 ],
709 'additionalProperties' => false,
710 ],
711 'output_schema' => [
712 'type' => 'object',
713 'properties' => [
714 'success' => [ 'type' => 'boolean' ],
715 'stylesheet' => [ 'type' => 'string' ],
716 'css' => [ 'type' => 'string' ],
717 'message' => [ 'type' => 'string' ],
718 ],
719 'required' => [ 'success', 'message' ],
720 ],
721 'execute_callback' => function( $input = [] ) {
722 $stylesheet = isset( $input['stylesheet'] ) && $input['stylesheet'] !== ''
723 ? sanitize_text_field( (string) $input['stylesheet'] )
724 : get_stylesheet();
725
726 if ( ! wp_get_theme( $stylesheet )->exists() ) {
727 return [ 'success' => false, 'message' => sprintf( 'Theme "%s" is not installed.', $stylesheet ) ];
728 }
729
730 $css = (string) wp_get_custom_css( $stylesheet );
731
732 return [
733 'success' => true,
734 'stylesheet' => $stylesheet,
735 'css' => $css,
736 'message' => ( '' === $css ) ? 'No Additional CSS is set for this theme.' : 'OK.',
737 ];
738 },
739 'permission_callback' => function() {
740 return current_user_can( 'edit_theme_options' );
741 },
742 'meta' => [
743 'mcp' => [ 'public' => true, 'type' => 'tool' ],
744 'annotations' => [
745 'readonly' => true,
746 'destructive' => false,
747 'idempotent' => true,
748 ],
749 ],
750 ] );
751
752 // ---- set-additional-css ----
753 wp_register_ability( 'atarim/set-additional-css', [
754 'label' => 'Set Additional CSS',
755 'description' => 'Sets the site-wide Additional CSS (Customizer > Additional CSS) for a theme. Accepts RAW CSS ONLY - do NOT pass Gutenberg block markup, HTML tags, <p> / <br>, or block comments. The value is written through the native WordPress custom-CSS pipeline (wp_update_custom_css_post) and printed inline exactly like the Customizer panel; it is not written to a file. Incoming content is defensively normalised (block comments and tags stripped, HTML entities decoded, smart quotes fixed) but callers should still send clean CSS. Defaults to the active theme. mode "replace" (default) overwrites all Additional CSS; "append" adds the given CSS after the existing CSS.',
756 'category' => 'atarim',
757 'input_schema' => [
758 'type' => 'object',
759 'properties' => [
760 'css' => [
761 'type' => 'string',
762 'description' => 'Raw CSS to store. No HTML, no block markup, no block comments.',
763 ],
764 'stylesheet' => [
765 'type' => 'string',
766 'description' => 'Theme stylesheet slug (folder name) to target. Omit for the active theme.',
767 'minLength' => 1,
768 ],
769 'mode' => [
770 'type' => 'string',
771 'description' => '"replace" overwrites all Additional CSS (default). "append" adds the given CSS after the existing CSS.',
772 'enum' => [ 'replace', 'append' ],
773 'default' => 'replace',
774 ],
775 ],
776 'required' => [ 'css' ],
777 'additionalProperties' => false,
778 ],
779 'output_schema' => [
780 'type' => 'object',
781 'properties' => [
782 'success' => [ 'type' => 'boolean' ],
783 'stylesheet' => [ 'type' => 'string' ],
784 'css' => [ 'type' => 'string' ],
785 'message' => [ 'type' => 'string' ],
786 ],
787 'required' => [ 'success', 'message' ],
788 ],
789 'execute_callback' => function( $input = [] ) {
790 if ( ! isset( $input['css'] ) || ! is_string( $input['css'] ) ) {
791 return [ 'success' => false, 'message' => 'css is required and must be a string.' ];
792 }
793
794 $stylesheet = isset( $input['stylesheet'] ) && $input['stylesheet'] !== ''
795 ? sanitize_text_field( (string) $input['stylesheet'] )
796 : get_stylesheet();
797
798 if ( ! wp_get_theme( $stylesheet )->exists() ) {
799 return [ 'success' => false, 'message' => sprintf( 'Theme "%s" is not installed.', $stylesheet ) ];
800 }
801
802 $mode = isset( $input['mode'] ) ? sanitize_key( (string) $input['mode'] ) : 'replace';
803 if ( ! in_array( $mode, [ 'replace', 'append' ], true ) ) {
804 $mode = 'replace';
805 }
806
807 $css = $this->avcf_normalize_css( (string) $input['css'] );
808
809 if ( 'append' === $mode ) {
810 $existing = (string) wp_get_custom_css( $stylesheet );
811 $css = ( '' !== trim( $existing ) ) ? rtrim( $existing ) . "\n\n" . $css : $css;
812 }
813
814 $result = wp_update_custom_css_post( $css, [ 'stylesheet' => $stylesheet ] );
815
816 if ( is_wp_error( $result ) ) {
817 return [ 'success' => false, 'message' => 'Update failed: ' . $result->get_error_message() ];
818 }
819
820 return [
821 'success' => true,
822 'stylesheet' => $stylesheet,
823 'css' => $css,
824 'message' => sprintf( 'Additional CSS %s for theme "%s".', ( 'append' === $mode ? 'appended' : 'updated' ), $stylesheet ),
825 ];
826 },
827 'permission_callback' => function() {
828 return current_user_can( 'edit_theme_options' );
829 },
830 'meta' => [
831 'mcp' => [ 'public' => true, 'type' => 'tool' ],
832 'annotations' => [
833 'readonly' => false,
834 'destructive' => false,
835 'idempotent' => true,
836 ],
837 ],
838 ] );
839 }
840
841 /**
842 * Defensively normalise a CSS string that may have been copied out of the
843 * block editor. Strips Gutenberg block comments and HTML tags, converts
844 * <br> and closing block tags to newlines, decodes HTML entities (so e.g.
845 * a child combinator encoded as &gt; is restored), and converts smart
846 * quotes to straight quotes so content declarations stay valid. Genuine
847 * CSS selectors and comment blocks are preserved.
848 *
849 * @param string $css
850 * @return string
851 */
852 private function avcf_normalize_css( $css ) {
853 if ( ! is_string( $css ) || '' === $css ) {
854 return '';
855 }
856
857 // Normalise line endings.
858 $css = str_replace( [ "\r\n", "\r" ], "\n", $css );
859
860 // Remove HTML comments, including Gutenberg block delimiters (<!-- wp:... -->).
861 $css = preg_replace( '/<!--.*?-->/s', '', $css );
862
863 // Convert <br> and closing block tags to newlines before stripping tags.
864 $css = preg_replace( '/<br\s*\/?>/i', "\n", $css );
865 $css = preg_replace( '#</(p|div|pre|code)>#i', "\n", $css );
866
867 // Strip any remaining HTML tags. CSS uses no "< ... >" constructs, so
868 // combinators (>, +, ~) and attribute selectors are left intact.
869 $css = preg_replace( '/<[^>]+>/', '', $css );
870
871 // Decode entities the editor may have introduced (&gt; &amp; &nbsp; ...).
872 $css = html_entity_decode( $css, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
873
874 // Smart quotes -> straight quotes.
875 $css = preg_replace( '/[\x{2018}\x{2019}\x{201A}\x{201B}\x{2032}]/u', "'", $css );
876 $css = preg_replace( '/[\x{201C}\x{201D}\x{201E}\x{201F}\x{2033}]/u', '"', $css );
877
878 // Non-breaking spaces -> normal spaces.
879 $css = str_replace( "\xC2\xA0", ' ', $css );
880
881 // Collapse 3+ newlines to a single blank line, then trim.
882 $css = preg_replace( "/\n{3,}/", "\n\n", $css );
883
884 return trim( $css );
885 }
886 }
887