PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / 5.1.1
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback v5.1.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-execute-php.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-readonly.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
945 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 'sha1' => [ 'type' => 'string' ],
718 'bytes' => [ 'type' => 'integer' ],
719 'message' => [ 'type' => 'string' ],
720 ],
721 'required' => [ 'success', 'message' ],
722 ],
723 'execute_callback' => function( $input = [] ) {
724 $stylesheet = isset( $input['stylesheet'] ) && $input['stylesheet'] !== ''
725 ? sanitize_text_field( (string) $input['stylesheet'] )
726 : get_stylesheet();
727
728 if ( ! wp_get_theme( $stylesheet )->exists() ) {
729 return [ 'success' => false, 'message' => sprintf( 'Theme "%s" is not installed.', $stylesheet ) ];
730 }
731
732 $css = (string) wp_get_custom_css( $stylesheet );
733
734 return [
735 'success' => true,
736 'stylesheet' => $stylesheet,
737 'css' => $css,
738 'sha1' => sha1( $css ),
739 'bytes' => strlen( $css ),
740 'message' => ( '' === $css ) ? 'No Additional CSS is set for this theme.' : sprintf( 'OK. Pass this sha1 as expected_sha1 to set-additional-css for a safe replace. (%d bytes)', strlen( $css ) ),
741 ];
742 },
743 'permission_callback' => function() {
744 return current_user_can( 'edit_theme_options' );
745 },
746 'meta' => [
747 'mcp' => [ 'public' => true, 'type' => 'tool' ],
748 'annotations' => [
749 'readonly' => true,
750 'destructive' => false,
751 'idempotent' => true,
752 ],
753 ],
754 ] );
755
756 // ---- set-additional-css ----
757 wp_register_ability( 'atarim/set-additional-css', [
758 'label' => 'Set Additional CSS',
759 '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.',
760 'category' => 'atarim',
761 'input_schema' => [
762 'type' => 'object',
763 'properties' => [
764 'css' => [
765 'type' => 'string',
766 'description' => 'Raw CSS to store. No HTML, no block markup, no block comments.',
767 ],
768 'stylesheet' => [
769 'type' => 'string',
770 'description' => 'Theme stylesheet slug (folder name) to target. Omit for the active theme.',
771 'minLength' => 1,
772 ],
773 'mode' => [
774 'type' => 'string',
775 'description' => '"replace" overwrites all Additional CSS (default). "append" adds the given CSS after the existing CSS.',
776 'enum' => [ 'replace', 'append' ],
777 'default' => 'replace',
778 ],
779 'expected_sha1' => [
780 'type' => 'string',
781 'description' => 'Optional optimistic-concurrency guard. If given, the write proceeds only when the CURRENT Additional CSS has this sha1 (get it from get-additional-css). If it does not match, the write is refused with the current sha1 so you can re-read and retry — preventing a blind replace from clobbering a change made since you read. Omit to force the write.',
782 ],
783 ],
784 'required' => [ 'css' ],
785 'additionalProperties' => false,
786 ],
787 'output_schema' => [
788 'type' => 'object',
789 'properties' => [
790 'success' => [ 'type' => 'boolean' ],
791 'conflict' => [ 'type' => 'boolean' ],
792 'stylesheet' => [ 'type' => 'string' ],
793 'mode' => [ 'type' => 'string' ],
794 'changed' => [ 'type' => 'boolean' ],
795 'before_sha1' => [ 'type' => 'string' ],
796 'stored_sha1' => [ 'type' => 'string' ],
797 'stored_bytes' => [ 'type' => 'integer' ],
798 'current_sha1' => [ 'type' => 'string' ],
799 'message' => [ 'type' => 'string' ],
800 ],
801 'required' => [ 'success', 'message' ],
802 ],
803 'execute_callback' => function( $input = [] ) {
804 if ( ! isset( $input['css'] ) || ! is_string( $input['css'] ) ) {
805 return [ 'success' => false, 'message' => 'css is required and must be a string.' ];
806 }
807
808 $stylesheet = isset( $input['stylesheet'] ) && $input['stylesheet'] !== ''
809 ? sanitize_text_field( (string) $input['stylesheet'] )
810 : get_stylesheet();
811
812 if ( ! wp_get_theme( $stylesheet )->exists() ) {
813 return [ 'success' => false, 'message' => sprintf( 'Theme "%s" is not installed.', $stylesheet ) ];
814 }
815
816 $mode = isset( $input['mode'] ) ? sanitize_key( (string) $input['mode'] ) : 'replace';
817 if ( ! in_array( $mode, [ 'replace', 'append' ], true ) ) {
818 $mode = 'replace';
819 }
820
821 // Current stored CSS (raw) for the concurrency guard and change reporting.
822 $existing_raw = (string) wp_get_custom_css( $stylesheet );
823 $before_sha1 = sha1( $existing_raw );
824
825 // Optimistic-concurrency guard: only proceed if the current CSS is
826 // what the caller expected. Stops a blind replace from silently
827 // clobbering a change made since the caller last read the value.
828 if ( isset( $input['expected_sha1'] ) && '' !== (string) $input['expected_sha1'] ) {
829 if ( $before_sha1 !== (string) $input['expected_sha1'] ) {
830 return [
831 'success' => false,
832 'conflict' => true,
833 'stylesheet' => $stylesheet,
834 'current_sha1' => $before_sha1,
835 'message' => 'Conflict: the current Additional CSS does not match expected_sha1 (it changed since you read it). Re-read it with get-additional-css and retry with the new sha1, or omit expected_sha1 to force the write.',
836 ];
837 }
838 }
839
840 $css = $this->avcf_normalize_css( (string) $input['css'] );
841
842 if ( 'append' === $mode ) {
843 $css = ( '' !== trim( $existing_raw ) ) ? rtrim( $existing_raw ) . "\n\n" . $css : $css;
844 }
845
846 $result = wp_update_custom_css_post( $css, [ 'stylesheet' => $stylesheet ] );
847
848 if ( is_wp_error( $result ) ) {
849 return [ 'success' => false, 'message' => 'Update failed: ' . $result->get_error_message() ];
850 }
851
852 // Receipt from the re-read stored value (no full-CSS echo).
853 $stored = (string) wp_get_custom_css( $stylesheet );
854 $stored_sha1 = sha1( $stored );
855
856 return [
857 'success' => true,
858 'stylesheet' => $stylesheet,
859 'mode' => $mode,
860 'changed' => ( $before_sha1 !== $stored_sha1 ),
861 'before_sha1' => $before_sha1,
862 'stored_sha1' => $stored_sha1,
863 'stored_bytes' => strlen( $stored ),
864 'message' => sprintf( 'Additional CSS %s for theme "%s" (%d bytes). Use stored_sha1 as expected_sha1 for your next safe replace.', ( 'append' === $mode ? 'appended' : 'updated' ), $stylesheet, strlen( $stored ) ),
865 ];
866 },
867 'permission_callback' => function() {
868 return current_user_can( 'edit_theme_options' );
869 },
870 'meta' => [
871 'mcp' => [ 'public' => true, 'type' => 'tool' ],
872 'annotations' => [
873 'readonly' => false,
874 'destructive' => false,
875 'idempotent' => true,
876 ],
877 ],
878 ] );
879 }
880
881 /**
882 * Defensively normalise a CSS string that may have been copied out of the
883 * block editor. Strips Gutenberg block comments and HTML tags, converts
884 * <br> and closing block tags to newlines, decodes HTML entities (so e.g.
885 * a child combinator encoded as &gt; is restored), and converts smart
886 * quotes to straight quotes so content declarations stay valid. Genuine
887 * CSS selectors and comment blocks are preserved.
888 *
889 * @param string $css
890 * @return string
891 */
892 private function avcf_normalize_css( $css ) {
893 if ( ! is_string( $css ) || '' === $css ) {
894 return '';
895 }
896
897 // Normalise line endings.
898 $css = str_replace( [ "\r\n", "\r" ], "\n", $css );
899
900 // Protect CSS comment blocks (/* ... */) before any HTML-tag munging:
901 // their contents may legitimately contain "<...>" (e.g. documentation or
902 // selector examples), which the tag-strip below would otherwise destroy.
903 $avcf_css_comments = [];
904 $css = preg_replace_callback( '#/\*.*?\*/#s', function( $m ) use ( &$avcf_css_comments ) {
905 $token = '%%AVCF_CSSCOMMENT_' . count( $avcf_css_comments ) . '%%';
906 $avcf_css_comments[] = $m[0];
907 return $token;
908 }, $css );
909
910 // Remove HTML comments, including Gutenberg block delimiters (<!-- wp:... -->).
911 $css = preg_replace( '/<!--.*?-->/s', '', $css );
912
913 // Convert <br> and closing block tags to newlines before stripping tags.
914 $css = preg_replace( '/<br\s*\/?>/i', "\n", $css );
915 $css = preg_replace( '#</(p|div|pre|code)>#i', "\n", $css );
916
917 // Strip any remaining HTML tags. CSS uses no "< ... >" constructs, so
918 // combinators (>, +, ~) and attribute selectors are left intact.
919 $css = preg_replace( '/<[^>]+>/', '', $css );
920
921 // Decode entities the editor may have introduced (&gt; &amp; &nbsp; ...).
922 $css = html_entity_decode( $css, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
923
924 // Smart quotes -> straight quotes.
925 $css = preg_replace( '/[\x{2018}\x{2019}\x{201A}\x{201B}\x{2032}]/u', "'", $css );
926 $css = preg_replace( '/[\x{201C}\x{201D}\x{201E}\x{201F}\x{2033}]/u', '"', $css );
927
928 // Non-breaking spaces -> normal spaces.
929 $css = str_replace( "\xC2\xA0", ' ', $css );
930
931 // Collapse 3+ newlines to a single blank line, then trim.
932 $css = preg_replace( "/\n{3,}/", "\n\n", $css );
933
934 // Restore protected CSS comment blocks verbatim.
935 if ( $avcf_css_comments ) {
936 $tokens = [];
937 foreach ( array_keys( $avcf_css_comments ) as $i ) {
938 $tokens[] = '%%AVCF_CSSCOMMENT_' . $i . '%%';
939 }
940 $css = str_replace( $tokens, $avcf_css_comments, $css );
941 }
942
943 return trim( $css );
944 }
945 }