From bd3902622f4fe07dfa4e0f10b052b23c9196ef98 Mon Sep 17 00:00:00 2001 From: Em Date: Mon, 11 Aug 2025 14:57:38 -0400 Subject: [PATCH 1/4] feat: Add Abilities API bridge for WP Feature API Implements a bridge between WordPress.org Abilities API and WP Feature API, allowing abilities to be automatically registered as features when enabled. --- .../class-wp-feature-abilities-bridge.php | 186 ++++++++++++++++++ includes/class-wp-feature-api-init.php | 23 ++- includes/load.php | 2 + wp-feature-api.php | 4 +- 4 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 includes/class-wp-feature-abilities-bridge.php diff --git a/includes/class-wp-feature-abilities-bridge.php b/includes/class-wp-feature-abilities-bridge.php new file mode 100644 index 0000000..6021801 --- /dev/null +++ b/includes/class-wp-feature-abilities-bridge.php @@ -0,0 +1,186 @@ +is_abilities_backend_enabled() ) { + return; + } + + if ( ! function_exists( 'wp_get_abilities' ) ) { + error_log( 'WP Feature API: Abilities backend enabled but Abilities API not found. Please install the Abilities API plugin.' ); + return; + } + + $this->register_abilities_as_features(); + } + + /** + * Checks if the abilities backend is enabled via configuration constant. + * + * @since 0.1.0 + * @return bool True if abilities backend is enabled, false otherwise. + */ + private function is_abilities_backend_enabled() { + return defined( 'WP_FEATURE_API_ABILITIES_BACKEND' ) && WP_FEATURE_API_ABILITIES_BACKEND; + } + + /** + * Registers all abilities as features. + * + * Retrieves all registered abilities using wp_get_abilities(), converts + * each to feature format using convert_ability_to_feature(), and registers + * the converted features using wp_register_feature(). + * + * @since 0.1.0 + * @return void + */ + private function register_abilities_as_features() { + $abilities = wp_get_abilities(); + + foreach ( $abilities as $ability ) { + try { + $feature_args = $this->convert_ability_to_feature( $ability ); + if ( null !== $feature_args ) { + wp_register_feature( $feature_args ); + } + } catch ( Exception $e ) { + error_log( 'WP Feature API: Failed to convert ability "' . $ability->get_name() . '" to feature: ' . $e->getMessage() ); + continue; + } + } + } + + /** + * Converts an ability to feature format. + * + * Maps ability properties to feature properties according to the conversion schema: + * - name (ability) → id (feature) with 'ability/' prefix + * - label (ability) → name (feature) + * - description, input_schema, output_schema → direct mapping + * - execute_callback → callback (wrapped to call ability's execute method) + * - permission_callback → permission_callback (wrapped) + * - meta.type → type (default: 'tool') + * - meta.location → location (default: 'server') + * - meta.category → categories (converted to array) + * - meta → meta (direct mapping) + * + * @since 0.1.0 + * @param WP_Ability $ability The ability to convert. + * @return array|null Feature arguments array, or null on conversion failure. + */ + private function convert_ability_to_feature( WP_Ability $ability ) { + try { + $meta = $ability->get_meta(); + + $callback = function( $args ) use ( $ability ) { + return $ability->execute( $args ); + }; + + $permission_callback = function( $args ) use ( $ability ) { + return $ability->has_permission( $args ); + }; + + // Extract categories from meta - handle both string and array + $categories = array(); + if ( isset( $meta['category'] ) ) { + $categories = is_array( $meta['category'] ) ? $meta['category'] : array( $meta['category'] ); + } elseif ( isset( $meta['categories'] ) ) { + $categories = is_array( $meta['categories'] ) ? $meta['categories'] : array( $meta['categories'] ); + } + + return array( + 'id' => 'ability/' . $ability->get_name(), + 'name' => $ability->get_label(), + 'description' => $ability->get_description(), + 'type' => isset( $meta['type'] ) ? $meta['type'] : 'tool', + 'location' => isset( $meta['location'] ) ? $meta['location'] : 'server', + 'categories' => $categories, + 'callback' => $callback, + 'permission_callback' => $permission_callback, + 'input_schema' => $ability->get_input_schema(), + 'output_schema' => $ability->get_output_schema(), + 'meta' => $meta, + ); + } catch ( Exception $e ) { + error_log( 'WP Feature API: Failed to convert ability "' . $ability->get_name() . '" to feature: ' . $e->getMessage() ); + return null; + } + } + + /** + * Prevents cloning of the singleton instance. + * + * @since 0.1.0 + */ + private function __clone() { + // Prevent cloning. + } + + /** + * Prevents unserialization of the singleton instance. + * + * @since 0.1.0 + */ + public function __wakeup() { + throw new Exception( 'Cannot unserialize singleton' ); + } +} diff --git a/includes/class-wp-feature-api-init.php b/includes/class-wp-feature-api-init.php index e1f13e2..95ed6b4 100644 --- a/includes/class-wp-feature-api-init.php +++ b/includes/class-wp-feature-api-init.php @@ -31,6 +31,9 @@ public static function initialize() { // enqueue admin scripts. add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_scripts' ) ); + // Initialize abilities bridge if enabled. + add_action( 'init', array( __CLASS__, 'initialize_abilities_bridge' ), 20 ); + // Load demo plugin if enabled. if ( defined( 'WP_FEATURE_API_LOAD_DEMO' ) && WP_FEATURE_API_LOAD_DEMO ) { self::load_agent_demo(); @@ -56,7 +59,6 @@ public static function enqueue_admin_scripts() { } $assets = require WP_FEATURE_API_PLUGIN_DIR . 'build/index.asset.php'; wp_enqueue_script( 'wp-features', WP_FEATURE_API_PLUGIN_URL . 'build/index.js', $assets['dependencies'], $assets['version'], true ); - } /** @@ -85,6 +87,25 @@ public static function register_rest_routes() { $controller->register_routes(); } + /** + * Initializes the abilities bridge if backend is enabled. + * + * @since 0.1.0 + * @return void + */ + public static function initialize_abilities_bridge() { + if ( ! defined( 'WP_FEATURE_API_ABILITIES_BACKEND' ) || ! WP_FEATURE_API_ABILITIES_BACKEND ) { + return; + } + + try { + $bridge = WP_Feature_Abilities_Bridge::get_instance(); + $bridge->init(); + } catch ( Exception $e ) { + error_log( 'WP Feature API: Bridge initialization failed: ' . $e->getMessage() ); + } + } + /** * Loads the WP Feature API Demo plugin. * diff --git a/includes/load.php b/includes/load.php index 9300eca..a254442 100644 --- a/includes/load.php +++ b/includes/load.php @@ -17,5 +17,7 @@ require_once WP_FEATURE_API_PLUGIN_DIR . 'includes/rest-api/class-wp-rest-feature-controller.php'; // Include core features. require_once WP_FEATURE_API_PLUGIN_DIR . 'includes/default-wp-features.php'; +// Include the WP_Feature_Abilities_Bridge class. +require_once WP_FEATURE_API_PLUGIN_DIR . 'includes/class-wp-feature-abilities-bridge.php'; // Include initialization class. require_once WP_FEATURE_API_PLUGIN_DIR . 'includes/class-wp-feature-api-init.php'; diff --git a/wp-feature-api.php b/wp-feature-api.php index 2d5c94a..913b890 100644 --- a/wp-feature-api.php +++ b/wp-feature-api.php @@ -53,8 +53,8 @@ function wp_feature_api_register_version( $version, $file ) { // Generate a unique key using version + path hash to prevent overwriting // installations of the same version in different directories. This is so we can // provide a preference for the plugin/developer version over a vendor version. - $unique_key = $version . '|' . md5($file); - $wp_feature_api_versions[$unique_key] = array( + $unique_key = $version . '|' . md5( $file ); + $wp_feature_api_versions[ $unique_key ] = array( 'version' => $version, 'file' => $file, ); From abc1d06d0e17c89aeffbcff49754c1184f97d551 Mon Sep 17 00:00:00 2001 From: Em Date: Tue, 12 Aug 2025 19:26:46 -0400 Subject: [PATCH 2/4] feat: Update abilities backend to use client-side switching --- .../class-wp-feature-abilities-bridge.php | 186 ------------------ includes/class-wp-feature-api-init.php | 28 +-- includes/load.php | 2 - package-lock.json | 6 +- package.json | 2 +- packages/client/src/api.ts | 8 +- packages/client/src/config.ts | 128 ++++++++++++ packages/client/src/index.ts | 3 + packages/client/src/store/resolvers.ts | 25 ++- src/index.js | 6 + wp-feature-api.php | 4 +- 11 files changed, 177 insertions(+), 221 deletions(-) delete mode 100644 includes/class-wp-feature-abilities-bridge.php create mode 100644 packages/client/src/config.ts diff --git a/includes/class-wp-feature-abilities-bridge.php b/includes/class-wp-feature-abilities-bridge.php deleted file mode 100644 index 6021801..0000000 --- a/includes/class-wp-feature-abilities-bridge.php +++ /dev/null @@ -1,186 +0,0 @@ -is_abilities_backend_enabled() ) { - return; - } - - if ( ! function_exists( 'wp_get_abilities' ) ) { - error_log( 'WP Feature API: Abilities backend enabled but Abilities API not found. Please install the Abilities API plugin.' ); - return; - } - - $this->register_abilities_as_features(); - } - - /** - * Checks if the abilities backend is enabled via configuration constant. - * - * @since 0.1.0 - * @return bool True if abilities backend is enabled, false otherwise. - */ - private function is_abilities_backend_enabled() { - return defined( 'WP_FEATURE_API_ABILITIES_BACKEND' ) && WP_FEATURE_API_ABILITIES_BACKEND; - } - - /** - * Registers all abilities as features. - * - * Retrieves all registered abilities using wp_get_abilities(), converts - * each to feature format using convert_ability_to_feature(), and registers - * the converted features using wp_register_feature(). - * - * @since 0.1.0 - * @return void - */ - private function register_abilities_as_features() { - $abilities = wp_get_abilities(); - - foreach ( $abilities as $ability ) { - try { - $feature_args = $this->convert_ability_to_feature( $ability ); - if ( null !== $feature_args ) { - wp_register_feature( $feature_args ); - } - } catch ( Exception $e ) { - error_log( 'WP Feature API: Failed to convert ability "' . $ability->get_name() . '" to feature: ' . $e->getMessage() ); - continue; - } - } - } - - /** - * Converts an ability to feature format. - * - * Maps ability properties to feature properties according to the conversion schema: - * - name (ability) → id (feature) with 'ability/' prefix - * - label (ability) → name (feature) - * - description, input_schema, output_schema → direct mapping - * - execute_callback → callback (wrapped to call ability's execute method) - * - permission_callback → permission_callback (wrapped) - * - meta.type → type (default: 'tool') - * - meta.location → location (default: 'server') - * - meta.category → categories (converted to array) - * - meta → meta (direct mapping) - * - * @since 0.1.0 - * @param WP_Ability $ability The ability to convert. - * @return array|null Feature arguments array, or null on conversion failure. - */ - private function convert_ability_to_feature( WP_Ability $ability ) { - try { - $meta = $ability->get_meta(); - - $callback = function( $args ) use ( $ability ) { - return $ability->execute( $args ); - }; - - $permission_callback = function( $args ) use ( $ability ) { - return $ability->has_permission( $args ); - }; - - // Extract categories from meta - handle both string and array - $categories = array(); - if ( isset( $meta['category'] ) ) { - $categories = is_array( $meta['category'] ) ? $meta['category'] : array( $meta['category'] ); - } elseif ( isset( $meta['categories'] ) ) { - $categories = is_array( $meta['categories'] ) ? $meta['categories'] : array( $meta['categories'] ); - } - - return array( - 'id' => 'ability/' . $ability->get_name(), - 'name' => $ability->get_label(), - 'description' => $ability->get_description(), - 'type' => isset( $meta['type'] ) ? $meta['type'] : 'tool', - 'location' => isset( $meta['location'] ) ? $meta['location'] : 'server', - 'categories' => $categories, - 'callback' => $callback, - 'permission_callback' => $permission_callback, - 'input_schema' => $ability->get_input_schema(), - 'output_schema' => $ability->get_output_schema(), - 'meta' => $meta, - ); - } catch ( Exception $e ) { - error_log( 'WP Feature API: Failed to convert ability "' . $ability->get_name() . '" to feature: ' . $e->getMessage() ); - return null; - } - } - - /** - * Prevents cloning of the singleton instance. - * - * @since 0.1.0 - */ - private function __clone() { - // Prevent cloning. - } - - /** - * Prevents unserialization of the singleton instance. - * - * @since 0.1.0 - */ - public function __wakeup() { - throw new Exception( 'Cannot unserialize singleton' ); - } -} diff --git a/includes/class-wp-feature-api-init.php b/includes/class-wp-feature-api-init.php index 95ed6b4..d6edd91 100644 --- a/includes/class-wp-feature-api-init.php +++ b/includes/class-wp-feature-api-init.php @@ -31,9 +31,6 @@ public static function initialize() { // enqueue admin scripts. add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_scripts' ) ); - // Initialize abilities bridge if enabled. - add_action( 'init', array( __CLASS__, 'initialize_abilities_bridge' ), 20 ); - // Load demo plugin if enabled. if ( defined( 'WP_FEATURE_API_LOAD_DEMO' ) && WP_FEATURE_API_LOAD_DEMO ) { self::load_agent_demo(); @@ -59,6 +56,13 @@ public static function enqueue_admin_scripts() { } $assets = require WP_FEATURE_API_PLUGIN_DIR . 'build/index.asset.php'; wp_enqueue_script( 'wp-features', WP_FEATURE_API_PLUGIN_URL . 'build/index.js', $assets['dependencies'], $assets['version'], true ); + + // Pass configuration to JavaScript + $config = array(); + if ( defined( 'WP_FEATURE_API_ABILITIES_BACKEND' ) && WP_FEATURE_API_ABILITIES_BACKEND ) { + $config['useAbilitiesBackend'] = true; + } + wp_localize_script( 'wp-features', 'wpFeatureAPIConfig', $config ); } /** @@ -87,24 +91,6 @@ public static function register_rest_routes() { $controller->register_routes(); } - /** - * Initializes the abilities bridge if backend is enabled. - * - * @since 0.1.0 - * @return void - */ - public static function initialize_abilities_bridge() { - if ( ! defined( 'WP_FEATURE_API_ABILITIES_BACKEND' ) || ! WP_FEATURE_API_ABILITIES_BACKEND ) { - return; - } - - try { - $bridge = WP_Feature_Abilities_Bridge::get_instance(); - $bridge->init(); - } catch ( Exception $e ) { - error_log( 'WP Feature API: Bridge initialization failed: ' . $e->getMessage() ); - } - } /** * Loads the WP Feature API Demo plugin. diff --git a/includes/load.php b/includes/load.php index a254442..9300eca 100644 --- a/includes/load.php +++ b/includes/load.php @@ -17,7 +17,5 @@ require_once WP_FEATURE_API_PLUGIN_DIR . 'includes/rest-api/class-wp-rest-feature-controller.php'; // Include core features. require_once WP_FEATURE_API_PLUGIN_DIR . 'includes/default-wp-features.php'; -// Include the WP_Feature_Abilities_Bridge class. -require_once WP_FEATURE_API_PLUGIN_DIR . 'includes/class-wp-feature-abilities-bridge.php'; // Include initialization class. require_once WP_FEATURE_API_PLUGIN_DIR . 'includes/class-wp-feature-api-init.php'; diff --git a/package-lock.json b/package-lock.json index 85894f4..40fe904 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wp-feature-api", - "version": "0.1.2", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wp-feature-api", - "version": "0.1.2", + "version": "0.1.9", "license": "GPL-2.0-or-later", "workspaces": [ "packages/*", @@ -32745,7 +32745,7 @@ }, "packages/client": { "name": "@automattic/wp-feature-api", - "version": "0.1.2", + "version": "0.1.8", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/api-fetch": "^6.55.0", diff --git a/package.json b/package.json index e8d42a9..52f4b1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wp-feature-api", - "version": "0.1.8", + "version": "0.1.9", "description": "A system for exposing WordPress functionality in a standardized, discoverable way for both server and client-side use", "main": "src/index.js", "private": true, diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index e347271..284a362 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -10,6 +10,7 @@ import apiFetch from '@wordpress/api-fetch'; import { store } from './store'; import type { Feature } from './types'; import { removeNullValues } from './utils'; +import { getRunEndpoint, shouldUseAbilitiesBackend } from './config'; /** * Registers a feature with the feature registry. @@ -81,9 +82,12 @@ export async function executeFeature( return await callback( args ); } - // Server-side features const method = feature.type === 'tool' ? 'POST' : 'GET'; - let requestPath = `/wp/v2/features/${ featureId }/run`; + const useAbilities = shouldUseAbilitiesBackend( feature ); + let requestPath = useAbilities + ? getRunEndpoint( featureId ) + : `/wp/v2/features/${ featureId }/run`; + const options: { method: string; data?: any } = { method }; // The LLM may pass in a bunch of new values for things, that can cause validation errors for certain diff --git a/packages/client/src/config.ts b/packages/client/src/config.ts new file mode 100644 index 0000000..7acce3c --- /dev/null +++ b/packages/client/src/config.ts @@ -0,0 +1,128 @@ +/** + * Configuration for Feature API client + * + * @since 0.1.0 + */ + +/** + * Internal dependencies + */ +import { ENTITY_NAME } from './store/constants'; + +export interface FeatureAPIConfig { + /** + * Whether to use the Abilities API backend for server features + */ + useAbilitiesBackend?: boolean; + + /** + * Entity name for fetching features/abilities + */ + entityName?: string; +} + +// Default configuration +const defaultConfig: FeatureAPIConfig = { + useAbilitiesBackend: false, + entityName: ENTITY_NAME, +}; + +// Current configuration +let currentConfig: FeatureAPIConfig = { ...defaultConfig }; + +/** + * Configure the Feature API client + * + * @param config Configuration options + */ +export function configure( config: Partial< FeatureAPIConfig > ): void { + currentConfig = { + ...defaultConfig, + ...config, + }; + + // If using abilities backend, update entity name and paths + if ( config.useAbilitiesBackend ) { + currentConfig.entityName = 'abilities'; + + // Register the abilities entity with core-data if not already registered + const { dispatch, select } = ( window as any ).wp.data; + const coreStore = ( window as any ).wp.coreData?.store; + + if ( coreStore && dispatch && select ) { + // Check if abilities entity is already registered + const entities = + select( coreStore ).getEntitiesConfig( 'root' ) || []; + const abilitiesEntityExists = entities.some( + ( entity: any ) => entity.name === 'abilities' + ); + + if ( ! abilitiesEntityExists ) { + dispatch( coreStore ).addEntities( [ + { + name: 'abilities', + kind: 'root', + baseURL: '/wp/v2/abilities', + baseURLParams: { context: 'edit' }, + plural: 'abilities', + label: 'Abilities', + transientEdits: { + callback: true, + }, + }, + ] ); + } + } + } +} + +/** + * Get current configuration + * + * @return Current configuration + */ +export function getConfig(): FeatureAPIConfig { + return { ...currentConfig }; +} + +/** + * Get the entity name for API calls + * + * @return Entity name (features or abilities) + */ +export function getEntityName(): string { + return currentConfig.entityName || ENTITY_NAME; +} + +/** + * Get the run endpoint path for a feature/ability + * + * @param id Feature or ability ID + * @return REST endpoint path + */ +export function getRunEndpoint( id: string ): string { + const entityName = getEntityName(); + const basePath = '/wp/v2'; + + // For abilities, strip the 'ability/' prefix if present + const cleanId = + currentConfig.useAbilitiesBackend && id.startsWith( 'ability/' ) + ? id.replace( 'ability/', '' ) + : id; + + return `${ basePath }/${ entityName }/${ cleanId }/run`; +} + +/** + * Check if a feature should use the abilities backend + * + * @param feature Feature object + * @return Whether to use abilities backend + */ +export function shouldUseAbilitiesBackend( feature: any ): boolean { + // If abilities backend is enabled and the feature is server-side + return ( + currentConfig.useAbilitiesBackend === true && + feature?.location === 'server' + ); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d745760..dfbd286 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -9,6 +9,7 @@ import { getRegisteredFeature, getRegisteredFeatures, } from './api'; +import { configure } from './config'; const publicApi = { store, @@ -17,6 +18,7 @@ const publicApi = { executeFeature, getRegisteredFeature, getRegisteredFeatures, + configure, }; export { store }; @@ -27,6 +29,7 @@ export { executeFeature, getRegisteredFeature, getRegisteredFeatures, + configure, }; export * from './command-integration'; export { publicApi as wpFeatures }; diff --git a/packages/client/src/store/resolvers.ts b/packages/client/src/store/resolvers.ts index 643e62e..af6f7e8 100644 --- a/packages/client/src/store/resolvers.ts +++ b/packages/client/src/store/resolvers.ts @@ -9,27 +9,44 @@ import { store as coreStore } from '@wordpress/core-data'; import { ENTITY_KIND, ENTITY_NAME } from './constants'; import { receiveFeatures, receiveFeature } from './actions'; import { store } from './index'; +import { getConfig, getEntityName } from '../config'; export function getRegisteredFeatures() { return async ( { dispatch, registry } ) => { + const config = getConfig(); + const entityName = getEntityName(); + // Start with page 1 let page = 1; let allFeatures = []; let hasMore = true; - // Keep fetching pages until we have all features + // Keep fetching pages until we have all features/abilities while ( hasMore ) { const features = await registry .resolveSelect( coreStore ) - .getEntityRecords( ENTITY_KIND, ENTITY_NAME, { + .getEntityRecords( ENTITY_KIND, entityName, { page, per_page: 100, } ); - + if ( ! features || features.length === 0 ) { hasMore = false; } else { - allFeatures = [ ...allFeatures, ...features ]; + // If fetching from abilities, convert them to feature format + const processedFeatures = config.useAbilitiesBackend + ? features.map( ( ability ) => ( { + ...ability, + // Ensure abilities have the 'ability/' prefix in their ID + id: ability.id.startsWith( 'ability/' ) + ? ability.id + : `ability/${ ability.id }`, + name: ability.label || ability.name, + location: ability.meta?.location || 'server', + } ) ) + : features; + + allFeatures = [ ...allFeatures, ...processedFeatures ]; page++; } } diff --git a/src/index.js b/src/index.js index 08261a4..dd315fc 100644 --- a/src/index.js +++ b/src/index.js @@ -3,5 +3,11 @@ */ import { coreFeatures } from '../packages/client-features/src'; import { registerFeature } from '../packages/client/src/api'; +import { configure } from '../packages/client/src'; + +// Configure to use Abilities API backend if enabled +if ( window.wpFeatureAPIConfig?.useAbilitiesBackend ) { + configure( { useAbilitiesBackend: true } ); +} coreFeatures.filter( ( feature ) => !! feature ).forEach( registerFeature ); diff --git a/wp-feature-api.php b/wp-feature-api.php index 913b890..b008e4a 100644 --- a/wp-feature-api.php +++ b/wp-feature-api.php @@ -3,7 +3,7 @@ * Plugin Name: WordPress Feature API * Plugin URI: https://github.com/Automattic/wp-feature-api * Description: A system for exposing server and client-side functionality in WordPress for use in LLMs and agentic systems. - * Version: 0.1.8 + * Version: 0.1.9 * Author: Automattic AI * Author URI: https://automattic.ai/ * Text Domain: wp-feature-api @@ -18,7 +18,7 @@ exit; } -$wp_feature_api_version = '0.1.8'; +$wp_feature_api_version = '0.1.9'; $wp_feature_api_plugin_dir = plugin_dir_path( __FILE__ ); $wp_feature_api_plugin_url = plugin_dir_url( __FILE__ ); From 6de6362d5b87cdc790112a7e36bfc6aadfb2b843 Mon Sep 17 00:00:00 2001 From: Em Date: Tue, 12 Aug 2025 23:21:26 -0400 Subject: [PATCH 3/4] refactor: Simplify abilities backend configuration - Change config from useAbilitiesBackend boolean to backend string ('features' | 'abilities') - Remove unnecessary wp.data availability checks (it's a dependency) - Fix demo ability registration with proper JSON schema for OpenAI compatibility - Remove configure() function - config is read once at module load - Simplify string comparison for wp_localize_script values --- .../includes/class-wp-feature-register.php | 52 +++++++-- .../wp-feature-api-agent.php | 3 + includes/class-wp-feature-api-init.php | 5 +- packages/client/src/config.ts | 104 +++++++++--------- packages/client/src/index.ts | 3 - packages/client/src/store/resolvers.ts | 23 ++-- src/index.js | 6 - 7 files changed, 108 insertions(+), 88 deletions(-) diff --git a/demo/wp-feature-api-agent/includes/class-wp-feature-register.php b/demo/wp-feature-api-agent/includes/class-wp-feature-register.php index 2780e60..22e3220 100644 --- a/demo/wp-feature-api-agent/includes/class-wp-feature-register.php +++ b/demo/wp-feature-api-agent/includes/class-wp-feature-register.php @@ -21,17 +21,47 @@ class WP_Feature_Register { * @return void */ public function register_features() { - /** Global Features */ - wp_register_feature( - array( - 'id' => 'demo/site-info', - 'name' => __( 'Site Information', 'wp-feature-api-agent' ), - 'description' => __( 'Get basic information about the WordPress site. This includes the name, description, URL, version, language, timezone, date format, time format, active plugins, and active theme.', 'wp-feature-api-agent' ), - 'type' => WP_Feature::TYPE_RESOURCE, - 'categories' => array( 'demo', 'site', 'information' ), - 'callback' => array( $this, 'site_info_callback' ), - ) - ); + // Check if we should use abilities API and if it's available + $use_abilities = defined( 'WP_FEATURE_API_ABILITIES_BACKEND' ) && + WP_FEATURE_API_ABILITIES_BACKEND && + function_exists( 'wp_register_ability' ); + + if ( $use_abilities ) { + wp_register_ability( + 'demo/site-info', + array( + 'label' => __( 'Site Information', 'wp-feature-api-agent' ), + 'description' => __( 'Get basic information about the WordPress site. This includes the name, description, URL, version, language, timezone, date format, time format, active plugins, and active theme.', 'wp-feature-api-agent' ), + 'execute_callback' => array( $this, 'site_info_callback' ), + 'permission_callback' => function() { + return current_user_can( 'read' ); + }, + 'input_schema' => array( + 'type' => 'object', + 'properties' => (object) array(), + ), + 'output_schema' => array( + 'type' => 'object', + ), + 'meta' => array( + 'type' => 'resource', + 'categories' => array( 'demo', 'site', 'information' ), + 'location' => 'server', + ), + ) + ); + } else { + wp_register_feature( + array( + 'id' => 'demo/site-info', + 'name' => __( 'Site Information', 'wp-feature-api-agent' ), + 'description' => __( 'Get basic information about the WordPress site. This includes the name, description, URL, version, language, timezone, date format, time format, active plugins, and active theme.', 'wp-feature-api-agent' ), + 'type' => WP_Feature::TYPE_RESOURCE, + 'categories' => array( 'demo', 'site', 'information' ), + 'callback' => array( $this, 'site_info_callback' ), + ) + ); + } } /** diff --git a/demo/wp-feature-api-agent/wp-feature-api-agent.php b/demo/wp-feature-api-agent/wp-feature-api-agent.php index 41f15a1..6f2a701 100644 --- a/demo/wp-feature-api-agent/wp-feature-api-agent.php +++ b/demo/wp-feature-api-agent/wp-feature-api-agent.php @@ -40,7 +40,10 @@ // Register additional demo features. $feature_register_instance = new A8C\WpFeatureApiAgent\WP_Feature_Register(); + +// Register on both hooks to support both APIs add_action( 'wp_feature_api_init', array( $feature_register_instance, 'register_features' ) ); +add_action( 'abilities_api_init', array( $feature_register_instance, 'register_features' ) ); /** * Enqueues scripts and styles for the admin area. diff --git a/includes/class-wp-feature-api-init.php b/includes/class-wp-feature-api-init.php index d6edd91..fae9554 100644 --- a/includes/class-wp-feature-api-init.php +++ b/includes/class-wp-feature-api-init.php @@ -56,11 +56,10 @@ public static function enqueue_admin_scripts() { } $assets = require WP_FEATURE_API_PLUGIN_DIR . 'build/index.asset.php'; wp_enqueue_script( 'wp-features', WP_FEATURE_API_PLUGIN_URL . 'build/index.js', $assets['dependencies'], $assets['version'], true ); - - // Pass configuration to JavaScript + $config = array(); if ( defined( 'WP_FEATURE_API_ABILITIES_BACKEND' ) && WP_FEATURE_API_ABILITIES_BACKEND ) { - $config['useAbilitiesBackend'] = true; + $config['backend'] = 'abilities'; } wp_localize_script( 'wp-features', 'wpFeatureAPIConfig', $config ); } diff --git a/packages/client/src/config.ts b/packages/client/src/config.ts index 7acce3c..8d6a5be 100644 --- a/packages/client/src/config.ts +++ b/packages/client/src/config.ts @@ -11,67 +11,62 @@ import { ENTITY_NAME } from './store/constants'; export interface FeatureAPIConfig { /** - * Whether to use the Abilities API backend for server features + * Backend to use for server features ('features' or 'abilities') */ - useAbilitiesBackend?: boolean; + backend: 'features' | 'abilities'; /** * Entity name for fetching features/abilities */ - entityName?: string; + entityName: string; } -// Default configuration -const defaultConfig: FeatureAPIConfig = { - useAbilitiesBackend: false, - entityName: ENTITY_NAME, -}; - -// Current configuration -let currentConfig: FeatureAPIConfig = { ...defaultConfig }; - -/** - * Configure the Feature API client - * - * @param config Configuration options - */ -export function configure( config: Partial< FeatureAPIConfig > ): void { - currentConfig = { - ...defaultConfig, - ...config, - }; +declare global { + interface Window { + wpFeatureAPIConfig?: { + backend?: string; + }; + wp?: any; + } +} - // If using abilities backend, update entity name and paths - if ( config.useAbilitiesBackend ) { - currentConfig.entityName = 'abilities'; +const backend = + window.wpFeatureAPIConfig?.backend === 'abilities' + ? 'abilities' + : 'features'; - // Register the abilities entity with core-data if not already registered - const { dispatch, select } = ( window as any ).wp.data; - const coreStore = ( window as any ).wp.coreData?.store; +// Set configuration once at module load +const currentConfig: FeatureAPIConfig = { + backend, + entityName: backend === 'abilities' ? 'abilities' : ENTITY_NAME, +}; - if ( coreStore && dispatch && select ) { - // Check if abilities entity is already registered - const entities = - select( coreStore ).getEntitiesConfig( 'root' ) || []; - const abilitiesEntityExists = entities.some( - ( entity: any ) => entity.name === 'abilities' - ); +// Register abilities entity with core-data if using abilities backend +// wp-data should be available since it's a dependency of wp-features +if ( backend === 'abilities' ) { + registerAbilitiesEntity(); +} - if ( ! abilitiesEntityExists ) { - dispatch( coreStore ).addEntities( [ - { - name: 'abilities', - kind: 'root', - baseURL: '/wp/v2/abilities', - baseURLParams: { context: 'edit' }, - plural: 'abilities', - label: 'Abilities', - transientEdits: { - callback: true, - }, - }, - ] ); - } +function registerAbilitiesEntity() { + const { dispatch, select } = ( window as any ).wp.data; + const coreStore = ( window as any ).wp.coreData?.store; + + if ( coreStore && dispatch && select ) { + const entities = select( coreStore ).getEntitiesConfig( 'root' ) || []; + const abilitiesEntityExists = entities.some( + ( entity: any ) => entity.name === 'abilities' + ); + if ( ! abilitiesEntityExists ) { + dispatch( coreStore ).addEntities( [ + { + name: 'abilities', + kind: 'root', + baseURL: '/wp/v2/abilities', + baseURLParams: { context: 'edit' }, + plural: 'abilities', + label: 'Abilities', + }, + ] ); } } } @@ -106,7 +101,7 @@ export function getRunEndpoint( id: string ): string { // For abilities, strip the 'ability/' prefix if present const cleanId = - currentConfig.useAbilitiesBackend && id.startsWith( 'ability/' ) + currentConfig.backend === 'abilities' && id.startsWith( 'ability/' ) ? id.replace( 'ability/', '' ) : id; @@ -120,9 +115,10 @@ export function getRunEndpoint( id: string ): string { * @return Whether to use abilities backend */ export function shouldUseAbilitiesBackend( feature: any ): boolean { - // If abilities backend is enabled and the feature is server-side + // If abilities backend is enabled and the feature is server-side or has ability prefix return ( - currentConfig.useAbilitiesBackend === true && - feature?.location === 'server' + currentConfig.backend === 'abilities' && + ( feature?.location === 'server' || + feature?.id?.startsWith( 'ability/' ) ) ); } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index dfbd286..d745760 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -9,7 +9,6 @@ import { getRegisteredFeature, getRegisteredFeatures, } from './api'; -import { configure } from './config'; const publicApi = { store, @@ -18,7 +17,6 @@ const publicApi = { executeFeature, getRegisteredFeature, getRegisteredFeatures, - configure, }; export { store }; @@ -29,7 +27,6 @@ export { executeFeature, getRegisteredFeature, getRegisteredFeatures, - configure, }; export * from './command-integration'; export { publicApi as wpFeatures }; diff --git a/packages/client/src/store/resolvers.ts b/packages/client/src/store/resolvers.ts index af6f7e8..a334d5e 100644 --- a/packages/client/src/store/resolvers.ts +++ b/packages/client/src/store/resolvers.ts @@ -34,17 +34,18 @@ export function getRegisteredFeatures() { hasMore = false; } else { // If fetching from abilities, convert them to feature format - const processedFeatures = config.useAbilitiesBackend - ? features.map( ( ability ) => ( { - ...ability, - // Ensure abilities have the 'ability/' prefix in their ID - id: ability.id.startsWith( 'ability/' ) - ? ability.id - : `ability/${ ability.id }`, - name: ability.label || ability.name, - location: ability.meta?.location || 'server', - } ) ) - : features; + const processedFeatures = + config.backend === 'abilities' + ? features.map( ( ability ) => ( { + ...ability, + // Ensure abilities have the 'ability/' prefix in their ID + id: ability.id.startsWith( 'ability/' ) + ? ability.id + : `ability/${ ability.id }`, + name: ability.label || ability.name, + location: ability.meta?.location || 'server', + } ) ) + : features; allFeatures = [ ...allFeatures, ...processedFeatures ]; page++; diff --git a/src/index.js b/src/index.js index dd315fc..08261a4 100644 --- a/src/index.js +++ b/src/index.js @@ -3,11 +3,5 @@ */ import { coreFeatures } from '../packages/client-features/src'; import { registerFeature } from '../packages/client/src/api'; -import { configure } from '../packages/client/src'; - -// Configure to use Abilities API backend if enabled -if ( window.wpFeatureAPIConfig?.useAbilitiesBackend ) { - configure( { useAbilitiesBackend: true } ); -} coreFeatures.filter( ( feature ) => !! feature ).forEach( registerFeature ); From f35cf8b916e2d5aa82a1385131e8dabd2867927b Mon Sep 17 00:00:00 2001 From: Em Date: Wed, 13 Aug 2025 14:41:05 -0400 Subject: [PATCH 4/4] fix: Update abilities backend integration for name-based identification - Add key: 'name' to abilities entity config for core-data - Map ability fields correctly to Feature interface - Fix resolver to handle abilities using name instead of id --- packages/client/src/config.ts | 1 + packages/client/src/store/resolvers.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/client/src/config.ts b/packages/client/src/config.ts index 8d6a5be..7215a9c 100644 --- a/packages/client/src/config.ts +++ b/packages/client/src/config.ts @@ -65,6 +65,7 @@ function registerAbilitiesEntity() { baseURLParams: { context: 'edit' }, plural: 'abilities', label: 'Abilities', + key: 'name', // Abilities use 'name' as their identifier, not 'id' }, ] ); } diff --git a/packages/client/src/store/resolvers.ts b/packages/client/src/store/resolvers.ts index a334d5e..78772d7 100644 --- a/packages/client/src/store/resolvers.ts +++ b/packages/client/src/store/resolvers.ts @@ -38,11 +38,14 @@ export function getRegisteredFeatures() { config.backend === 'abilities' ? features.map( ( ability ) => ( { ...ability, - // Ensure abilities have the 'ability/' prefix in their ID - id: ability.id.startsWith( 'ability/' ) - ? ability.id - : `ability/${ ability.id }`, + // Use the ability's name field as the id, with 'ability/' prefix + id: ability.name?.startsWith( 'ability/' ) + ? ability.name + : `ability/${ ability.name }`, name: ability.label || ability.name, + description: ability.description || '', + type: ability.meta?.type || 'tool', + categories: ability.meta?.categories || [], location: ability.meta?.location || 'server', } ) ) : features;