PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / 5.1
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback v5.1
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 3 weeks ago class-avcf-abilities-block-navigation.php 3 weeks ago class-avcf-abilities-cache.php 3 weeks ago class-avcf-abilities-content.php 3 weeks ago class-avcf-abilities-core.php 3 weeks ago class-avcf-abilities-global-styles.php 3 weeks ago class-avcf-abilities-gutenberg.php 3 weeks ago class-avcf-abilities-media.php 3 weeks ago class-avcf-abilities-metadata.php 3 weeks ago class-avcf-abilities-navigation.php 3 weeks ago class-avcf-abilities-patterns.php 3 weeks ago class-avcf-abilities-plugins.php 3 weeks ago class-avcf-abilities-settings.php 3 weeks ago class-avcf-abilities-taxonomies.php 3 weeks ago class-avcf-abilities-templates.php 3 weeks ago class-avcf-abilities-theme-files.php 3 weeks ago class-avcf-abilities-themes.php 3 weeks ago class-avcf-abilities-users.php 3 weeks ago
class-avcf-abilities-plugins.php
794 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 'message' => [ 'type' => 'string' ],
449 ],
450 'required' => [ 'success', 'plugin_file', 'message' ],
451 ],
452 'execute_callback' => function( $input = [] ) {
453 if ( ! function_exists( 'get_plugins' ) ) {
454 require_once ABSPATH . 'wp-admin/includes/plugin.php';
455 }
456 require_once ABSPATH . 'wp-admin/includes/file.php';
457 require_once ABSPATH . 'wp-admin/includes/misc.php';
458 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
459
460 $plugin_file = isset( $input['plugin_file'] ) ? $input['plugin_file'] : '';
461 $plugin_file = ltrim( str_replace( [ '..', '\\' ], '', $plugin_file ), '/' );
462
463 if ( empty( $plugin_file ) ) {
464 return [
465 'success' => false,
466 'plugin_file' => '',
467 'previous_version' => '',
468 'new_version' => '',
469 'message' => 'plugin_file is required.',
470 ];
471 }
472
473 $all_plugins = get_plugins();
474 if ( ! isset( $all_plugins[ $plugin_file ] ) ) {
475 return [
476 'success' => false,
477 'plugin_file' => $plugin_file,
478 'previous_version' => '',
479 'new_version' => '',
480 'message' => 'Plugin not installed.',
481 ];
482 }
483
484 $current_version = isset( $all_plugins[ $plugin_file ]['Version'] ) ? $all_plugins[ $plugin_file ]['Version'] : '';
485
486 // Force a fresh update check so we don't act on stale transient data.
487 // wp_update_plugins() makes a remote call to api.wordpress.org for free plugins
488 // and triggers third-party update-checker hooks for paid plugins.
489 wp_update_plugins();
490
491 $updates = get_site_transient( 'update_plugins' );
492 $update_list = ( $updates && ! empty( $updates->response ) ) ? $updates->response : [];
493
494 if ( ! isset( $update_list[ $plugin_file ] ) ) {
495 return [
496 'success' => false,
497 'plugin_file' => $plugin_file,
498 'previous_version' => $current_version,
499 'new_version' => '',
500 'message' => 'No update available for this plugin.',
501 ];
502 }
503
504 $new_version = isset( $update_list[ $plugin_file ]->new_version ) ? $update_list[ $plugin_file ]->new_version : '';
505
506 // Filesystem credentials check — same pattern as delete-plugin.
507 ob_start();
508 $creds_ok = WP_Filesystem();
509 ob_end_clean();
510
511 if ( ! $creds_ok ) {
512 return [
513 'success' => false,
514 'plugin_file' => $plugin_file,
515 'previous_version' => $current_version,
516 'new_version' => $new_version,
517 'message' => 'Could not initialize filesystem — server may require FTP credentials.',
518 ];
519 }
520
521 // Silent upgrader skin — no HTML output during MCP request.
522 $skin = new \WP_Ajax_Upgrader_Skin();
523 $upgrader = new \Plugin_Upgrader( $skin );
524
525 // Plugin_Upgrader::upgrade() pulls the download URL from the update_plugins
526 // transient — works identically for free and paid plugins as long as the
527 // update was registered there.
528 $result = $upgrader->upgrade( $plugin_file );
529
530 if ( is_wp_error( $result ) ) {
531 return [
532 'success' => false,
533 'plugin_file' => $plugin_file,
534 'previous_version' => $current_version,
535 'new_version' => $new_version,
536 'message' => 'Update failed: ' . $result->get_error_message(),
537 ];
538 }
539
540 if ( $result === false ) {
541 $skin_errors = $skin->get_errors();
542 $err_msg = is_wp_error( $skin_errors ) && $skin_errors->has_errors()
543 ? $skin_errors->get_error_message()
544 : 'Unknown upgrader error.';
545 return [
546 'success' => false,
547 'plugin_file' => $plugin_file,
548 'previous_version' => $current_version,
549 'new_version' => $new_version,
550 'message' => 'Update failed: ' . $err_msg,
551 ];
552 }
553
554 // Re-read plugin headers to confirm the actual installed version.
555 $all_plugins_after = get_plugins();
556 $installed_version = isset( $all_plugins_after[ $plugin_file ]['Version'] )
557 ? $all_plugins_after[ $plugin_file ]['Version']
558 : $new_version;
559
560 return [
561 'success' => true,
562 'plugin_file' => $plugin_file,
563 'previous_version' => $current_version,
564 'new_version' => $installed_version,
565 'message' => sprintf( 'Plugin updated from %s to %s.', $current_version, $installed_version ),
566 ];
567 },
568 'permission_callback' => function() {
569 return current_user_can( 'update_plugins' );
570 },
571 'meta' => [
572 'mcp' => [ 'public' => true, 'type' => 'tool' ],
573 'annotations' => [
574 'readonly' => false,
575 'destructive' => false,
576 'idempotent' => false,
577 ],
578 ],
579 ] );
580
581 // ---- deactivate-plugin ----
582 wp_register_ability( 'atarim/deactivate-plugin', [
583 'label' => 'Deactivate Plugin',
584 'description' => 'Deactivates an installed plugin by its plugin file path (e.g. "akismet/akismet.php").',
585 'category' => 'atarim',
586 'input_schema' => [
587 'type' => 'object',
588 'properties' => [
589 'plugin_file' => [
590 'type' => 'string',
591 'description' => 'Plugin file path relative to the plugins directory (e.g. "akismet/akismet.php"). Use list-plugins to discover this value.',
592 'minLength' => 1,
593 ],
594 ],
595 'required' => [ 'plugin_file' ],
596 'additionalProperties' => false,
597 ],
598 'output_schema' => [
599 'type' => 'object',
600 'properties' => [
601 'success' => [ 'type' => 'boolean' ],
602 'plugin_file' => [ 'type' => 'string' ],
603 'message' => [ 'type' => 'string' ],
604 ],
605 'required' => [ 'success', 'plugin_file', 'message' ],
606 ],
607 'execute_callback' => function( $input = [] ) {
608 if ( ! function_exists( 'deactivate_plugins' ) ) {
609 require_once ABSPATH . 'wp-admin/includes/plugin.php';
610 }
611
612 $plugin_file = isset( $input['plugin_file'] ) ? $input['plugin_file'] : '';
613 // Light path normalization without losing the forward slash.
614 $plugin_file = ltrim( str_replace( [ '..', '\\' ], '', $plugin_file ), '/' );
615
616 if ( empty( $plugin_file ) ) {
617 return [
618 'success' => false,
619 'plugin_file' => '',
620 'message' => 'plugin_file is required.',
621 ];
622 }
623
624 $all_plugins = get_plugins();
625 if ( ! isset( $all_plugins[ $plugin_file ] ) ) {
626 return [
627 'success' => false,
628 'plugin_file' => $plugin_file,
629 'message' => 'Plugin not installed.',
630 ];
631 }
632
633 if ( ! is_plugin_active( $plugin_file ) ) {
634 return [
635 'success' => false,
636 'plugin_file' => $plugin_file,
637 'message' => 'Plugin is already inactive.',
638 ];
639 }
640
641 // Guard against self-deactivation — would break the very request handling this call.
642 if ( $plugin_file === AVCF_PLUGIN_BASE ) {
643 return [
644 'success' => false,
645 'plugin_file' => $plugin_file,
646 'message' => 'Cannot deactivate the Atarim plugin via MCP.',
647 ];
648 }
649
650 deactivate_plugins( $plugin_file );
651
652 // deactivate_plugins() returns void; re-check.
653 $still_active = is_plugin_active( $plugin_file );
654
655 return [
656 'success' => ! $still_active,
657 'plugin_file' => $plugin_file,
658 'message' => $still_active ? 'Deactivation failed.' : 'Plugin deactivated.',
659 ];
660 },
661 'permission_callback' => function() {
662 return current_user_can( 'deactivate_plugins' );
663 },
664 'meta' => [
665 'mcp' => [ 'public' => true, 'type' => 'tool' ],
666 'annotations' => [
667 'readonly' => false,
668 'destructive' => false,
669 'idempotent' => true,
670 ],
671 ],
672 ] );
673
674 // ---- delete-plugin ----
675 wp_register_ability( 'atarim/delete-plugin', [
676 'label' => 'Delete Plugin',
677 'description' => 'Permanently deletes an installed plugin from disk. Plugin must be deactivated first.',
678 'category' => 'atarim',
679 'input_schema' => [
680 'type' => 'object',
681 'properties' => [
682 'plugin_file' => [
683 'type' => 'string',
684 'description' => 'Plugin file path relative to the plugins directory (e.g. "akismet/akismet.php").',
685 'minLength' => 1,
686 ],
687 ],
688 'required' => [ 'plugin_file' ],
689 'additionalProperties' => false,
690 ],
691 'output_schema' => [
692 'type' => 'object',
693 'properties' => [
694 'success' => [ 'type' => 'boolean' ],
695 'plugin_file' => [ 'type' => 'string' ],
696 'message' => [ 'type' => 'string' ],
697 ],
698 'required' => [ 'success', 'plugin_file', 'message' ],
699 ],
700 'execute_callback' => function( $input = [] ) {
701 if ( ! function_exists( 'delete_plugins' ) ) {
702 require_once ABSPATH . 'wp-admin/includes/plugin.php';
703 }
704 require_once ABSPATH . 'wp-admin/includes/file.php';
705
706 $plugin_file = isset( $input['plugin_file'] ) ? $input['plugin_file'] : '';
707 $plugin_file = ltrim( str_replace( [ '..', '\\' ], '', $plugin_file ), '/' );
708
709 if ( empty( $plugin_file ) ) {
710 return [
711 'success' => false,
712 'plugin_file' => '',
713 'message' => 'plugin_file is required.',
714 ];
715 }
716
717 if ( $plugin_file === AVCF_PLUGIN_BASE ) {
718 return [
719 'success' => false,
720 'plugin_file' => $plugin_file,
721 'message' => 'Cannot delete the Atarim plugin via MCP.',
722 ];
723 }
724
725 $all_plugins = get_plugins();
726 if ( ! isset( $all_plugins[ $plugin_file ] ) ) {
727 return [
728 'success' => false,
729 'plugin_file' => $plugin_file,
730 'message' => 'Plugin not installed.',
731 ];
732 }
733
734 if ( is_plugin_active( $plugin_file ) ) {
735 return [
736 'success' => false,
737 'plugin_file' => $plugin_file,
738 'message' => 'Plugin is currently active. Deactivate it before deleting.',
739 ];
740 }
741
742 // delete_plugins() needs filesystem credentials; request them silently.
743 // On direct/ssh/ftpext methods with stored creds this works; otherwise it fails cleanly.
744 ob_start();
745 $creds_ok = WP_Filesystem();
746 ob_end_clean();
747
748 if ( ! $creds_ok ) {
749 return [
750 'success' => false,
751 'plugin_file' => $plugin_file,
752 'message' => 'Could not initialize filesystem — server may require FTP credentials.',
753 ];
754 }
755
756 $result = delete_plugins( [ $plugin_file ] );
757
758 if ( is_wp_error( $result ) ) {
759 return [
760 'success' => false,
761 'plugin_file' => $plugin_file,
762 'message' => 'Delete failed: ' . $result->get_error_message(),
763 ];
764 }
765
766 if ( $result === false || $result === null ) {
767 return [
768 'success' => false,
769 'plugin_file' => $plugin_file,
770 'message' => 'Delete failed (filesystem error).',
771 ];
772 }
773
774 return [
775 'success' => true,
776 'plugin_file' => $plugin_file,
777 'message' => 'Plugin deleted.',
778 ];
779 },
780 'permission_callback' => function() {
781 return current_user_can( 'delete_plugins' );
782 },
783 'meta' => [
784 'mcp' => [ 'public' => true, 'type' => 'tool' ],
785 'annotations' => [
786 'readonly' => false,
787 'destructive' => true,
788 'idempotent' => false,
789 ],
790 ],
791 ] );
792 }
793 }
794