PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.3
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.3
4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 All 201 releases
betterdocs / includes / Abilities / AbilitiesRegistrar.php

AbilitiesRegistrar.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.3, at includes/Abilities/AbilitiesRegistrar.php

464 lines 13.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Abilities registrar.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Abilities;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit; // Exit if accessed directly.
13 }
14
15 /**
16 * Registers BetterDocs' abilities with the WordPress Abilities API.
17 *
18 * Registration is **always** on when the API is available — it is deliberately
19 * not gated by the `enable_mcp` toggle. Every ability permission-checks itself
20 * on each call, so registering exposes nothing; it only makes BetterDocs
21 * discoverable to generic Abilities clients the way WordPress core abilities
22 * are. Coupling the two meant a fresh install, where `enable_mcp` defaults to
23 * off, registered zero abilities and was invisible to every connector. The
24 * developer kill switch is `betterdocs_abilities_api_enabled`.
25 *
26 * @since 4.9.0
27 */
28 class AbilitiesRegistrar {
29
30 /**
31 * Ability-name prefixes that mark an ability as BetterDocs'. Free owns
32 * `betterdocs/`; Pro registers under `betterdocs-pro/` through the
33 * `betterdocs_register_abilities` filter.
34 *
35 * @since 4.9.0
36 */
37 public const ABILITY_PREFIXES = [ 'betterdocs/', 'betterdocs-pro/' ];
38
39 /**
40 * Ability category slug.
41 *
42 * @since 4.9.0
43 */
44 public const CATEGORY = 'betterdocs';
45
46 /**
47 * Whether the replay in {@see self::ensure_registered()} has already run
48 * this request. One attempt only — a replay that produced nothing will not
49 * produce anything the second time either, and the MCP server asks for the
50 * tool list more than once per request.
51 *
52 * @since 4.9.0
53 *
54 * @var bool
55 */
56 private static $replayed = false;
57
58 /**
59 * Our own ability objects, by id.
60 *
61 * `wp_register_ability()` builds the runtime's `WP_Ability` from the array we
62 * hand it, so afterwards our instance is reachable only through the two
63 * callbacks. `MCPTools` needs more than that — `describe()` for the
64 * live description, `requires_pro()`, `get_capability()`, the annotations —
65 * so we keep our own map.
66 *
67 * @since 4.9.0
68 *
69 * @var array<string, AbilityBase>
70 */
71 private static $instances = [];
72
73 /**
74 * Hooks the registrar up.
75 *
76 * Both hooks are added **unconditionally** — no `function_exists( 'wp_register_ability' )`
77 * early return. The Abilities API is a set of global functions behind
78 * `function_exists` guards, so which copy owns them (core's, ours in
79 * `dependencies/`, another plugin's) is decided by load order, not by us. An
80 * early return here would leave BetterDocs permanently unregistered on any
81 * site where the API lands after the plugin. The callbacks carry the guards
82 * instead, which makes hooking free when no API ever appears.
83 *
84 * @since 4.9.0
85 */
86 public function __construct() {
87 add_action( 'wp_abilities_api_categories_init', [ $this, 'register_category' ] );
88 add_action( 'wp_abilities_api_init', [ $this, 'register_abilities' ] );
89 }
90
91 /**
92 * The ability instance behind an id, when this request registered it.
93 *
94 * @since 4.9.0
95 *
96 * @param string $id Ability id.
97 * @return AbilityBase|null
98 */
99 public static function instance( $id ) {
100 return isset( self::$instances[ $id ] ) ? self::$instances[ $id ] : null;
101 }
102
103 /**
104 * Every ability instance this request registered, by id.
105 *
106 * @since 4.9.0
107 *
108 * @return array<string, AbilityBase>
109 */
110 public static function instances() {
111 return self::$instances;
112 }
113
114 /**
115 * Registers the BetterDocs ability category.
116 *
117 * @since 4.9.0
118 *
119 * @return void
120 */
121 public function register_category() {
122 if ( function_exists( 'wp_has_ability_category' ) && wp_has_ability_category( self::CATEGORY ) ) {
123 return;
124 }
125
126 if ( ! function_exists( 'wp_register_ability_category' ) ) {
127 return;
128 }
129
130 wp_register_ability_category(
131 self::CATEGORY,
132 [
133 'label' => __( 'BetterDocs', 'betterdocs' ),
134 'description' => __( 'Create and manage docs, categories, tags, FAQs, knowledge bases, settings and analytics.', 'betterdocs' )
135 ]
136 );
137 }
138
139 /**
140 * Registers BetterDocs' abilities.
141 *
142 * @since 4.9.0
143 *
144 * @return void
145 */
146 public function register_abilities() {
147 if ( ! AbilityBase::abilities_enabled() ) {
148 return;
149 }
150
151 foreach ( $this->build_abilities() as $id => $ability ) {
152 // Recorded before the duplicate check: the instance map is what
153 // MCPTools reads, and an ability someone else registered first is
154 // still ours to describe.
155 self::$instances[ $id ] = $ability;
156
157 if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $id ) ) {
158 continue;
159 }
160
161 $ability->register();
162 }
163 }
164
165 /**
166 * Builds the final `[ id => AbilityBase ]` map: Free abilities, then Pro
167 * stubs, then whatever the filter returns.
168 *
169 * Kept separate from {@see self::register_abilities()} so the composition
170 * rules — by-id replacement, and the two skips — are testable without a
171 * WordPress registry to register into.
172 *
173 * The result is re-keyed by `get_id()` **after** the filter, which is what
174 * makes Pro's registrar work: it returns real ability objects carrying the
175 * same ids as Free's stubs, and they replace the stubs by name whether the
176 * filter kept our keys or appended with numeric ones.
177 *
178 * @since 4.9.0
179 *
180 * @return array<string, AbilityBase>
181 */
182 public function build_abilities() {
183 $list = [];
184
185 foreach ( array_merge( $this->free_abilities(), $this->stub_abilities() ) as $ability ) {
186 if ( $ability instanceof AbilityBase ) {
187 $list[ $ability->get_id() ] = $ability;
188 }
189 }
190
191 /**
192 * Filters the abilities BetterDocs registers.
193 *
194 * Return objects with the same ids to replace them — this is how Pro
195 * swaps Free's placeholder stubs for the real implementations without
196 * the tool name ever changing.
197 *
198 * @since 4.9.0
199 *
200 * @param array<string, AbilityBase> $list Abilities by id.
201 */
202 $filtered = apply_filters( 'betterdocs_register_abilities', $list );
203
204 $final = [];
205
206 foreach ( (array) $filtered as $ability ) {
207 if ( ! $ability instanceof AbilityBase ) {
208 continue;
209 }
210
211 if ( ! $ability->meets_capability_policy() || ! $ability->is_enabled() ) {
212 continue;
213 }
214
215 $final[ $ability->get_id() ] = $ability;
216 }
217
218 return $final;
219 }
220
221 /**
222 * Free's own abilities.
223 *
224 * @since 4.9.0
225 *
226 * @return AbilityBase[]
227 */
228 protected function free_abilities() {
229 return [
230 new Status\GetStatus(),
231 new Docs\CreateDoc(),
232 new Docs\UpdateDoc(),
233 new Docs\GetDoc(),
234 new Docs\ListDocs(),
235 new Docs\DeleteDoc(),
236 new Terms\CreateTerm(),
237 new Terms\UpdateTerm(),
238 new Terms\DeleteTerm(),
239 new Terms\ListTerms(),
240 new Faq\CreateFAQGroup(),
241 new Faq\UpdateFAQGroup(),
242 new Faq\DeleteFAQGroup(),
243 new Faq\ListFAQGroups(),
244 new Faq\CreateFAQ(),
245 new Faq\UpdateFAQ(),
246 new Faq\DeleteFAQ(),
247 new Faq\ListFAQs(),
248 new Faq\AttachFAQ(),
249 new Settings\GetSettingsSchema(),
250 new Settings\GetSettings(),
251 new Settings\UpdateSettings(),
252 new Analytics\GetDocAnalytics()
253 ];
254 }
255
256 /**
257 * Placeholder abilities for the Pro-only tools, so the catalog is the same
258 * shape on every site. Pro replaces them by id.
259 *
260 * @since 4.9.0
261 *
262 * @return AbilityBase[]
263 */
264 protected function stub_abilities() {
265 $stubs = [];
266
267 foreach ( ProStubs::specs() as $spec ) {
268 $stubs[] = new StubAbility( $spec );
269 }
270
271 return $stubs;
272 }
273
274 /**
275 * Guarantees BetterDocs' abilities are in the registry, replaying
276 * registration once if they are not.
277 *
278 * `wp_abilities_api_init` fires exactly once per request, from the lazy
279 * registry singleton of whichever copy owns the globals. If a foreign copy
280 * owns them and fires before our hook is attached, our callback never runs:
281 * the registry fills up with everyone else's abilities and `tools/list`
282 * answers `[]` while auth, discovery and `initialize` all report success.
283 *
284 * Reading `wp_get_abilities()` first forces that lazy init, so by the time we
285 * decide to replay, the action has fired and `wp_register_ability()` will
286 * accept us. Per-ability `wp_has_ability()` checks keep the replay idempotent,
287 * so it is a no-op after a healthy hook run.
288 *
289 * The replay is skipped entirely when core owns the registry: core's
290 * `wp_register_ability()` refuses anything outside the running
291 * `wp_abilities_api_init` action, so replaying there registers nothing and
292 * only logs (ADR-032). The count is still returned, and the diagnostic
293 * summary is logged under `WP_DEBUG` when it is zero.
294 *
295 * @since 4.9.0
296 *
297 * @param callable|null $replay Optional replay routine, for tests.
298 * @return int BetterDocs abilities in the registry afterwards.
299 */
300 public static function ensure_registered( $replay = null ) {
301 if ( ! function_exists( 'wp_get_abilities' ) ) {
302 return 0;
303 }
304
305 // Forces the registry's lazy init, and with it `wp_abilities_api_init`.
306 $count = self::count_registered();
307
308 // On WordPress 6.9+, where core owns the registry, `wp_register_ability()`
309 // only works *while* `wp_abilities_api_init` is running — core checks
310 // `doing_action()`, not `did_action()`. A replay after the fact therefore
311 // cannot register anything; it would only write one `_doing_it_wrong()`
312 // notice per ability into the log. Say so once instead (ADR-032).
313 if ( 'core' === Runtime::owner()['source'] ) {
314 if ( 0 === $count && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
315 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated diagnostic.
316 error_log( '[BD-MCP] No BetterDocs abilities registered and WordPress core owns the Abilities API, so registration cannot be replayed. ' . self::summary() );
317 }
318
319 return $count;
320 }
321
322 if ( $count > 0 || self::$replayed ) {
323 return $count;
324 }
325
326 // Before the registry has initialised, `wp_register_ability()` refuses
327 // and calls `_doing_it_wrong()`. Nothing to replay into yet.
328 if ( ! function_exists( 'did_action' ) || ! did_action( 'wp_abilities_api_init' ) ) {
329 return $count;
330 }
331
332 self::$replayed = true;
333
334 if ( ! AbilityBase::abilities_enabled() ) {
335 return 0;
336 }
337
338 if ( null === $replay ) {
339 $registrar = new self();
340 $replay = static function () use ( $registrar ) {
341 $registrar->register_category();
342 $registrar->register_abilities();
343 };
344 }
345
346 $replay();
347
348 $count = self::count_registered();
349
350 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
351 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated diagnostic.
352 error_log( '[BD-MCP] BetterDocs abilities were missing from the registry; replayed registration. ' . self::summary() );
353 }
354
355 return $count;
356 }
357
358 /**
359 * How many BetterDocs abilities the registry currently holds.
360 *
361 * @since 4.9.0
362 *
363 * @return int
364 */
365 public static function count_registered() {
366 if ( ! function_exists( 'wp_get_abilities' ) ) {
367 return 0;
368 }
369
370 $count = 0;
371
372 foreach ( wp_get_abilities() as $ability ) {
373 if ( ! is_object( $ability ) || ! method_exists( $ability, 'get_name' ) ) {
374 continue;
375 }
376
377 foreach ( self::ABILITY_PREFIXES as $prefix ) {
378 if ( 0 === strpos( (string) $ability->get_name(), $prefix ) ) {
379 ++$count;
380 break;
381 }
382 }
383 }
384
385 return $count;
386 }
387
388 /**
389 * Diagnostic snapshot of the Abilities API as this request sees it.
390 *
391 * Feeds `bd-get-status`, the MCP self-test and the debug log, so "nothing
392 * registered" is distinguishable from "everything filtered out" without
393 * shell access.
394 *
395 * @since 4.9.0
396 *
397 * @return array
398 */
399 public static function diagnostics() {
400 $available = function_exists( 'wp_get_abilities' );
401 $owner = Runtime::owner();
402
403 // Read the registry BEFORE `hook_fired`: `wp_get_abilities()` forces the
404 // lazy singleton's init, which is what fires `wp_abilities_api_init`.
405 // Reading `did_action()` first would report `hook_fired => false` in the
406 // same snapshot that already counts registered abilities — an
407 // internally inconsistent line for the one scenario this exists for.
408 $total = $available ? count( wp_get_abilities() ) : 0;
409 $betterdocs = self::count_registered();
410
411 return [
412 'api_available' => $available,
413 'owner' => $owner,
414 'foreign' => 'foreign' === $owner['source'],
415 'hook_fired' => function_exists( 'did_action' ) ? (bool) did_action( 'wp_abilities_api_init' ) : false,
416 'total' => $total,
417 'betterdocs' => $betterdocs,
418 'replayed' => self::$replayed
419 ];
420 }
421
422 /**
423 * One-line, human-readable form of {@see self::diagnostics()}.
424 *
425 * @since 4.9.0
426 *
427 * @return string
428 */
429 public static function summary() {
430 $d = self::diagnostics();
431 $owners = [
432 'core' => __( 'WordPress core', 'betterdocs' ),
433 'bundled' => __( "BetterDocs' bundled runtime", 'betterdocs' ),
434 'foreign' => __( 'another plugin', 'betterdocs' ),
435 'none' => __( 'nobody', 'betterdocs' )
436 ];
437
438 $source = $d['owner']['source'];
439
440 return sprintf(
441 'Abilities API: %s; owner: %s (%s); abilities total: %d, betterdocs: %d; init fired: %s; replayed: %s',
442 $d['api_available'] ? 'present' : 'missing',
443 isset( $owners[ $source ] ) ? $owners[ $source ] : $source,
444 '' !== $d['owner']['path'] ? $d['owner']['path'] : 'unknown path',
445 $d['total'],
446 $d['betterdocs'],
447 $d['hook_fired'] ? 'yes' : 'no',
448 $d['replayed'] ? 'yes' : 'no'
449 );
450 }
451
452 /**
453 * Clears the per-request statics. Tests only.
454 *
455 * @since 4.9.0
456 *
457 * @return void
458 */
459 public static function reset() {
460 self::$instances = [];
461 self::$replayed = false;
462 }
463 }
464