false` (the locked * graceful-degradation fork). The plugin never fatals on an older core. */ class Abilities { /** * WPPilot's built-in abilities, describing the content actions the WPPilot * service offers. They are descriptive only: executing one through the * Abilities API returns an error that points to the service, which makes * every change with a preview, the owner's approval and an undo. * * @return array> */ private function definitions(): array { return array( array( 'name' => 'wppilot/rewrite-meta-description', 'label' => __( 'Rewrite meta description', 'wingpilot' ), 'description' => __( "Rewrite a post or page's SEO meta description.", 'wingpilot' ), ), array( 'name' => 'wppilot/rewrite-post-title', 'label' => __( 'Rewrite post title', 'wingpilot' ), 'description' => __( "Rewrite a post or page's title.", 'wingpilot' ), ), array( 'name' => 'wppilot/rewrite-excerpt', 'label' => __( 'Rewrite excerpt', 'wingpilot' ), 'description' => __( "Rewrite a post or page's excerpt.", 'wingpilot' ), ), array( 'name' => 'wppilot/improve-image-alt-text', 'label' => __( 'Improve image alt text', 'wingpilot' ), 'description' => __( 'Write accessible, SEO-friendly alt text for an image attachment.', 'wingpilot' ), ), array( 'name' => 'wppilot/set-post-status', 'label' => __( 'Set post status', 'wingpilot' ), 'description' => __( 'Change a post or page status (draft, publish, private, trash).', 'wingpilot' ), ), array( 'name' => 'wppilot/edit-post-content', 'label' => __( 'Edit post content', 'wingpilot' ), 'description' => __( 'Make a surgical, reversible edit to a post or page body.', 'wingpilot' ), ), ); } /** * The category every WPPilot ability belongs to. * * From WordPress 6.9 an ability without a registered category is refused by * WP_Ability::prepare_properties() with a _doing_it_wrong() notice per * ability, and the ability is dropped. The category must exist before any * ability names it, so it is registered on its own, earlier hook. */ public const CATEGORY = 'wppilot'; /** * Hook registration onto the Abilities API's own init actions: the * category on `wp_abilities_api_categories_init`, the abilities on * `wp_abilities_api_init`. A no-op on cores without the API. */ public function hook(): void { if ( ! $this->is_available() ) { return; } add_action( 'wp_abilities_api_categories_init', array( $this, 'register_category' ) ); add_action( 'wp_abilities_api_init', array( $this, 'register' ) ); } /** * Register the `wppilot` ability category. A no-op on cores without the * category API, and when the category is already registered. */ public function register_category(): void { if ( ! function_exists( 'wp_register_ability_category' ) ) { return; } // Indirect calls, for the reason given in register(). $has = 'wp_has_ability_category'; if ( function_exists( $has ) && $has( self::CATEGORY ) ) { return; } $register = 'wp_register_ability_category'; $register( self::CATEGORY, array( 'label' => __( 'WPPilot', 'wingpilot' ), 'description' => __( 'Reversible site changes that WPPilot prepares and the site owner approves.', 'wingpilot' ), ) ); } /** * Whether the running WordPress exposes the Abilities API. WordPress 6.9+ * provides wp_register_ability()/wp_get_abilities(). */ public function is_available(): bool { return function_exists( 'wp_register_ability' ) && function_exists( 'wp_get_abilities' ); } /** * Register WPPilot's abilities. No-op (logged by the caller) when the API is * unavailable. Hooked on `wp_abilities_api_init` by hook(), after the * category it names is registered. */ public function register(): void { if ( ! function_exists( 'wp_register_ability' ) ) { return; } // Called through a variable holding the function's fixed name: the // plugin supports WordPress 6.0 and this API exists only from 6.9, so a // direct call reads to static analysis as requiring 6.9. The // function_exists() guard above is what keeps older WordPress working. // The name is a literal, never taken from input. $register = 'wp_register_ability'; // A core that has the category API requires a category on every // ability; one without it (pre-release 6.9) does not know the argument. $category = function_exists( 'wp_register_ability_category' ) ? self::CATEGORY : ''; foreach ( $this->definitions() as $ability ) { $args = array( 'label' => $ability['label'], 'description' => $ability['description'], 'input_schema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array( 'type' => 'integer' ), 'instruction' => array( 'type' => 'string' ), ), 'required' => array( 'post_id' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'success' => array( 'type' => 'boolean' ), ), ), 'execute_callback' => static function () { // V1: execution flows through the WPPilot backend action // lifecycle (preview/execute/rollback with snapshots), not // directly through the ability. This keeps the snapshot // safety contract intact. return new \WP_Error( 'wppilot_ability_via_backend', __( 'This ability is executed through the WPPilot backend action lifecycle.', 'wingpilot' ) ); }, 'permission_callback' => static function ( $input = null ): bool { // The Abilities API passes the ability's input, not a // REST request; a REST request is still understood. $post_id = 0; if ( is_array( $input ) && isset( $input['post_id'] ) ) { $post_id = (int) $input['post_id']; } elseif ( $input instanceof \WP_REST_Request ) { $post_id = (int) $input->get_param( 'post_id' ); } if ( $post_id > 0 ) { return current_user_can( 'edit_post', $post_id ); } return current_user_can( 'edit_posts' ); }, ); if ( '' !== $category ) { $args['category'] = $category; } $register( $ability['name'], $args ); } } /** * The full abilities inventory for the backend (heartbeat + site model). * * Shape per item: name, label, description, namespace, is_sitepilot. The * `available` flag reflects whether the core exposes the API at all * (graceful-degradation fork); when false, `abilities` is an empty array and * the backend tells the AI only built-in WPPilot actions exist. * * `is_sitepilot` keeps its name deliberately: it is a WIRE field the backend * already parses (`site-data-client.ts`), and renaming one side of a field * that two independently-deployed components agree on breaks whichever ships * second. The NAMES it is computed from are the part owners and other AI * tools ever see, and those are `wppilot/*`. * * @return array{available: bool, abilities: array>} */ public function inventory(): array { if ( ! $this->is_available() ) { return array( 'available' => false, 'abilities' => array(), ); } $abilities = array(); // Indirect call with a fixed name, for the reason given in register(); // is_available() guards it. $get_abilities = 'wp_get_abilities'; $registered = $get_abilities(); if ( is_array( $registered ) ) { foreach ( $registered as $ability ) { $name = $this->ability_name( $ability ); if ( '' === $name ) { continue; } $namespace = strstr( $name, '/', true ); $abilities[] = array( 'name' => $name, 'label' => $this->ability_label( $ability, $name ), 'description' => $this->ability_description( $ability ), 'namespace' => false === $namespace ? '' : $namespace, 'is_sitepilot' => str_starts_with( $name, 'wppilot/' ), ); } } return array( 'available' => true, 'abilities' => $abilities, ); } /** * Read an ability's name across the possible return shapes (object with * get_name(), object with a name property, or an array). * * @param mixed $ability A single entry from wp_get_abilities(). */ private function ability_name( $ability ): string { if ( is_object( $ability ) && method_exists( $ability, 'get_name' ) ) { return (string) $ability->get_name(); } if ( is_object( $ability ) && isset( $ability->name ) ) { return (string) $ability->name; } if ( is_array( $ability ) && isset( $ability['name'] ) ) { return (string) $ability['name']; } if ( is_string( $ability ) ) { return $ability; } return ''; } /** * @param mixed $ability A single entry from wp_get_abilities(). * @param string $fallback Used when no label is exposed. */ private function ability_label( $ability, string $fallback ): string { if ( is_object( $ability ) && method_exists( $ability, 'get_label' ) ) { $label = (string) $ability->get_label(); if ( '' !== $label ) { return $label; } } if ( is_array( $ability ) && ! empty( $ability['label'] ) ) { return (string) $ability['label']; } return $fallback; } /** * @param mixed $ability A single entry from wp_get_abilities(). */ private function ability_description( $ability ): string { if ( is_object( $ability ) && method_exists( $ability, 'get_description' ) ) { return (string) $ability->get_description(); } if ( is_array( $ability ) && isset( $ability['description'] ) ) { return (string) $ability['description']; } return ''; } }