Skip to content
This repository was archived by the owner on Feb 5, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 10 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
288 changes: 288 additions & 0 deletions includes/abilities/class-wp-core-abilities.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
<?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.
class WP_Core_Abilities {
Comment thread
Jameswlepage marked this conversation as resolved.
Outdated
/**
* Category slugs for core abilities.
*
* @since 0.3.0
*/
public const CATEGORY_SITE = 'site';
public const CATEGORY_USER = 'user';
Comment thread
Jameswlepage marked this conversation as resolved.
/**
* 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.' ),
)
);
Comment thread
Jameswlepage marked this conversation as resolved.
Outdated
}

/**
* Registers the default core abilities.
*
* @since 0.3.0
Comment thread
Jameswlepage marked this conversation as resolved.
Outdated
*
* @return void
*/
public static function register(): void {
self::register_get_site_info();
self::register_get_current_user_info();
self::register_get_environment_info();
}

/**
* Registers the `wp/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
Copy Markdown
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.

@justlevine justlevine Oct 21, 2025

Copy link
Copy Markdown
Contributor

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.

'wp/get-site-info',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should our prefix be core to align block namespaces? (personally think wp is fine)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I’m biased and prefer core/ based on my familiarity with the Gutenberg project where every aspect uses that convention: block types, block patterns, block bindings, data stores, etc.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It was core/ and I had suggested wp/. I didn't think of the convention already being established elsewhere. It's confusing to me that WP sometimes prefixes with wp_ and other times uses core. 🤷‍♂️

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If Gutenberg uses core as a prefix, we probably should follow that convention because it's the more modern implementation on WordPress. If anybody has strong feelings, let me know.

Will roll it back to core, but not super opinionated here.

array(
'label' => __( 'Get Site Information' ),
'description' => __( 'Returns a single site information field configured in WordPress (e.g., site name, URL, version) for display or diagnostics.' ),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Similarly to #108 (comment), I'm wondering if we'd get more mileage out of this if it returned an entire array of blog_info / site health, and instead used a fields: string[] if a user only wants a subset.

The obvious use case is LLMs/REST where you don't want a bunch of round trips for basic data, but I think it's helpful beyond that.

Semantically, it lets '-info' align better with the other two abilities.

Also, a shaped output_schema with the actual properties and matching types is better for both LLMs, traditional tools, and humans to reason with vs an array{field:string,value:mixed}. (We see the effects of this a lot in GraphQL/headless land)

Thoughts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point. It was the first ability I wrote, so missing consistency and not self documenting which I think is a big unlock. Kind of 'layered' but I think that's OK in this situation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

However, if we make this change, then REST would require a weird empty ?input field given how the current controller works.

GET /run?input # All fields
GET /run?input[fields][]=name # Filtered

This could be a nonbreaking improvement in the future and imo shouldn't block

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think the problem is that when you provide empty input, then the REST API sanitizes it and sets it to an empty array. Otherwise, execute will use its default value null. So, the solution could be allowing null in the input schema to address it. There might be better alternatives to consider, but I can't think of a simpler approach.

'category' => self::CATEGORY_SITE,
'input_schema' => array(
'type' => 'object',
Comment thread
gziolo marked this conversation as resolved.
'properties' => array(
'field' => array(
'type' => 'string',
'enum' => $fields,
'description' => __( 'The site information field to retrieve.' ),
),
),
Comment thread
Jameswlepage marked this conversation as resolved.
Outdated
'required' => array( 'field' ),
'additionalProperties' => false,
),
'output_schema' => array(
'type' => 'object',
'required' => array( 'field', 'value' ),
'properties' => array(
'field' => array(
'type' => 'string',
'description' => __( 'The requested site information field.' ),
),
'value' => array(
'type' => 'string',
'description' => __( 'The string value of the requested site information field.' ),
),
),
'additionalProperties' => false,
),
'execute_callback' => static function ( $input = array() ): array {
$field = $input['field'];
$value = get_bloginfo( $field );

return array(
'field' => $field,
'value' => $value,
);
},
'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 `wp/get-current-user-info` ability.
*
* @since 0.3.0
*
* @return void
*/
protected static function register_get_current_user_info(): void {
wp_register_ability(
'wp/get-current-user-info',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm wondering if it makes sense to generalize this to wp/get-user-info and then add an optional id: int input field (which in the future could be expanded with an idType and polymorphism or separate login_name, slug, email fields) that defaults to the current user if not supplied.

I don't think we need both, and implementing it this way will make it easier to handle the "agent user" pattern that @gziolo brought up in #108 (comment)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it’s perfectly fine to start with supporting only the current user because it has simple permission check model. There isn’t anything sensitive about asking for your info. If any, we could drop the current part from the ability unique name in case we feel it would help making it more extensible to support also fetching details about other users later. Filtering output by field name(s) seems like enhancement worth exploring but I would keep it for later because right now there isn’t much to filter.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

However I guess you are comparing it to the site info where such filtering by field is possible. I left my comment earlier, the downside of the shape proposed for site info is that the input and output schema describes more the shape of the response rather what individual fields represent. It’s much richer for the user as every field has description explaining their role.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed it - good optionality now, but I like starting as direct/upfront/atomic as possible.

array(
'label' => __( 'Get Current 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
Copy Markdown
Member

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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 `wp/get-environment-info` ability.
*
* @since 0.3.0
*
* @return void
*/
protected static function register_get_environment_info(): void {
wp_register_ability(
'wp/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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Loading