Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0849e44
Expose site score and recommendations as WordPress abilities
ilicfilip Sep 18, 2026
edf8d92
Expose site score and recommendations as WordPress abilities
ilicfilip Sep 16, 2026
6a21942
Add tests for the abilities layer
ilicfilip Sep 16, 2026
69110ad
Fix ability tests re-registering on already-fired hooks
ilicfilip Sep 16, 2026
31edeeb
Make ability registration idempotent
ilicfilip Sep 16, 2026
a8589cd
Stop the recommendation field test from asserting nothing
ilicfilip Sep 16, 2026
2bdacad
Add complete-recommendation for settings-only recommendations
ilicfilip Sep 18, 2026
28b8da1
Extend complete-recommendation to SEO-plugin settings
ilicfilip Sep 18, 2026
d7bbaf5
Split the abilities layer into classes with one job each
ilicfilip Sep 18, 2026
c12782d
Fix code style: array-arrow alignment and reserved variable name
ilicfilip Sep 22, 2026
d750f6f
Merge branch 'develop' into filip/abilities-api
ilicfilip Sep 24, 2026
e78bffe
Add a short confirmation code to the test email
ilicfilip Sep 18, 2026
ff2fafe
Let an agent clear WordPress's placeholder content
ilicfilip Sep 18, 2026
4b10ec5
Let a caller record which page serves the About, Contact or FAQ role
ilicfilip Sep 18, 2026
b8d3900
Merge branch 'filip/abilities-api' into filip/abilities-api-write
ilicfilip Sep 29, 2026
0c03d14
Merge remote-tracking branch 'origin/develop' into filip/abilities-api
ilicfilip Sep 29, 2026
fccaa41
Merge pull request #783 from ProgressPlanner/filip/abilities-api-write
ilicfilip Sep 29, 2026
c007721
Expose the abilities to MCP clients
ilicfilip Sep 29, 2026
a59533d
List a recommendation awaiting celebration under completed
ilicfilip Sep 25, 2026
4ad77ba
See a page role in the same request that set it
ilicfilip Sep 29, 2026
f7bfa2a
Record a completion when complete-recommendation makes it
ilicfilip Sep 29, 2026
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
259 changes: 259 additions & 0 deletions classes/abilities/class-abilities.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
<?php
/**
* Register Progress Planner abilities with the WordPress Abilities API.
*
* Abilities are how an AI agent reads this plugin. Registration is unconditional
* on the API being present: any MCP bridge that discovers WordPress abilities
* picks these up without this plugin knowing the bridge exists.
*
* Nothing downstream re-checks these abilities. A bridge that offers a
* third-party ability serves it on its own permission callback, so the callbacks
* declared here are the only gate that runs. They are written to stand alone.
*
* This class registers and delegates; the work lives in Site_Score and
* Recommendations.
*
* @package Progress_Planner
*/

namespace Progress_Planner\Abilities;

/**
* Abilities class.
*/
class Abilities {

/**
* The ability category slug, and the namespace every ability name uses.
*
* @var string
*/
const CATEGORY = 'progress-planner';

/**
* The capability required to read plugin data.
*
* Matches the capability the admin pages are gated on, so an ability can
* never surface data the user could not open in wp-admin.
*
* @var string
*/
const READ_CAPABILITY = 'edit_others_posts';

/**
* The capability required to change a setting.
*
* The same capability the interactive tasks check before writing, so an
* ability can never do what the popover would refuse.
*
* @var string
*/
const WRITE_CAPABILITY = 'manage_options';

/**
* The site-score reader.
*
* @var Site_Score
*/
private $site_score;

/**
* The recommendations reader and applier.
*
* @var Recommendations
*/
private $recommendations;

/**
* Constructor.
*/
public function __construct() {
$this->site_score = new Site_Score();
$this->recommendations = new Recommendations();

// Categories register on an earlier hook than abilities: core rejects an
// ability naming a category that does not exist yet.
\add_action( 'wp_abilities_api_categories_init', [ $this, 'register_categories' ] );
\add_action( 'wp_abilities_api_init', [ $this, 'register_abilities' ] );
}

/**
* Register the ability category.
*
* @return void
*/
public function register_categories() {
if ( ! \function_exists( 'wp_register_ability_category' ) ) {
return;
}

// Registering twice is incorrect usage. Guarding here rather than relying
// on the hook firing once keeps a second instance of this class harmless.
// is_registered() is the notice-free probe: wp_get_ability_category()
// reports a miss as incorrect usage in its own right.
if ( $this->is_category_registered() ) {
return;
}

\wp_register_ability_category(
self::CATEGORY,
[
'label' => \__( 'Progress Planner', 'progress-planner' ),
'description' => \__( 'Website maintenance progress, scores and recommendations provided by Progress Planner.', 'progress-planner' ),
]
);
}

/**
* Register the abilities.
*
* @return void
*/
public function register_abilities() {
if ( ! \function_exists( 'wp_register_ability' ) ) {
return;
}

// As above: re-registering is incorrect usage, so the registry is asked
// first rather than assuming this runs exactly once.
if ( $this->is_ability_registered( self::CATEGORY . '/get-site-score' ) ) {
return;
}

\wp_register_ability(
self::CATEGORY . '/get-site-score',
$this->ability_args(
[
'label' => \__( 'Get site score', 'progress-planner' ),
'description' => \__( 'Get the Progress Planner activity score for this site, the weekly maintenance checklist, earned badges and the six-month score history.', 'progress-planner' ),
'input_schema' => Schemas::no_input(),
'output_schema' => Schemas::site_score(),
'execute_callback' => [ $this->site_score, 'get' ],
]
)
);

\wp_register_ability(
self::CATEGORY . '/list-recommendations',
$this->ability_args(
[
'label' => \__( 'List recommendations', 'progress-planner' ),
'description' => \__( 'List the website maintenance tasks Progress Planner currently recommends, newest first. Returns only tasks the current user is allowed to act on.', 'progress-planner' ),
'input_schema' => Schemas::list_recommendations_input(),
'output_schema' => Schemas::recommendations(),
'execute_callback' => [ $this->recommendations, 'list' ],
]
)
);

\wp_register_ability(
self::CATEGORY . '/complete-recommendation',
$this->ability_args(
[
'label' => \__( 'Complete recommendation', 'progress-planner' ),
'description' => \__( 'Apply a Progress Planner recommendation that consists of a single site setting, such as the tagline, timezone or an SEO plugin toggle. Only a fixed list of settings can be changed this way; anything needing judgement, content or deletion is reported back with a link instead of being applied.', 'progress-planner' ),
'input_schema' => Schemas::complete_recommendation_input(),
'output_schema' => Schemas::complete_recommendation(),
'permission_callback' => [ $this, 'can_fix' ],
'execute_callback' => [ $this->recommendations, 'complete' ],
'readonly' => false,
// Most of what this applies is a settings change, but two
// recommendations trash WordPress's placeholder content. The
// annotation describes what the ability can do, not what a
// given call happens to do, so it is declared destructive and
// a client prompts before any of it runs.
'destructive' => true,
]
)
);
}

/**
* Build the registration arguments for one ability.
*
* The shared parts are defined once so an ability cannot drift from the
* category, the permission model or the annotations the others declare.
*
* @param array<string, mixed> $args The ability-specific arguments.
*
* @return array<string, mixed>
*/
private function ability_args( array $args ) {
$readonly = $args['readonly'] ?? true;
$destructive = $args['destructive'] ?? false;
unset( $args['readonly'], $args['destructive'] );

return \array_merge(
[
'category' => self::CATEGORY,
'permission_callback' => [ $this, 'can_read' ],
'meta' => [
'show_in_rest' => true,
// The MCP adapter's default server lists and runs only
// abilities that opt in here; without it no agent sees them.
'mcp' => [ 'public' => true ],
'annotations' => [
'readonly' => $readonly,
'destructive' => $destructive,
'idempotent' => true,
],
],
],
$args
);
}

/**
* Whether the current user may read Progress Planner data.
*
* @return bool
*/
public function can_read() {
return \current_user_can( self::READ_CAPABILITY );
}

/**
* Whether the current user may apply a fix.
*
* There is no nonce: an authenticated agent call is not a forged
* cross-origin form post, so this capability and the fixed list of settings
* in Recommendation_Fixes are what bound the write surface.
*
* @return bool
*/
public function can_fix() {
return \current_user_can( self::WRITE_CAPABILITY );
}

/**
* Whether the ability category is already registered.
*
* @return bool
*/
private function is_category_registered() {
if ( ! \class_exists( '\WP_Ability_Categories_Registry' ) ) {
return false;
}

$registry = \WP_Ability_Categories_Registry::get_instance();

return $registry && $registry->is_registered( self::CATEGORY );
}

/**
* Whether an ability is already registered.
*
* @param string $name The fully-qualified ability name.
*
* @return bool
*/
private function is_ability_registered( $name ) {
if ( ! \class_exists( '\WP_Abilities_Registry' ) ) {
return false;
}

$registry = \WP_Abilities_Registry::get_instance();

return $registry && $registry->is_registered( $name );
}
}
Loading
Loading