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 > 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 (> & ...). |
| 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 |