PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / abilities / zipai / system / plugin-resolver.php

plugin-resolver.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at inc/abilities/zipai/system/plugin-resolver.php

146 lines 5.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared slug → plugin-file resolver for the plugin-lifecycle abilities.
4 *
5 * Activate / Deactivate / Delete all need to map an LLM-supplied slug
6 * (or `folder/file` identifier) to the actual `plugin_file` key
7 * WordPress core uses internally. Centralise the matching rules so
8 * the four abilities agree on what shapes they accept and how the
9 * lookup falls back.
10 *
11 * Accepted input shapes:
12 * - bare folder slug: "contact-form-7"
13 * - WP REST plugin id (no extension): "contact-form-7/wp-contact-form-7"
14 * - WP REST plugin id (with .php): "contact-form-7/wp-contact-form-7.php"
15 *
16 * @since 0.0.5
17 * @package zip-ai
18 */
19
20 namespace ZipAI\MCP\Classes\Abilities\Zipai\System;
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit;
24 }
25
26 class PluginResolver {
27
28 /**
29 * Whether a resolved plugin file is ZIP AI itself — the plugin serving
30 * this very MCP request. Lifecycle abilities refuse to deactivate or
31 * delete it: doing so severs the assistant's own transport (chat, tools,
32 * and the in-flight session) — observed live 2026-08-06, where a bulk
33 * "deactivate the rest" sweep took the agent down mid-plan. Toggling
34 * ZIP AI off is supported only from wp-admin → Plugins, outside MCP.
35 *
36 * @param string $plugin_file Canonical `folder/file.php` key.
37 * @return bool
38 */
39 public static function is_self( $plugin_file ) {
40 return plugin_basename( ZIPAI_MCP_FILE ) === $plugin_file;
41 }
42
43 /**
44 * Resolve a slug-shaped identifier to the actual `folder/file.php`
45 * key WP core uses for installed plugins. Returns null when no
46 * installed plugin matches.
47 *
48 * Callers MUST `require_once ABSPATH . 'wp-admin/includes/plugin.php'`
49 * before invoking — `get_plugins()` is a wp-admin function.
50 *
51 * @param string $slug Slug, `folder/file`, or `folder/file.php`.
52 * @return string|null `folder/file.php` key, or null if not installed.
53 */
54 public static function resolve_plugin_file( $slug ) {
55 $slug = (string) $slug;
56 $plugins = function_exists( 'get_plugins' ) ? get_plugins() : array();
57
58 if ( array() === $plugins ) {
59 return null;
60 }
61
62 // Direct match: caller already provided the canonical key.
63 if ( isset( $plugins[ $slug ] ) ) {
64 return $slug;
65 }
66
67 // Caller provided `folder/file` without `.php` — try appending.
68 if ( isset( $plugins[ $slug . '.php' ] ) ) {
69 return $slug . '.php';
70 }
71
72 // Bare-slug match against the folder component of every key. Root-level
73 // single-file plugins are eligible here — "the user named this file" is
74 // the intent for activate/deactivate/delete.
75 return self::match_folder_slug( $plugins, $slug, false );
76 }
77
78 /**
79 * Resolve a bare WordPress.org folder slug to its installed
80 * `folder/file.php` key, matching ONLY the folder component.
81 *
82 * Unlike resolve_plugin_file(), this does NOT match a root-level
83 * single-file plugin whose bare filename equals the slug (e.g. a
84 * `redirects.php` snippet for slug "redirects"). PluginInstall needs proof
85 * that the wp.org *folder-slug* plugin is present; a bare-filename match is
86 * a false positive there — it would report an unrelated file as the plugin
87 * and, with `status:active`, activate it. Activate / deactivate / delete
88 * keep resolve_plugin_file(), where "the user named this file" is the
89 * intent and a root-file match is correct.
90 *
91 * Callers MUST `require_once ABSPATH . 'wp-admin/includes/plugin.php'`
92 * before invoking — `get_plugins()` is a wp-admin function.
93 *
94 * @param string $slug Bare folder slug (e.g. "sureforms").
95 * @return string|null `folder/file.php` key, or null if not installed.
96 */
97 public static function resolve_installed_folder( $slug ) {
98 $plugins = function_exists( 'get_plugins' ) ? get_plugins() : array();
99
100 // Skip root-level single-file plugins — PluginInstall needs proof the
101 // wp.org *folder-slug* plugin is present, so a bare-filename hit is a
102 // false positive here.
103 return self::match_folder_slug( $plugins, (string) $slug, true );
104 }
105
106 /**
107 * Return the first installed plugin key whose folder component (case-
108 * insensitively) equals $slug, or null. When $skip_root is true, root-level
109 * single-file plugins (no folder, e.g. "hello.php") are ignored.
110 *
111 * @param array<array<mixed>> $plugins get_plugins() result.
112 * @param string $slug Bare folder slug.
113 * @param bool $skip_root Whether to skip root-level single-file plugins.
114 * @return string|null `folder/file.php` key, or null if no match.
115 */
116 private static function match_folder_slug( $plugins, $slug, $skip_root ) {
117 // Prefer the conventional main file (`slug/slug.php`) — a folder shipping
118 // two files with plugin headers otherwise resolves to whichever
119 // get_plugins() lists first, and activate/deactivate hits the wrong one.
120 $lower_slug = strtolower( $slug );
121 $fallback = null;
122 foreach ( $plugins as $file => $_data ) {
123 $file = (string) $file;
124 if ( false === strpos( $file, '/' ) ) {
125 if ( $skip_root ) {
126 continue;
127 }
128 $folder = $file;
129 } else {
130 $folder = explode( '/', $file, 2 )[0];
131 }
132 if ( '' === $folder || strtolower( $folder ) !== $lower_slug ) {
133 continue;
134 }
135 if ( strtolower( $file ) === $lower_slug . '/' . $lower_slug . '.php' ) {
136 return $file;
137 }
138 if ( null === $fallback ) {
139 $fallback = $file;
140 }
141 }
142
143 return $fallback;
144 }
145 }
146