PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / 5.1.2
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback v5.1.2
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / doit / abilities / class-avcf-abilities-plugins.php
atarim-visual-collaboration / doit / abilities Last commit date
class-avcf-abilities-base.php 2 weeks ago class-avcf-abilities-block-navigation.php 2 weeks ago class-avcf-abilities-cache.php 2 weeks ago class-avcf-abilities-content.php 2 weeks ago class-avcf-abilities-core.php 2 weeks ago class-avcf-abilities-execute-php.php 2 weeks ago class-avcf-abilities-global-styles.php 2 weeks ago class-avcf-abilities-gutenberg.php 2 weeks ago class-avcf-abilities-media.php 2 weeks ago class-avcf-abilities-metadata.php 2 weeks ago class-avcf-abilities-navigation.php 2 weeks ago class-avcf-abilities-patterns.php 2 weeks ago class-avcf-abilities-plugins.php 2 weeks ago class-avcf-abilities-readonly.php 2 weeks ago class-avcf-abilities-settings.php 2 weeks ago class-avcf-abilities-taxonomies.php 2 weeks ago class-avcf-abilities-templates.php 2 weeks ago class-avcf-abilities-theme-files.php 2 weeks ago class-avcf-abilities-themes.php 2 weeks ago class-avcf-abilities-users.php 2 weeks ago
class-avcf-abilities-plugins.php
820 lines
1 <?php
2 /**
3 * Plugin management MCP abilities.
4 *
5 * Registers Atarim/* abilities for installing, activating, updating, and
6 * removing WordPress plugins via the AI action layer. Works with both free
7 * WordPress.org plugins (via the WP_Repo) and paid third-party plugins that
8 * register updates through their own update servers.
9 *
10 * Exposed abilities:
11 * atarim/list-plugins All installed plugins + update info.
12 * atarim/install-plugin Install a free plugin from WordPress.org.
13 * atarim/activate-plugin Activate an installed plugin.
14 * atarim/update-plugin Update an installed plugin to latest.
15 * atarim/deactivate-plugin Deactivate an active plugin.
16 * atarim/delete-plugin Permanently remove a plugin from disk.
17 *
18 * Note: ability names registered here must also be added to the $tools array
19 * in doit/class-avcf-mcp.php::avcf_mcp_setup_server() to be exposed by the
20 * MCP server.
21 *
22 * @package atarim-visual-collaboration
23 */
24
25 if ( ! defined('ABSPATH') ) {
26 exit;
27 }
28
29 class AVCF_Abilities_Plugins extends AVCF_Abilities_Base {
30
31 /**
32 * Register all plugin management abilities.
33 * Called from AVCF_MCP::avcf_mcp_register_abilities() on wp_abilities_api_init.
34 */
35 public function register() {
36 // Ensure plugin functions are available in non-admin contexts (MCP requests).
37 if ( ! function_exists( 'get_plugins' ) ) {
38 require_once ABSPATH . 'wp-admin/includes/plugin.php';
39 }
40
41 // ---- list-plugins ----
42 wp_register_ability( 'atarim/list-plugins', [
43 'label' => 'List Plugins',
44 'description' => 'Returns all installed WordPress plugins with their status, version, author, and update availability.',
45 'category' => 'atarim',
46 'input_schema' => [
47 'type' => 'object',
48 'properties' => [
49 'status' => [
50 'type' => 'string',
51 'description' => 'Filter by activation status. Omit for all.',
52 'enum' => [ 'active', 'inactive', 'all' ],
53 'default' => 'all',
54 ],
55 ],
56 'additionalProperties' => false,
57 ],
58 'output_schema' => [
59 'type' => 'object',
60 'properties' => [
61 'total' => [ 'type' => 'integer' ],
62 'plugins' => [
63 'type' => 'array',
64 'items' => [
65 'type' => 'object',
66 'properties' => [
67 'slug' => [ 'type' => 'string' ],
68 'plugin_file' => [ 'type' => 'string' ],
69 'name' => [ 'type' => 'string' ],
70 'version' => [ 'type' => 'string' ],
71 'author' => [ 'type' => 'string' ],
72 'description' => [ 'type' => 'string' ],
73 'status' => [ 'type' => 'string' ],
74 'network_active' => [ 'type' => 'boolean' ],
75 'requires_wp' => [ 'type' => 'string' ],
76 'requires_php' => [ 'type' => 'string' ],
77 'update_available' => [ 'type' => 'boolean' ],
78 'new_version' => [ 'type' => 'string' ],
79 ],
80 ],
81 ],
82 ],
83 'required' => [ 'total', 'plugins' ],
84 ],
85 'execute_callback' => function( $input = [] ) {
86 if ( ! function_exists( 'get_plugins' ) ) {
87 require_once ABSPATH . 'wp-admin/includes/plugin.php';
88 }
89
90 $status_filter = isset( $input['status'] ) ? $input['status'] : 'all';
91 $all_plugins = get_plugins();
92 $updates = get_site_transient( 'update_plugins' );
93 $update_list = ( $updates && ! empty( $updates->response ) ) ? $updates->response : [];
94
95 $plugins = [];
96 foreach ( $all_plugins as $plugin_file => $data ) {
97 $is_active = is_plugin_active( $plugin_file );
98 $is_network_active = is_multisite() && is_plugin_active_for_network( $plugin_file );
99 $current_status = $is_active ? 'active' : 'inactive';
100
101 if ( $status_filter !== 'all' && $status_filter !== $current_status ) {
102 continue;
103 }
104
105 $slug = dirname( $plugin_file );
106 if ( $slug === '.' ) {
107 // Single-file plugin
108 $slug = basename( $plugin_file, '.php' );
109 }
110
111 $has_update = isset( $update_list[ $plugin_file ] );
112 $new_version = $has_update ? $update_list[ $plugin_file ]->new_version : '';
113
114 $plugins[] = [
115 'slug' => $slug,
116 'plugin_file' => $plugin_file,
117 'name' => isset( $data['Name'] ) ? $data['Name'] : '',
118 'version' => isset( $data['Version'] ) ? $data['Version'] : '',
119 'author' => isset( $data['Author'] ) ? wp_strip_all_tags( $data['Author'] ) : '',
120 'description' => isset( $data['Description'] ) ? wp_strip_all_tags( $data['Description'] ) : '',
121 'status' => $current_status,
122 'network_active' => $is_network_active,
123 'requires_wp' => isset( $data['RequiresWP'] ) ? (string) $data['RequiresWP'] : '',
124 'requires_php' => isset( $data['RequiresPHP'] ) ? (string) $data['RequiresPHP'] : '',
125 'update_available' => $has_update,
126 'new_version' => $new_version,
127 ];
128 }
129
130 return [
131 'total' => count( $plugins ),
132 'plugins' => $plugins,
133 ];
134 },
135 'permission_callback' => function() {
136 return current_user_can( 'activate_plugins' );
137 },
138 'meta' => [
139 'mcp' => [ 'public' => true, 'type' => 'tool' ],
140 'annotations' => [
141 'readonly' => true,
142 'destructive' => false,
143 'idempotent' => true,
144 ],
145 ],
146 ] );
147
148 // ---- install-plugin (free plugins only, from wordpress.org) ----
149 wp_register_ability( 'atarim/install-plugin', [
150 'label' => 'Install Plugin',
151 'description' => 'Installs a free plugin from the WordPress.org repository by its slug. Does not activate it.',
152 'category' => 'atarim',
153 'input_schema' => [
154 'type' => 'object',
155 'properties' => [
156 'slug' => [
157 'type' => 'string',
158 'description' => 'The WordPress.org plugin slug (e.g. "contact-form-7"). Must exist in the free repository.',
159 'minLength' => 1,
160 ],
161 ],
162 'required' => [ 'slug' ],
163 'additionalProperties' => false,
164 ],
165 'output_schema' => [
166 'type' => 'object',
167 'properties' => [
168 'success' => [ 'type' => 'boolean' ],
169 'slug' => [ 'type' => 'string' ],
170 'plugin_file' => [ 'type' => 'string' ],
171 'message' => [ 'type' => 'string' ],
172 ],
173 'required' => [ 'success', 'slug', 'message' ],
174 ],
175 'execute_callback' => function( $input = [] ) {
176 $slug = isset( $input['slug'] ) ? sanitize_key( $input['slug'] ) : '';
177 if ( empty( $slug ) ) {
178 return [
179 'success' => false,
180 'slug' => '',
181 'plugin_file' => '',
182 'message' => 'Plugin slug is required.',
183 ];
184 }
185
186 require_once ABSPATH . 'wp-admin/includes/file.php';
187 require_once ABSPATH . 'wp-admin/includes/misc.php';
188 require_once ABSPATH . 'wp-admin/includes/plugin.php';
189 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
190 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
191
192 // Query wordpress.org for the plugin — confirms it's a free repo plugin
193 // and gets the verified download_link (signed by w.org).
194 $api = plugins_api( 'plugin_information', [
195 'slug' => $slug,
196 'fields' => [ 'sections' => false ],
197 ] );
198
199 if ( is_wp_error( $api ) ) {
200 return [
201 'success' => false,
202 'slug' => $slug,
203 'plugin_file' => '',
204 'message' => 'Plugin not found in WordPress.org repository: ' . $api->get_error_message(),
205 ];
206 }
207
208 if ( empty( $api->download_link ) ) {
209 return [
210 'success' => false,
211 'slug' => $slug,
212 'plugin_file' => '',
213 'message' => 'No download link available — only free WordPress.org plugins are supported.',
214 ];
215 }
216
217 // Silent upgrader skin — no HTML output during MCP request.
218 $skin = new \WP_Ajax_Upgrader_Skin();
219 $upgrader = new \Plugin_Upgrader( $skin );
220 $result = $upgrader->install( $api->download_link );
221
222 if ( is_wp_error( $result ) ) {
223 return [
224 'success' => false,
225 'slug' => $slug,
226 'plugin_file' => '',
227 'message' => 'Install failed: ' . $result->get_error_message(),
228 ];
229 }
230
231 if ( $result === false ) {
232 $skin_errors = $skin->get_errors();
233 $err_msg = is_wp_error( $skin_errors ) && $skin_errors->has_errors()
234 ? $skin_errors->get_error_message()
235 : 'Unknown installer error (filesystem permissions or unavailable updates).';
236 return [
237 'success' => false,
238 'slug' => $slug,
239 'plugin_file' => '',
240 'message' => 'Install failed: ' . $err_msg,
241 ];
242 }
243
244 $plugin_file = $upgrader->plugin_info();
245
246 return [
247 'success' => true,
248 'slug' => $slug,
249 'plugin_file' => $plugin_file ? $plugin_file : '',
250 'message' => 'Plugin installed successfully. Use activate_plugins capability separately to enable it.',
251 ];
252 },
253 'permission_callback' => function() {
254 return current_user_can( 'install_plugins' );
255 },
256 'meta' => [
257 'mcp' => [ 'public' => true, 'type' => 'tool' ],
258 'annotations' => [
259 'readonly' => false,
260 'destructive' => false,
261 'idempotent' => false,
262 ],
263 ],
264 ] );
265
266 // ---- activate-plugin ----
267 wp_register_ability( 'atarim/activate-plugin', [
268 'label' => 'Activate Plugin',
269 'description' => 'Activates an installed (but inactive) plugin by its plugin file path. Use list-plugins to discover the plugin_file, or use the value returned by install-plugin.',
270 'category' => 'atarim',
271 'input_schema' => [
272 'type' => 'object',
273 'properties' => [
274 'plugin_file' => [
275 'type' => 'string',
276 'description' => 'Plugin file path relative to the plugins directory (e.g. "akismet/akismet.php").',
277 'minLength' => 1,
278 ],
279 'network_wide' => [
280 'type' => 'boolean',
281 'description' => 'On multisite, activate network-wide instead of for the current site. Ignored on single-site installs.',
282 'default' => false,
283 ],
284 ],
285 'required' => [ 'plugin_file' ],
286 'additionalProperties' => false,
287 ],
288 'output_schema' => [
289 'type' => 'object',
290 'properties' => [
291 'success' => [ 'type' => 'boolean' ],
292 'plugin_file' => [ 'type' => 'string' ],
293 'network_active' => [ 'type' => 'boolean' ],
294 'message' => [ 'type' => 'string' ],
295 ],
296 'required' => [ 'success', 'plugin_file', 'message' ],
297 ],
298 'execute_callback' => function( $input = [] ) {
299 if ( ! function_exists( 'activate_plugin' ) ) {
300 require_once ABSPATH . 'wp-admin/includes/plugin.php';
301 }
302
303 $plugin_file = isset( $input['plugin_file'] ) ? $input['plugin_file'] : '';
304 $plugin_file = ltrim( str_replace( [ '..', '\\' ], '', $plugin_file ), '/' );
305
306 if ( empty( $plugin_file ) ) {
307 return [
308 'success' => false,
309 'plugin_file' => '',
310 'network_active' => false,
311 'message' => 'plugin_file is required.',
312 ];
313 }
314
315 $network_wide = ! empty( $input['network_wide'] ) && is_multisite();
316
317 $all_plugins = get_plugins();
318 if ( ! isset( $all_plugins[ $plugin_file ] ) ) {
319 return [
320 'success' => false,
321 'plugin_file' => $plugin_file,
322 'network_active' => false,
323 'message' => 'Plugin not installed.',
324 ];
325 }
326
327 // Hard-fail compatibility checks — mirrors the activate-theme behaviour.
328 // WordPress core also performs these checks in newer versions, but doing
329 // them here means we return a useful structured error to the AI caller
330 // regardless of the WP version on the host site.
331 $plugin_data = $all_plugins[ $plugin_file ];
332 $requires_wp = isset( $plugin_data['RequiresWP'] ) ? (string) $plugin_data['RequiresWP'] : '';
333 $requires_php = isset( $plugin_data['RequiresPHP'] ) ? (string) $plugin_data['RequiresPHP'] : '';
334
335 if ( $requires_wp !== '' ) {
336 global $wp_version;
337 if ( version_compare( $wp_version, $requires_wp, '<' ) ) {
338 return [
339 'success' => false,
340 'plugin_file' => $plugin_file,
341 'network_active' => false,
342 'message' => sprintf(
343 'Plugin requires WordPress %s; this site runs %s. Update WordPress before activating.',
344 $requires_wp,
345 $wp_version
346 ),
347 ];
348 }
349 }
350
351 if ( $requires_php !== '' ) {
352 if ( version_compare( PHP_VERSION, $requires_php, '<' ) ) {
353 return [
354 'success' => false,
355 'plugin_file' => $plugin_file,
356 'network_active' => false,
357 'message' => sprintf(
358 'Plugin requires PHP %s; this site runs %s. Upgrade PHP before activating.',
359 $requires_php,
360 PHP_VERSION
361 ),
362 ];
363 }
364 }
365
366 if ( is_plugin_active( $plugin_file ) && ! $network_wide ) {
367 return [
368 'success' => false,
369 'plugin_file' => $plugin_file,
370 'network_active' => is_multisite() && is_plugin_active_for_network( $plugin_file ),
371 'message' => 'Plugin is already active.',
372 ];
373 }
374
375 if ( $network_wide && is_plugin_active_for_network( $plugin_file ) ) {
376 return [
377 'success' => false,
378 'plugin_file' => $plugin_file,
379 'network_active' => true,
380 'message' => 'Plugin is already network-active.',
381 ];
382 }
383
384 // activate_plugin() runs the plugin's activation hook and may produce
385 // output if the plugin is buggy. Suppress to keep the MCP response clean.
386 // Returns null on success, WP_Error on failure, or a WP_Error if the
387 // plugin triggered a fatal error during activation.
388 $silent = false;
389 $result = activate_plugin( $plugin_file, '', $network_wide, $silent );
390
391 if ( is_wp_error( $result ) ) {
392 return [
393 'success' => false,
394 'plugin_file' => $plugin_file,
395 'network_active' => false,
396 'message' => 'Activation failed: ' . $result->get_error_message(),
397 ];
398 }
399
400 // Re-check; activate_plugin returns null on success but doesn't guarantee state.
401 $now_active = is_plugin_active( $plugin_file );
402 $now_network_active = is_multisite() && is_plugin_active_for_network( $plugin_file );
403
404 return [
405 'success' => $now_active,
406 'plugin_file' => $plugin_file,
407 'network_active' => $now_network_active,
408 'message' => $now_active ? 'Plugin activated.' : 'Activation completed but plugin is not active — check for activation errors.',
409 ];
410 },
411 'permission_callback' => function() {
412 return current_user_can( 'activate_plugins' );
413 },
414 'meta' => [
415 'mcp' => [ 'public' => true, 'type' => 'tool' ],
416 'annotations' => [
417 'readonly' => false,
418 'destructive' => false,
419 'idempotent' => true,
420 ],
421 ],
422 ] );
423
424 // ---- update-plugin ----
425 wp_register_ability( 'atarim/update-plugin', [
426 'label' => 'Update Plugin',
427 'description' => 'Updates an installed plugin to the latest available version. Works with both free WordPress.org plugins and paid/third-party plugins that report updates through their own update server. Fails if no update is available.',
428 'category' => 'atarim',
429 'input_schema' => [
430 'type' => 'object',
431 'properties' => [
432 'plugin_file' => [
433 'type' => 'string',
434 'description' => 'Plugin file path relative to the plugins directory (e.g. "akismet/akismet.php"). Use list-plugins to discover this value.',
435 'minLength' => 1,
436 ],
437 ],
438 'required' => [ 'plugin_file' ],
439 'additionalProperties' => false,
440 ],
441 'output_schema' => [
442 'type' => 'object',
443 'properties' => [
444 'success' => [ 'type' => 'boolean' ],
445 'plugin_file' => [ 'type' => 'string' ],
446 'previous_version' => [ 'type' => 'string' ],
447 'new_version' => [ 'type' => 'string' ],
448 'was_active' => [ 'type' => 'boolean', 'description' => 'Whether the plugin was active before the update.' ],
449 'is_active' => [ 'type' => 'boolean', 'description' => 'Whether the plugin is active after the update. If was_active is true and this is false, the plugin was left switched off.' ],
450 'message' => [ 'type' => 'string' ],
451 ],
452 'required' => [ 'success', 'plugin_file', 'message' ],
453 ],
454 'execute_callback' => function( $input = [] ) {
455 if ( ! function_exists( 'get_plugins' ) ) {
456 require_once ABSPATH . 'wp-admin/includes/plugin.php';
457 }
458 require_once ABSPATH . 'wp-admin/includes/file.php';
459 require_once ABSPATH . 'wp-admin/includes/misc.php';
460 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
461
462 $plugin_file = isset( $input['plugin_file'] ) ? $input['plugin_file'] : '';
463 $plugin_file = ltrim( str_replace( [ '..', '\\' ], '', $plugin_file ), '/' );
464
465 if ( empty( $plugin_file ) ) {
466 return [
467 'success' => false,
468 'plugin_file' => '',
469 'previous_version' => '',
470 'new_version' => '',
471 'message' => 'plugin_file is required.',
472 ];
473 }
474
475 $all_plugins = get_plugins();
476 if ( ! isset( $all_plugins[ $plugin_file ] ) ) {
477 return [
478 'success' => false,
479 'plugin_file' => $plugin_file,
480 'previous_version' => '',
481 'new_version' => '',
482 'message' => 'Plugin not installed.',
483 ];
484 }
485
486 $current_version = isset( $all_plugins[ $plugin_file ]['Version'] ) ? $all_plugins[ $plugin_file ]['Version'] : '';
487
488 // WordPress can leave a plugin deactivated after an upgrade. Record the
489 // state up front so it can be restored below, rather than reporting
490 // success while the site quietly loses the plugin.
491 $was_active = is_plugin_active( $plugin_file );
492 $was_network_active = is_multisite() && is_plugin_active_for_network( $plugin_file );
493
494 // Force a fresh update check so we don't act on stale transient data.
495 // wp_update_plugins() makes a remote call to api.wordpress.org for free plugins
496 // and triggers third-party update-checker hooks for paid plugins.
497 wp_update_plugins();
498
499 $updates = get_site_transient( 'update_plugins' );
500 $update_list = ( $updates && ! empty( $updates->response ) ) ? $updates->response : [];
501
502 if ( ! isset( $update_list[ $plugin_file ] ) ) {
503 return [
504 'success' => false,
505 'plugin_file' => $plugin_file,
506 'previous_version' => $current_version,
507 'new_version' => '',
508 'message' => 'No update available for this plugin.',
509 ];
510 }
511
512 $new_version = isset( $update_list[ $plugin_file ]->new_version ) ? $update_list[ $plugin_file ]->new_version : '';
513
514 // Filesystem credentials check — same pattern as delete-plugin.
515 ob_start();
516 $creds_ok = WP_Filesystem();
517 ob_end_clean();
518
519 if ( ! $creds_ok ) {
520 return [
521 'success' => false,
522 'plugin_file' => $plugin_file,
523 'previous_version' => $current_version,
524 'new_version' => $new_version,
525 'message' => 'Could not initialize filesystem — server may require FTP credentials.',
526 ];
527 }
528
529 // Silent upgrader skin — no HTML output during MCP request.
530 $skin = new \WP_Ajax_Upgrader_Skin();
531 $upgrader = new \Plugin_Upgrader( $skin );
532
533 // Plugin_Upgrader::upgrade() pulls the download URL from the update_plugins
534 // transient — works identically for free and paid plugins as long as the
535 // update was registered there.
536 $result = $upgrader->upgrade( $plugin_file );
537
538 // Restore the pre-upgrade activation state before reporting back.
539 $reactivation_failed = false;
540 if ( $was_active && ! is_wp_error( $result ) && $result !== false && ! is_plugin_active( $plugin_file ) ) {
541 $activation = activate_plugin( $plugin_file, '', $was_network_active, true );
542 $reactivation_failed = is_wp_error( $activation );
543 }
544
545 if ( is_wp_error( $result ) ) {
546 return [
547 'success' => false,
548 'plugin_file' => $plugin_file,
549 'previous_version' => $current_version,
550 'new_version' => $new_version,
551 'message' => 'Update failed: ' . $result->get_error_message(),
552 ];
553 }
554
555 if ( $result === false ) {
556 $skin_errors = $skin->get_errors();
557 $err_msg = is_wp_error( $skin_errors ) && $skin_errors->has_errors()
558 ? $skin_errors->get_error_message()
559 : 'Unknown upgrader error.';
560 return [
561 'success' => false,
562 'plugin_file' => $plugin_file,
563 'previous_version' => $current_version,
564 'new_version' => $new_version,
565 'message' => 'Update failed: ' . $err_msg,
566 ];
567 }
568
569 // Re-read plugin headers to confirm the actual installed version.
570 $all_plugins_after = get_plugins();
571 $installed_version = isset( $all_plugins_after[ $plugin_file ]['Version'] )
572 ? $all_plugins_after[ $plugin_file ]['Version']
573 : $new_version;
574
575 $is_active_after = is_plugin_active( $plugin_file );
576
577 return [
578 'success' => true,
579 'plugin_file' => $plugin_file,
580 'previous_version' => $current_version,
581 'new_version' => $installed_version,
582 'was_active' => $was_active,
583 'is_active' => $is_active_after,
584 'message' => $was_active && ! $is_active_after
585 ? sprintf(
586 'Plugin updated from %s to %s, but it was left DEACTIVATED and could not be reactivated automatically%s. Reactivate it before relying on the site.',
587 $current_version,
588 $installed_version,
589 $reactivation_failed ? '' : ' (state unexpectedly changed)'
590 )
591 : sprintf( 'Plugin updated from %s to %s.', $current_version, $installed_version ),
592 ];
593 },
594 'permission_callback' => function() {
595 return current_user_can( 'update_plugins' );
596 },
597 'meta' => [
598 'mcp' => [ 'public' => true, 'type' => 'tool' ],
599 'annotations' => [
600 'readonly' => false,
601 'destructive' => true,
602 'idempotent' => false,
603 ],
604 ],
605 ] );
606
607 // ---- deactivate-plugin ----
608 wp_register_ability( 'atarim/deactivate-plugin', [
609 'label' => 'Deactivate Plugin',
610 'description' => 'Deactivates an installed plugin by its plugin file path (e.g. "akismet/akismet.php").',
611 'category' => 'atarim',
612 'input_schema' => [
613 'type' => 'object',
614 'properties' => [
615 'plugin_file' => [
616 'type' => 'string',
617 'description' => 'Plugin file path relative to the plugins directory (e.g. "akismet/akismet.php"). Use list-plugins to discover this value.',
618 'minLength' => 1,
619 ],
620 ],
621 'required' => [ 'plugin_file' ],
622 'additionalProperties' => false,
623 ],
624 'output_schema' => [
625 'type' => 'object',
626 'properties' => [
627 'success' => [ 'type' => 'boolean' ],
628 'plugin_file' => [ 'type' => 'string' ],
629 'message' => [ 'type' => 'string' ],
630 ],
631 'required' => [ 'success', 'plugin_file', 'message' ],
632 ],
633 'execute_callback' => function( $input = [] ) {
634 if ( ! function_exists( 'deactivate_plugins' ) ) {
635 require_once ABSPATH . 'wp-admin/includes/plugin.php';
636 }
637
638 $plugin_file = isset( $input['plugin_file'] ) ? $input['plugin_file'] : '';
639 // Light path normalization without losing the forward slash.
640 $plugin_file = ltrim( str_replace( [ '..', '\\' ], '', $plugin_file ), '/' );
641
642 if ( empty( $plugin_file ) ) {
643 return [
644 'success' => false,
645 'plugin_file' => '',
646 'message' => 'plugin_file is required.',
647 ];
648 }
649
650 $all_plugins = get_plugins();
651 if ( ! isset( $all_plugins[ $plugin_file ] ) ) {
652 return [
653 'success' => false,
654 'plugin_file' => $plugin_file,
655 'message' => 'Plugin not installed.',
656 ];
657 }
658
659 if ( ! is_plugin_active( $plugin_file ) ) {
660 return [
661 'success' => false,
662 'plugin_file' => $plugin_file,
663 'message' => 'Plugin is already inactive.',
664 ];
665 }
666
667 // Guard against self-deactivation — would break the very request handling this call.
668 if ( $plugin_file === AVCF_PLUGIN_BASE ) {
669 return [
670 'success' => false,
671 'plugin_file' => $plugin_file,
672 'message' => 'Cannot deactivate the Atarim plugin via MCP.',
673 ];
674 }
675
676 deactivate_plugins( $plugin_file );
677
678 // deactivate_plugins() returns void; re-check.
679 $still_active = is_plugin_active( $plugin_file );
680
681 return [
682 'success' => ! $still_active,
683 'plugin_file' => $plugin_file,
684 'message' => $still_active ? 'Deactivation failed.' : 'Plugin deactivated.',
685 ];
686 },
687 'permission_callback' => function() {
688 return current_user_can( 'deactivate_plugins' );
689 },
690 'meta' => [
691 'mcp' => [ 'public' => true, 'type' => 'tool' ],
692 'annotations' => [
693 'readonly' => false,
694 'destructive' => true,
695 'idempotent' => true,
696 ],
697 ],
698 ] );
699
700 // ---- delete-plugin ----
701 wp_register_ability( 'atarim/delete-plugin', [
702 'label' => 'Delete Plugin',
703 'description' => 'Permanently deletes an installed plugin from disk. Plugin must be deactivated first.',
704 'category' => 'atarim',
705 'input_schema' => [
706 'type' => 'object',
707 'properties' => [
708 'plugin_file' => [
709 'type' => 'string',
710 'description' => 'Plugin file path relative to the plugins directory (e.g. "akismet/akismet.php").',
711 'minLength' => 1,
712 ],
713 ],
714 'required' => [ 'plugin_file' ],
715 'additionalProperties' => false,
716 ],
717 'output_schema' => [
718 'type' => 'object',
719 'properties' => [
720 'success' => [ 'type' => 'boolean' ],
721 'plugin_file' => [ 'type' => 'string' ],
722 'message' => [ 'type' => 'string' ],
723 ],
724 'required' => [ 'success', 'plugin_file', 'message' ],
725 ],
726 'execute_callback' => function( $input = [] ) {
727 if ( ! function_exists( 'delete_plugins' ) ) {
728 require_once ABSPATH . 'wp-admin/includes/plugin.php';
729 }
730 require_once ABSPATH . 'wp-admin/includes/file.php';
731
732 $plugin_file = isset( $input['plugin_file'] ) ? $input['plugin_file'] : '';
733 $plugin_file = ltrim( str_replace( [ '..', '\\' ], '', $plugin_file ), '/' );
734
735 if ( empty( $plugin_file ) ) {
736 return [
737 'success' => false,
738 'plugin_file' => '',
739 'message' => 'plugin_file is required.',
740 ];
741 }
742
743 if ( $plugin_file === AVCF_PLUGIN_BASE ) {
744 return [
745 'success' => false,
746 'plugin_file' => $plugin_file,
747 'message' => 'Cannot delete the Atarim plugin via MCP.',
748 ];
749 }
750
751 $all_plugins = get_plugins();
752 if ( ! isset( $all_plugins[ $plugin_file ] ) ) {
753 return [
754 'success' => false,
755 'plugin_file' => $plugin_file,
756 'message' => 'Plugin not installed.',
757 ];
758 }
759
760 if ( is_plugin_active( $plugin_file ) ) {
761 return [
762 'success' => false,
763 'plugin_file' => $plugin_file,
764 'message' => 'Plugin is currently active. Deactivate it before deleting.',
765 ];
766 }
767
768 // delete_plugins() needs filesystem credentials; request them silently.
769 // On direct/ssh/ftpext methods with stored creds this works; otherwise it fails cleanly.
770 ob_start();
771 $creds_ok = WP_Filesystem();
772 ob_end_clean();
773
774 if ( ! $creds_ok ) {
775 return [
776 'success' => false,
777 'plugin_file' => $plugin_file,
778 'message' => 'Could not initialize filesystem — server may require FTP credentials.',
779 ];
780 }
781
782 $result = delete_plugins( [ $plugin_file ] );
783
784 if ( is_wp_error( $result ) ) {
785 return [
786 'success' => false,
787 'plugin_file' => $plugin_file,
788 'message' => 'Delete failed: ' . $result->get_error_message(),
789 ];
790 }
791
792 if ( $result === false || $result === null ) {
793 return [
794 'success' => false,
795 'plugin_file' => $plugin_file,
796 'message' => 'Delete failed (filesystem error).',
797 ];
798 }
799
800 return [
801 'success' => true,
802 'plugin_file' => $plugin_file,
803 'message' => 'Plugin deleted.',
804 ];
805 },
806 'permission_callback' => function() {
807 return current_user_can( 'delete_plugins' );
808 },
809 'meta' => [
810 'mcp' => [ 'public' => true, 'type' => 'tool' ],
811 'annotations' => [
812 'readonly' => false,
813 'destructive' => true,
814 'idempotent' => false,
815 ],
816 ],
817 ] );
818 }
819 }
820