Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cfff125
Add initial core abilities for WordPress 6.9
Jameswlepage Oct 10, 2025
1957efb
Fix linting issues and update hook naming
Jameswlepage Oct 10, 2025
299ecc9
Remove core/find-abilities ability
Jameswlepage Oct 15, 2025
7f928ef
Refactor core abilities: namespace, categories, and expanded environm…
Jameswlepage Oct 15, 2025
19f665a
Fix wp/get-current-user-info output schema required fields
Jameswlepage Oct 15, 2025
033f653
Remove unused input parameter from wp/get-site-info permission callback
Jameswlepage Oct 15, 2025
dbf97f7
Refine core abilities with constants and improved descriptions
Jameswlepage Oct 16, 2025
28b6040
Update includes/abilities/class-wp-core-abilities.php
Jameswlepage Oct 16, 2025
93e85df
Fix code style and add environment examples in WP_Core_Abilities
Jameswlepage Oct 16, 2025
53d37fb
Merge branch 'trunk' into feature/initial-core-abilities
Jameswlepage Oct 16, 2025
6dced81
Merge branch 'trunk' into feature/initial-core-abilities
Jameswlepage Oct 19, 2025
4dce755
Apply suggestions from code review
Jameswlepage Oct 19, 2025
00fa5c1
Changed namespace back to core/ and not wp/ to align w/ GB
Jameswlepage Oct 19, 2025
afd83c9
Renaming Get Current User info to Get User Info to allow for flexibil…
Jameswlepage Oct 19, 2025
766490b
Update function name for user info too
Jameswlepage Oct 19, 2025
8f09665
Rewrite Site Info ability to return all fields by default w/ self doc…
Jameswlepage Oct 19, 2025
b7b69a1
Normalize ability inputs with schema defaults for empty REST calls
Jameswlepage Oct 20, 2025
73a2092
drop input var on site permission callback
Jameswlepage Oct 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions includes/abilities-api/class-wp-ability.php
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,27 @@ public function get_input_schema(): array {
return $this->input_schema;
}

/**
* Applies the defined input default when no input is provided.
*
* @since 0.4.0
*
* @param mixed $input Optional. The raw input provided for the ability. Default `null`.
* @return mixed The input with the schema default applied when available.
*/
public function normalize_input( $input = null ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran out of time to include that in the patch against WordPress trunk. Is there anyone willing to handle the bug fix PR in core just for this change? 😄

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WordPress/wordpress-develop#10395 - started working on the patch. Unit tests are still missing at the time of writing.

if ( null !== $input ) {
return $input;
}

$input_schema = $this->get_input_schema();
if ( ! empty( $input_schema ) && array_key_exists( 'default', $input_schema ) ) {
return $input_schema['default'];
}

return null;
}

/**
* Retrieves the output schema for the ability.
*
Expand Down Expand Up @@ -436,6 +457,7 @@ protected function invoke_callback( callable $callback, $input = null ) {
* @return bool|\WP_Error Whether the ability has the necessary permission.
*/
public function check_permissions( $input = null ) {
$input = $this->normalize_input( $input );
$is_valid = $this->validate_input( $input );
if ( is_wp_error( $is_valid ) ) {
return $is_valid;
Expand Down Expand Up @@ -522,6 +544,7 @@ protected function validate_output( $output ) {
* @return mixed|\WP_Error The result of the ability execution, or WP_Error on failure.
*/
public function execute( $input = null ) {
$input = $this->normalize_input( $input );
$has_permissions = $this->check_permissions( $input );
if ( true !== $has_permissions ) {
if ( is_wp_error( $has_permissions ) ) {
Expand Down
317 changes: 317 additions & 0 deletions includes/abilities/class-wp-core-abilities.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
<?php
/**
* Core Abilities registration.
*
* @package WordPress
* @subpackage Abilities_API
* @since 0.3.0
*/

declare( strict_types = 1 );

/**
* Registers the default core abilities that ship with the Abilities API.
*
* @since 0.3.0
*/
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- Core class intended for WordPress core.
final class WP_Core_Abilities {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the value in putting these in a class? It feels like this could all be done in more straightforward functions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be perfectly fine to introduce two functions or static methods and attach them inside src/wp-includes/default-filters.php in WP core, for example:

add_filter( 'wp_abilities_api_categories_init', 'register_core_ability_categories' );
add_filter( 'wp_abilities_api_init', 'register_core_abilities' );

/**
* Category slugs for core abilities.
*
* @since 0.3.0
*/
public const CATEGORY_SITE = 'site';
public const CATEGORY_USER = 'user';
/**
* Registers the core abilities categories.
*
* @since 0.3.0
*
* @return void
*/
public static function register_category(): void {
// Site-related capabilities
wp_register_ability_category(
self::CATEGORY_SITE,
array(
'label' => __( 'Site' ),
'description' => __( 'Abilities that retrieve or modify site information and settings.' ),
)
);

// User-related capabilities
wp_register_ability_category(
self::CATEGORY_USER,
array(
'label' => __( 'User' ),
'description' => __( 'Abilities that retrieve or modify user information and settings.' ),
)
);
}

/**
* Registers the default core abilities.
*
* @since 0.3.0
*
* @return void
*/
public static function register(): void {
self::register_get_site_info();
self::register_get_user_info();
self::register_get_environment_info();
}

/**
* Registers the `core/get-site-info` ability.
*
* @since 0.3.0
*
* @return void
*/
protected static function register_get_site_info(): void {
$fields = array(
'name',
'description',
'url',
'wpurl',
'admin_email',
'charset',
'language',
'version',
);

wp_register_ability(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be duplicating the site settings REST endpoint. What is the value of exposing this here when there is already a way to expose this information.

Copy link
Contributor

@justlevine justlevine Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great Q!

If you're asking from a more philosophical POV ("why do Abilities even exist"), then the answer is similar to "What is the value of having REST endpoints when there's already a php function and ajax?". The tl;dr goal of the Abilities API is to create a sort of "Generic API" abstraction + registry, the idea being that instead of needed to reregister/expose the same functionality to various last-mile APIs (REST, admin-ajax, graphql, PHP in theme/plugins, MCP/A2A, Command Palette, and whatever future tech integrations that come up with). It's standardized and reliable, like hooks but for functionality, so all anybody needs is a generic adapter and it'l work without any domain knowledge of the ability itself.

In fact, I'd hope and expect to see the internals of many of our existing REST endpoints updated to internally use the Abilities API in 7.0 and beyond.

If you're asking from a more practical POV (e.g. regarding the shape of the input/output schemas), I think we're open to evolution here. I believe we want something that it generic to make sense in multiple context, and not just a single one of either REST, MCP, or Command Palette.

'core/get-site-info',
array(
'label' => __( 'Get Site Information' ),
'description' => __( 'Returns site information configured in WordPress. By default returns all fields, or optionally a filtered subset.' ),
'category' => self::CATEGORY_SITE,
'input_schema' => array(
'type' => 'object',
'properties' => array(
'fields' => array(
'type' => 'array',
'items' => array(
'type' => 'string',
'enum' => $fields,
),
'description' => __( 'Optional: Limit response to specific fields. If omitted, all fields are returned.' ),
),
),
'additionalProperties' => false,
'default' => array(),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'name' => array(
'type' => 'string',
'description' => __( 'The site title.' ),
),
'description' => array(
'type' => 'string',
'description' => __( 'The site tagline.' ),
),
'url' => array(
'type' => 'string',
'description' => __( 'The site home URL.' ),
),
'wpurl' => array(
'type' => 'string',
'description' => __( 'The WordPress installation URL.' ),
),
'admin_email' => array(
'type' => 'string',
'description' => __( 'The site administrator email address.' ),
),
'charset' => array(
'type' => 'string',
'description' => __( 'The site character encoding.' ),
),
'language' => array(
'type' => 'string',
'description' => __( 'The site language locale code.' ),
),
'version' => array(
'type' => 'string',
'description' => __( 'The WordPress version.' ),
),
),
'additionalProperties' => false,
),
'execute_callback' => static function ( $input = array() ): array {
$input = is_array( $input ) ? $input : array();
$all_fields = array( 'name', 'description', 'url', 'wpurl', 'admin_email', 'charset', 'language', 'version' );
$requested_fields = ! empty( $input['fields'] ) ? $input['fields'] : $all_fields;

$result = array();
foreach ( $requested_fields as $field ) {
$result[ $field ] = get_bloginfo( $field );
}

return $result;
},
'permission_callback' => static function (): bool {
return current_user_can( 'manage_options' );
},
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => true,
),
)
);
}

/**
* Registers the `core/get-user-info` ability.
*
* @since 0.3.0
*
* @return void
*/
protected static function register_get_user_info(): void {
wp_register_ability(
'core/get-user-info',
array(
'label' => __( 'Get User Information' ),
'description' => __( 'Returns basic profile details for the current authenticated user to support personalization, auditing, and access-aware behavior.' ),
'category' => self::CATEGORY_USER,
'output_schema' => array(
'type' => 'object',
'required' => array( 'id', 'display_name', 'user_nicename', 'user_login', 'roles', 'locale' ),
'properties' => array(
'id' => array(
'type' => 'integer',
'description' => __( 'The user ID.' ),
),
'display_name' => array(
'type' => 'string',
'description' => __( 'The display name of the user.' ),
),
'user_nicename' => array(
'type' => 'string',
'description' => __( 'The URL-friendly name for the user.' ),
),
'user_login' => array(
'type' => 'string',
'description' => __( 'The login username for the user.' ),
),
'roles' => array(
'type' => 'array',
'description' => __( 'The roles assigned to the user.' ),
'items' => array(
'type' => 'string',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @aaronjorbin, what we have seems correct, roles is of type array with each item being a string. Feel free to correct me if I'm missing anything.

),
),
'locale' => array(
'type' => 'string',
'description' => __( 'The locale string for the user, such as en_US.' ),
),
),
'additionalProperties' => false,
),
'execute_callback' => static function (): array {
$current_user = wp_get_current_user();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to be interesting to see how it plays out in practice with AI clients that use some access tokens. It might happen that this will report back as an agent user 😄

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related: #108 (comment)

Switching to current_user_can() per the above would make this useful for both the agent user, and the user interacting with the agent.


return array(
'id' => $current_user->ID,
'display_name' => $current_user->display_name,
'user_nicename' => $current_user->user_nicename,
'user_login' => $current_user->user_login,
'roles' => $current_user->roles,
'locale' => get_user_locale( $current_user ),
);
},
'permission_callback' => static function (): bool {
return is_user_logged_in();
},
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => false,
),
)
);
}

/**
* Registers the `core/get-environment-info` ability.
*
* @since 0.3.0
*
* @return void
*/
protected static function register_get_environment_info(): void {
wp_register_ability(
'core/get-environment-info',
array(
'label' => __( 'Get Environment Info' ),
'description' => __( 'Returns core details about the site\'s runtime context for diagnostics and compatibility (environment, PHP runtime, database server info, WordPress version).' ),
'category' => self::CATEGORY_SITE,
'output_schema' => array(
'type' => 'object',
'required' => array( 'environment', 'php_version', 'db_server_info', 'wp_version' ),
'properties' => array(
'environment' => array(
'type' => 'string',
'description' => __( 'The site\'s runtime environment classification (e.g., production, staging, development).' ),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use examples here, too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can only be one of four values. I think it's valueable to make that clearer.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enum would fit perfectly here 👍🏻

'examples' => array( 'production', 'staging', 'development', 'local' ),
),
'php_version' => array(
'type' => 'string',
'description' => __( 'The PHP runtime version executing WordPress.' ),
),
'db_server_info' => array(
'type' => 'string',
'description' => __( 'The database server vendor and version string reported by the driver.' ),
'examples' => array( '8.0.34', '10.11.6-MariaDB' ),
),
'wp_version' => array(
'type' => 'string',
'description' => __( 'The WordPress core version running on this site.' ),
),
),
'additionalProperties' => false,
),
'execute_callback' => static function (): array {
global $wpdb;

$env = wp_get_environment_type();
$php_version = phpversion();
$db_server_info = '';
if ( isset( $wpdb ) && is_object( $wpdb ) && method_exists( $wpdb, 'db_server_info' ) ) {
$db_server_info = $wpdb->db_server_info() ?? '';
}
$wp_version = get_bloginfo( 'version' );

return array(
'environment' => $env,
'php_version' => $php_version,
'db_server_info' => $db_server_info,
'wp_version' => $wp_version,
);
},
'permission_callback' => static function (): bool {
return current_user_can( 'manage_options' );
},
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
'idempotent' => true,
),
'show_in_rest' => true,
),
)
);
}
}
14 changes: 14 additions & 0 deletions includes/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@
require_once __DIR__ . '/abilities-api.php';
}

// Load core abilities class.
if ( ! class_exists( 'WP_Core_Abilities' ) ) {
require_once __DIR__ . '/abilities/class-wp-core-abilities.php';
}

// Register core abilities category and abilities when requested via filter or when not in test environment.
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Plugin-specific hook for feature plugin context.
if ( ! ( defined( 'WP_RUN_CORE_TESTS' ) || defined( 'WP_TESTS_CONFIG_FILE_PATH' ) || ( function_exists( 'getenv' ) && false !== getenv( 'WP_PHPUNIT__DIR' ) ) ) || apply_filters( 'abilities_api_register_core_abilities', false ) ) {
if ( function_exists( 'add_action' ) ) {
add_action( 'abilities_api_categories_init', array( 'WP_Core_Abilities', 'register_category' ) );
add_action( 'abilities_api_init', array( 'WP_Core_Abilities', 'register' ) );
}
}

// Load REST API init class for plugin bootstrap.
if ( ! class_exists( 'WP_REST_Abilities_Init' ) ) {
require_once __DIR__ . '/rest-api/class-wp-rest-abilities-init.php';
Expand Down
Loading