PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.12
ZIP AI – AI Website Builder & AI Agent v0.0.12
0.0.13 0.0.12 0.0.11 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 / core / run-wp-cli.php

run-wp-cli.php in ZIP AI – AI Website Builder & AI Agent 0.0.12, at inc/abilities/core/run-wp-cli.php

3,499 lines 128.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Run WP-CLI Command Ability.
4 *
5 * Dispatch strategy:
6 * 1. The code runs inside a WP-CLI process (`defined( 'WP_CLI' )`).
7 * It delegates to `\WP_CLI::runcommand()`. This gives full WP-CLI
8 * support in-process.
9 * 2. The code runs in a web, REST or AJAX context.
10 * It dispatches the command to a native WordPress PHP function.
11 * The allowlist below selects the function. It uses no subprocess,
12 * no shell, no `proc_open` and no `eval`.
13 *
14 * The native dispatcher covers the WP-CLI commands an AI agent uses to
15 * manage a site (plugin, theme, option, post, user, menu, cron, cache,
16 * transient, rewrite, core, language). A command outside the allowlist
17 * returns a clear error.
18 *
19 * ## Authentication and authorisation chain
20 *
21 * A caller must pass every layer below to reach `execute()`:
22 *
23 * 1. The MCP REST endpoint `/zip-ai/v1/mcp` gate
24 * (`REST_API::check_permission`). It accepts a valid Bearer token.
25 * It matches the token with `hash_equals` against the decrypted
26 * stored auth token. It also accepts a logged-in admin
27 * (`current_user_can( 'manage_options' )`).
28 * 2. `setup_user_context()` resolves `X-WP-User-ID` into a WP user on
29 * the Bearer path. So `current_user_can()` inside the ability
30 * shows the real capabilities of the acting user.
31 * 3. The Abilities API permission_callback
32 * (`Abstract_Ability::check_permission`) checks
33 * `current_user_can( manage_options )` again. It runs this before it
34 * routes to `handle_execute()`.
35 * 4. This class's `execute()` adds more checks. It runs a second
36 * `current_user_can()` check. It runs an explicit `is_super_admin()`
37 * check on multisite. It runs `verify_command_security()` before
38 * dispatch.
39 *
40 * `Abstract_Ability::handle_execute` records every successful call. It
41 * writes to the `zip_ai_audit_log` option via `Event_Logger`. The record
42 * holds the user id, the tool id, the sanitised input, the sanitised
43 * output and a timestamp.
44 *
45 * Compliance: this file contains no forbidden functions from the
46 * WordPress.org Plugin Guidelines.
47 *
48 * @since 0.0.1
49 * @package zip-ai
50 */
51
52 namespace ZipAI\MCP\Classes\Abilities\Core;
53
54 defined( 'ABSPATH' ) || exit;
55
56 use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
57 use ZipAI\MCP\Classes\Core\Tool_Types;
58 use ZipAI\MCP\Classes\Core\Response;
59 use ZipAI\MCP\Classes\Core\Utils;
60
61 /**
62 * Ability: execute a WP-CLI-style command via native WP functions.
63 */
64 class RunWpCli extends Abstract_Ability {
65
66 use Security_Verifier_Trait;
67 use Command_Parser_Trait;
68 use Search_Replace_Engine_Trait;
69
70 /** Max bytes of output to return. */
71 const MAX_OUTPUT_BYTES = 2097152;
72
73 /**
74 * Marks the tool as destructive for UI approval flows.
75 *
76 * @var bool
77 */
78 protected $is_destructive = true;
79
80 /**
81 * The agent runs bulk operations through this ability one item at a time —
82 * a large delete or update is hundreds of calls in a minute — so the
83 * interactive default (100/min) throttles a legitimate bulk run partway
84 * through. The REST proxy carries the same limit for the same reason.
85 *
86 * @var int
87 */
88 protected $rate_limit = 1000;
89
90 /**
91 * Configure the ability metadata.
92 *
93 * @return void
94 */
95 public function configure() {
96 $this->id = 'zipai/run-wp-cli';
97 $this->label = 'Run WP-CLI Command';
98 $this->description = 'Run a WP-CLI-style command. Omit the leading "wp" — pass only the subcommand and flags. '
99 . 'When the plugin is running inside a WP-CLI process the full WP-CLI command surface is available; in web/REST context only the allowlisted commands below are dispatched to native WordPress functions.'
100 . "\n\n"
101 . 'READ COMMANDS:'
102 . "\n" . ' plugin list [--status=active|inactive|all] [--format=json]'
103 . "\n" . ' plugin get <slug> [--format=json]'
104 . "\n" . ' plugin is-active <slug>'
105 . "\n" . ' theme list [--status=active|inactive] [--format=json]'
106 . "\n" . ' theme get [<slug>] [--format=json]'
107 . "\n" . ' option get <key>'
108 . "\n" . ' option list [--search=pattern]'
109 . "\n" . ' post list [--post_type=…] [--post_status=…] [--per_page=…] [--page=…] [--fields=ID,post_title,…] [--format=json|count]'
110 . "\n" . ' — paginated: returns `data.pagination` {total,total_pages,page,per_page,has_more}. Use --format=count for the total only; page through with --page when has_more is true.'
111 . "\n" . ' post get <ID> [--fields=…] [--format=json]'
112 . "\n" . ' post meta get <ID> <meta_key>'
113 . "\n" . ' user list [--role=…] [--per_page=…] [--page=…] [--fields=ID,user_login,user_email,…] [--format=json|count]'
114 . "\n" . ' user get <id-or-login> [--format=json]'
115 . "\n" . ' user meta get <id> <meta_key>'
116 . "\n" . ' menu list [--format=json]'
117 . "\n" . ' menu item list <menu> [--format=json]'
118 . "\n" . ' sidebar list [--format=json]'
119 . "\n" . ' widget list <sidebar-id> [--format=json]'
120 . "\n" . ' core version'
121 . "\n" . ' core check-update — pending core updates (from WP\'s cached update data; no live wp.org call)'
122 . "\n" . ' cli info — PHP / WordPress runtime facts for the request (no WP-CLI binary in web context)'
123 . "\n" . ' language core list'
124 . "\n" . ' cron event list [--format=json]'
125 . "\n" . ' transient get <key>'
126 . "\n" . ' transient list [--search=…] [--limit=…] [--network] — list transient keys + expiry (no values)'
127 . "\n" . ' transient type — storage backend (database vs object-cache)'
128 . "\n" . ' term list <taxonomy> [--per_page=…] [--page=…] [--hide_empty=true|false] [--format=json|count]'
129 . "\n" . ' post meta list <ID> — all meta keys for a post'
130 . "\n" . ' user meta list <ID> — all meta keys for a user'
131 . "\n" . ' role list — registered user roles + cap counts'
132 . "\n" . ' role list-caps <role> — capability set for one role'
133 . "\n" . ' db size [--tables] [--human-readable] — database size summary'
134 . "\n" . ' rewrite list — current permalink rewrite rules'
135 . "\n" . ' cron schedule list — registered cron intervals'
136 . "\n" . ' env — WP / PHP / MySQL / extensions diagnostic'
137 . "\n" . ' option list [--search=…] [--autoload=on|off] [--limit=…] — list options, optionally filtered by autoload'
138 . "\n" . ' post-type list [--public=true|false] [--show_in_rest=true|false] [--format=json]'
139 . "\n" . ' taxonomy list [--public=true|false] [--object_type=<post_type>] [--format=json]'
140 . "\n" . ' comment list [--status=approve|hold|spam|trash|all] [--post_id=<ID>] [--search=…] [--per_page=…] [--page=…] [--fields=…] [--format=json|count|ids]'
141 . "\n" . ' — paginated like post list. Default --status=all covers approved + pending; spam/trash need an explicit --status.'
142 . "\n" . ' comment get <ID> [--fields=…]'
143 . "\n" . ' comment count [<post-ID>] — totals by status (approved, moderated, spam, trash)'
144 . "\n" . ' comment status <ID>'
145 . "\n" . ' comment exists <ID>'
146 . "\n" . ' comment meta get <ID> <meta_key>'
147 . "\n" . ' comment meta list <ID>'
148 . "\n\n"
149 . 'WRITE COMMANDS (require user approval):'
150 . "\n" . ' theme activate <slug> — server-side switch_theme()'
151 . "\n" . ' (plugin install/activate/deactivate/delete/update and theme install/delete/update'
152 . "\n" . ' are NOT routed through `run-wp-cli` — use the dedicated'
153 . "\n" . ' abilities zipai/install-plugin, zipai/activate-plugin,'
154 . "\n" . ' zipai/deactivate-plugin, zipai/delete-plugin, zipai/update-plugin,'
155 . "\n" . ' zipai/install-theme, zipai/delete-theme, zipai/update-theme.'
156 . "\n" . ' Calling them via run-wp-cli returns an error.)'
157 . "\n" . ' option update <key> <value> [--format=json]'
158 . "\n" . ' — set a WP option. Idempotent (creates when missing).'
159 . "\n" . ' Brick-risk keys (siteurl, home, admin_email, default_role,'
160 . "\n" . ' db_version, blog_charset, users_can_register) refuse upfront.'
161 . "\n" . ' option delete <key> — remove a WP option (idempotent on missing).'
162 . "\n" . ' post create --post_type=page --post_status=publish --post_title="Title"'
163 . "\n" . ' post update <ID> --post_title="New Title" (post_content edits are blocked here — use editor__apply_change with the page open in the block editor)'
164 . "\n" . ' post delete <ID> [<ID2> …] [--force] — accepts multiple IDs in one call.'
165 . "\n" . ' comment create --comment_post_ID=<ID> --comment_content="…" [--comment_author=…] [--comment_author_email=…] [--comment_approved=0|1]'
166 . "\n" . ' comment update <ID> --comment_content="…" [--comment_author=…]'
167 . "\n" . ' comment delete <ID> [<ID2> …] [--force] — trash by default; --force = permanent. Accepts multiple IDs.'
168 . "\n" . ' comment approve|unapprove|spam|unspam|trash|untrash <ID> [<ID2> …] — moderation; accepts multiple IDs.'
169 . "\n" . ' comment recount <post-ID> [<post-ID2> …] — recalculate cached comment_count on posts'
170 . "\n" . ' menu create <name>'
171 . "\n" . ' menu item add-post <menu-id> <post-id>'
172 . "\n" . ' cache flush'
173 . "\n" . ' rewrite flush'
174 . "\n" . ' transient delete --expired — drop only expired transient pairs (safe cleanup)'
175 . "\n" . ' transient delete --all — drop every transient (destructive cache flush)'
176 . "\n" . ' user add-role <id-or-login> <role> — non-admin roles only (administrator/super-admin blocked)'
177 . "\n" . ' user remove-role <id-or-login> <role> — same restriction'
178 . "\n" . ' user set-role <id-or-login> <role> — replaces all roles; same restriction'
179 . "\n" . ' user add-cap <id-or-login> <cap> — admin-class caps (manage_options, etc.) blocked'
180 . "\n" . ' user remove-cap <id-or-login> <cap> — same restriction'
181 . "\n\n"
182 . 'SITE-WIDE OPERATIONS (require user approval — destructive):'
183 . "\n" . ' search-replace "<old>" "<new>" [--all-tables] [--skip-columns=col1,col2] [--dry-run]'
184 . "\n" . ' Serialized-PHP-aware text replacement across post_content, postmeta, options, comments, etc. '
185 . "\n" . ' `guid` and `user_pass` columns are ALWAYS skipped. Use --dry-run first to preview counts.'
186 . "\n\n"
187 . 'NOTES:'
188 . "\n" . ' • In web/REST context, commands outside the allowlist return an explanatory error. Use the REST / Abilities API for uncovered operations.'
189 . "\n" . ' • "post update --post_content=…" is intentionally blocked: it replaces the entire block-editor content with a plain string, destroying block markup. Use editor__apply_change with the page open in the block editor instead.'
190 . "\n" . ' • Blocked for security: eval, eval-file, shell, package, server, db query, db import, db drop, option add/patch (use option update — it creates when missing), post/comment meta add/update/delete/patch, transient set/patch, transient delete <key> (specific-key — use --expired or --all instead), user create/update/delete, and user meta writes. Brick-risk option keys (siteurl, home, admin_email, default_role, db_version, blog_charset, users_can_register) refuse on option update/delete.';
191 $this->capability = 'manage_options';
192 }
193
194 /**
195 * Report the tool type to the MCP registry.
196 *
197 * @return string
198 */
199 public function get_tool_type() {
200 return Tool_Types::ACTION;
201 }
202
203 /**
204 * Return the tool's input schema.
205 *
206 * @return array<string,mixed>
207 */
208 public function get_input_schema() {
209 return array(
210 'type' => 'object',
211 'properties' => array(
212 'command' => array(
213 'type' => 'string',
214 'description' => 'WP-CLI subcommand and flags — without the leading "wp". '
215 . 'Examples: "option get siteurl", "plugin list --status=active --format=json", '
216 . '"post list --post_type=attachment --format=json --fields=ID,post_title,guid"',
217 ),
218 ),
219 'required' => array( 'command' ),
220 );
221 }
222
223 // ─── Entrypoint ─────────────────────────────────────────────────────────────
224
225 /**
226 * Execute the command.
227 *
228 * @param array<string,mixed> $input Validated input.
229 * @return array<string,mixed> Response data.
230 */
231 public function execute( $input ) {
232 // Defense-in-depth — four checks before dispatch. The Abilities API
233 // permission_callback (`Abstract_Ability::check_permission`) already
234 // gates this execute callback with `current_user_can($capability)`
235 // before it is invoked; we re-run the check here so any future
236 // direct caller is still subject to the same rule.
237 if ( ! is_user_logged_in() ) {
238 return Response::error( 'Authentication required.' );
239 }
240 if ( ! current_user_can( $this->capability ) ) {
241 return Response::error( 'Permission denied. You must have manage_options capability.' );
242 }
243 if ( is_multisite() && ! is_super_admin() ) {
244 return Response::error( 'Permission denied. You must be a network administrator on multisite installations to run WP-CLI commands.' );
245 }
246
247 $command = trim( Utils::to_str( $input['command'] ?? '' ) );
248 if ( '' === $command ) {
249 return Response::error( 'Empty command. Example: "option get siteurl"' );
250 }
251
252 // Cap command length defensively — anything larger is not a real CLI
253 // invocation and risks tying up the parser.
254 if ( strlen( $command ) > 4096 ) {
255 return Response::error( 'Command is too long. Break it into multiple invocations.' );
256 }
257
258 // Strip accidental leading "wp ".
259 $command = (string) preg_replace( '/^wp\s+/i', '', $command );
260
261 $args = $this->parse_command_to_args( $command );
262 if ( is_wp_error( $args ) ) {
263 return Response::error( $args->get_error_message() );
264 }
265
266 $security_check = $this->verify_command_security( $args );
267 if ( is_wp_error( $security_check ) ) {
268 return Response::error( $security_check->get_error_message() );
269 }
270
271 // Fast path: the WP-CLI runtime is ready. Delegate to it. But do NOT
272 // delegate `search-replace`.
273 //
274 // The protections for `search-replace` live inside
275 // `handle_search_replace()`. They exclude the users, usermeta, sitemeta
276 // and ms-global tables. They also filter protected-option rows. The
277 // verifier has no equal for these. It cannot know which needle matches
278 // which option before the walk. A raw string passed to WP-CLI would run
279 // an unguarded replacement (DSA-15). So this one family always uses the
280 // native engine.
281 //
282 // The family test reads EVERY candidate split. It does not read only the
283 // `parse_flags` split. Under one parser `--search search-replace <old>
284 // <new>` binds the family name as a flag value. WP-CLI reads the same
285 // name as the command. That would route the exact command this exception
286 // keeps native.
287 //
288 // This exception does NOT claim full cover on both paths.
289 // `verify_command_security()` runs for both executors. So every SECURITY
290 // rule in it applies to both. But the plugin and theme lifecycle
291 // redirects ("use the dedicated `zipai/install-plugin` ability",
292 // `:386-415`) are dispatcher cases. They are not verifier rules. The
293 // verifier lets those commands through whenever `wp_is_file_mod_allowed()`
294 // is true. On this path they would install or delete server-side. That
295 // skips the consent flow the dedicated abilities provide.
296 //
297 // This branch is NOT dead code. `wp mcp serve` reaches it. It uses the
298 // bundled `lib/mcp-adapter`. Its `McpAdapter` inits on `init` under
299 // WP-CLI. It is a stdio JSON-RPC MCP server. It routes `tools/call` to
300 // abilities. This runs in a process where `WP_CLI` is defined.
301 //
302 // That entry point stops one hop short today. This is only because
303 // `mcp-adapter/execute-ability` refuses anything without
304 // `meta['mcp']['public'] === true`. No `zipai/*` ability sets it. One
305 // `mcp_adapter_default_server_config` filter would make it reachable. So
306 // treat this path as guarded by configuration. Do not treat it as
307 // unreachable.
308 //
309 // A related point is wider than this ability. Only
310 // `Rest_Api::handle_mcp_request` (`inc/api/rest-api.php:116`) enters the
311 // MCP-context flag of `Protected_Options_Filter`. So on ANY non-REST MCP
312 // transport the filter backstop is inert for every protected key. This is
313 // tracked separately.
314 if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( '\WP_CLI' ) ) {
315 $families = array_column( $this->candidate_positionals( $args ), 0 );
316 if ( ! in_array( 'search-replace', $families, true ) ) {
317 return $this->run_via_wpcli_api( $command );
318 }
319 }
320
321 // Web / REST context — map to native WordPress PHP functions.
322 return $this->dispatch_native( $args );
323 }
324
325 // ─── Security ───────────────────────────────────────────────────────────────
326 // `verify_command_security()` lives in Security_Verifier_Trait
327 // (security-verifier-trait.php).
328
329 // ─── WP-CLI runtime passthrough ─────────────────────────────────────────────
330
331 /**
332 * Delegate to `\WP_CLI::runcommand()` when the request is already running
333 * inside a WP-CLI process.
334 *
335 * @param string $command Subcommand string (no leading "wp").
336 * @return array<string,mixed>
337 */
338 private function run_via_wpcli_api( string $command ): array {
339 try {
340 /**
341 * Narrowed type for `$result`.
342 *
343 * @var object{return_code:int,stdout:string,stderr:string} $result
344 */
345 $result = \WP_CLI::runcommand(
346 $command,
347 array(
348 'launch' => false,
349 'return' => 'all',
350 'exit_error' => false,
351 )
352 );
353 } catch ( \Throwable $e ) {
354 // Internal fault in WP-CLI itself (not a command's own non-zero exit —
355 // that's handled below with the real stderr the agent needs). The raw
356 // exception text can leak class names / paths, so keep it server-side
357 // (WP_DEBUG) and return a static message.
358 Utils::debug_log( 'WP-CLI runtime fault', $e->getMessage() );
359 return Response::error( 'WP-CLI could not run the command. Please try again.' );
360 }
361
362 if ( 0 !== $result->return_code ) {
363 $error_raw = '' !== trim( $result->stderr ) ? $result->stderr : $result->stdout;
364 $error = trim( $error_raw );
365 if ( '' === $error ) {
366 $error = sprintf( 'Command failed (exit %d)', $result->return_code );
367 }
368 return Response::error( $error );
369 }
370
371 $stdout = trim( $result->stdout );
372 $is_truncated = false;
373
374 if ( strlen( $stdout ) >= self::MAX_OUTPUT_BYTES ) {
375 $stdout = substr( $stdout, 0, self::MAX_OUTPUT_BYTES );
376 $is_truncated = true;
377 }
378
379 /**
380 * Parsed command output.
381 *
382 * @var array<string,mixed> $parsed
383 */
384 $parsed = $this->parse_output( $stdout );
385 if ( $is_truncated ) {
386 $parsed['truncated'] = true;
387 $parsed['truncated_message'] = 'Output truncated. Use --format=json with pagination flags to page through results.';
388 }
389 return $parsed;
390 }
391
392 // ─── Native dispatcher ──────────────────────────────────────────────────────
393
394 /**
395 * Dispatch an allowlisted WP-CLI command to a native WordPress handler.
396 *
397 * @param string[] $args Parsed command tokens (no leading "wp").
398 * @return array<string,mixed>
399 */
400 private function dispatch_native( array $args ): array {
401 list( $positional, $flags ) = $this->parse_flags( $args );
402
403 $base = strtolower( $positional[0] ?? '' );
404 $sub = strtolower( $positional[1] ?? '' );
405
406 // Single-token commands whose first positional is the command itself
407 // (rather than a "<base> <sub>" pair). Branch before the switch so the
408 // switch's "$base $sub" key doesn't need to encode the search/replace
409 // arguments.
410 if ( 'search-replace' === $base ) {
411 return $this->handle_search_replace( array_slice( $positional, 1 ), $flags );
412 }
413
414 // Routes where the subcommand is a single word after the base.
415 $route = trim( "{$base} {$sub}" );
416 $rest = array_slice( $positional, 2 );
417
418 switch ( $route ) {
419 // Plugin.
420 case 'plugin list':
421 return $this->handle_plugin_list( $flags );
422 case 'plugin get':
423 return $this->handle_plugin_get( $rest, $flags );
424 case 'plugin is-active':
425 case 'plugin is_active':
426 return $this->handle_plugin_is_active( $rest );
427
428 // Plugin lifecycle (install/activate/deactivate/delete) is handled by
429 // dedicated browser-proxied abilities. Refuse here with an explicit
430 // pointer so the LLM picks the right tool instead of running
431 // `wp plugin activate ...` and falling through to a stub.
432 case 'plugin install':
433 return Response::error(
434 '`wp plugin install` is not available here. Use the dedicated `zipai/install-plugin` ability — it installs through the user\'s browser session under the user\'s real `install_plugins` capability.'
435 );
436 case 'plugin activate':
437 return Response::error(
438 '`wp plugin activate` is not available here. Use the dedicated `zipai/activate-plugin` ability — it activates through the user\'s browser session under the user\'s real `activate_plugins` capability.'
439 );
440 case 'plugin deactivate':
441 return Response::error(
442 '`wp plugin deactivate` is not available here. Use the dedicated `zipai/deactivate-plugin` ability — it deactivates through the user\'s browser session under the user\'s real `activate_plugins` capability.'
443 );
444 case 'plugin delete':
445 return Response::error(
446 '`wp plugin delete` is not available here. Use the dedicated `zipai/delete-plugin` ability — it deletes through the user\'s browser session under the user\'s real `delete_plugins` capability.'
447 );
448 case 'plugin update':
449 return Response::error(
450 '`wp plugin update` is not available here. Use the dedicated `zipai/update-plugin` ability — it updates server-side under the user\'s real `update_plugins` capability and verifies each version bump.'
451 );
452
453 // Theme.
454 case 'theme list':
455 return $this->handle_theme_list( $flags );
456 case 'theme get':
457 case 'theme status':
458 return $this->handle_theme_get( $rest, $flags );
459 case 'theme activate':
460 return $this->handle_theme_activate( $rest );
461 case 'theme install':
462 return Response::error(
463 '`wp theme install` is not available here. Use the dedicated `zipai/install-theme` ability — it installs through the user\'s browser session under the user\'s real `install_themes` capability.'
464 );
465 case 'theme delete':
466 return Response::error(
467 '`wp theme delete` is not available here. Use the dedicated `zipai/delete-theme` ability — it deletes through the user\'s browser session under the user\'s real `delete_themes` capability.'
468 );
469 case 'theme update':
470 return Response::error(
471 '`wp theme update` is not available here. Use the dedicated `zipai/update-theme` ability — it updates server-side under the user\'s real `update_themes` capability and verifies each version bump.'
472 );
473
474 // Option. Reads + targeted writes (update / delete) are allowed.
475 // `add` / `patch` stay blocked in verify_command_security — `update`
476 // is idempotent and covers the legitimate write surface. Protected
477 // keys (Protected_Options_Filter::write_protected_keys()) refuse upfront.
478 case 'option get':
479 return $this->handle_option_get( $rest, $flags );
480 case 'option list':
481 return $this->handle_option_list( $flags );
482 case 'option update':
483 return $this->handle_option_update( $rest, $flags );
484 case 'option delete':
485 return $this->handle_option_delete( $rest );
486
487 // Transient — `set`/`patch` and arbitrary-key `delete` are blocked in
488 // verify_command_security; the four cases below are the safe ones.
489 case 'transient get':
490 return $this->handle_transient_get( $rest );
491 case 'transient list':
492 return $this->handle_transient_list( $flags );
493 case 'transient type':
494 return $this->handle_transient_type();
495 case 'transient delete':
496 return $this->handle_transient_delete( $flags );
497
498 // Post.
499 case 'post list':
500 return $this->handle_post_list( $flags );
501 case 'post get':
502 return $this->handle_post_get( $rest, $flags );
503 case 'post create':
504 return $this->handle_post_create( $flags );
505 case 'post update':
506 return $this->handle_post_update( $rest, $flags );
507 case 'post delete':
508 return $this->handle_post_delete( $rest, $flags );
509
510 // Post meta — 3 tokens: `post meta <verb> ...`.
511 case 'post meta':
512 return $this->route_meta( 'post', $rest, $flags );
513
514 // Comment.
515 case 'comment list':
516 return $this->handle_comment_list( $flags );
517 case 'comment get':
518 return $this->handle_comment_get( $rest, $flags );
519 case 'comment count':
520 return $this->handle_comment_count( $rest );
521 case 'comment exists':
522 return $this->handle_comment_exists( $rest );
523 case 'comment status':
524 return $this->handle_comment_status( $rest );
525 case 'comment create':
526 return $this->handle_comment_create( $flags );
527 case 'comment update':
528 return $this->handle_comment_update( $rest, $flags );
529 case 'comment delete':
530 return $this->handle_comment_delete( $rest, $flags );
531 case 'comment approve':
532 case 'comment unapprove':
533 case 'comment spam':
534 case 'comment unspam':
535 case 'comment trash':
536 case 'comment untrash':
537 return $this->handle_comment_set_status( $sub, $rest );
538 case 'comment recount':
539 return $this->handle_comment_recount( $rest );
540
541 // Comment meta — reads only (writes blocked in verify_command_security).
542 case 'comment meta':
543 return $this->route_meta( 'comment', $rest, $flags );
544
545 // User.
546 case 'user list':
547 return $this->handle_user_list( $flags );
548 case 'user get':
549 return $this->handle_user_get( $rest, $flags );
550 case 'user meta':
551 return $this->route_meta( 'user', $rest, $flags );
552 case 'user add-role':
553 return $this->handle_user_role_change( 'add', $rest );
554 case 'user remove-role':
555 return $this->handle_user_role_change( 'remove', $rest );
556 case 'user set-role':
557 return $this->handle_user_role_change( 'set', $rest );
558 case 'user add-cap':
559 return $this->handle_user_cap_change( 'add', $rest );
560 case 'user remove-cap':
561 return $this->handle_user_cap_change( 'remove', $rest );
562
563 // Term.
564 case 'term list':
565 return $this->handle_term_list( $rest, $flags );
566
567 // Post type / taxonomy schema discovery.
568 case 'post-type list':
569 case 'post_type list':
570 return $this->handle_post_type_list( $flags );
571 case 'taxonomy list':
572 return $this->handle_taxonomy_list( $flags );
573
574 // Menu.
575 case 'menu list':
576 return $this->handle_menu_list( $flags );
577 case 'menu create':
578 return $this->handle_menu_create( $rest );
579 case 'menu item':
580 return $this->route_menu_item( $rest, $flags );
581
582 // Sidebar / widget.
583 case 'sidebar list':
584 return $this->handle_sidebar_list();
585 case 'widget list':
586 return $this->handle_widget_list( $rest );
587
588 // Core / language / misc.
589 case 'core version':
590 return Response::success( array( 'version' => get_bloginfo( 'version' ) ) );
591 case 'core check-update':
592 case 'core check_update':
593 return $this->handle_core_check_update();
594
595 // Core UPDATES are deliberately unsupported — no route, no ability.
596 // An explicit refusal beats the generic catch-all so the LLM relays
597 // the manual path instead of hunting for a fallback tool.
598 case 'core update':
599 case 'core upgrade':
600 case 'core download':
601 return Response::error(
602 'WordPress core updates are not supported by the agent — there is no core-update ability and `wp core update` has no web-context route. Ask the user to apply the update in wp-admin → Dashboard → Updates.'
603 );
604
605 // CLI runtime probe — reports the PHP/WP process serving the
606 // request (there is no WP-CLI binary in web/REST context).
607 case 'cli info':
608 return $this->handle_cli_info();
609
610 case 'language core':
611 if ( 'list' === ( $positional[2] ?? '' ) ) {
612 return Response::success( get_available_languages() );
613 }
614 return Response::error( sprintf( 'Unsupported subcommand: "language core %s".', $positional[2] ?? '' ) );
615 case 'cache flush':
616 wp_cache_flush();
617 return Response::success( array( 'success' => true ) );
618 case 'rewrite flush':
619 // Soft flush: drop the cached rewrite_rules option so WordPress
620 // regenerates rules on the next request. Equivalent to passing
621 // false to flush_rewrite_rules() but without writing .htaccess
622 // or hitting the VIP restricted-functions sniff.
623 delete_option( 'rewrite_rules' );
624 return Response::success( array( 'success' => true ) );
625 case 'cron event':
626 if ( 'list' === ( $positional[2] ?? '' ) ) {
627 return $this->handle_cron_event_list();
628 }
629 return Response::error( sprintf( 'Unsupported subcommand: "cron event %s".', $positional[2] ?? '' ) );
630 case 'cron schedule':
631 if ( 'list' === ( $positional[2] ?? '' ) ) {
632 return $this->handle_cron_schedule_list();
633 }
634 return Response::error( sprintf( 'Unsupported subcommand: "cron schedule %s".', $positional[2] ?? '' ) );
635 case 'rewrite list':
636 return $this->handle_rewrite_list();
637
638 // Diagnostics: role discovery, db size, env info.
639 case 'role list':
640 return $this->handle_role_list( $flags );
641 case 'role list-caps':
642 return $this->handle_role_list_caps( $rest );
643 case 'db size':
644 return $this->handle_db_size( $flags );
645 case 'env':
646 return $this->handle_env();
647 }
648
649 return Response::error(
650 sprintf(
651 'Command "%s" is not available in web/REST context on the WordPress.org build. Use the REST or Abilities API for uncovered operations.',
652 trim( "{$base} {$sub}" )
653 )
654 );
655 }
656
657 // ─── Plugin handlers ────────────────────────────────────────────────────────
658
659 /**
660 * Handle "plugin list".
661 *
662 * @param array<string,mixed> $flags Parsed flags.
663 * @return array<string,mixed>
664 */
665 private function handle_plugin_list( array $flags ): array {
666 if ( ! function_exists( 'get_plugins' ) ) {
667 require_once ABSPATH . 'wp-admin/includes/plugin.php';
668 }
669 // `get_plugin_updates()` reads the cached `update_plugins` site
670 // transient (populated by core's scheduled `wp_update_plugins()`) —
671 // no forced wp.org call, so it's safe and fast in web/REST context.
672 // Without this the `update` / `update_version` columns the caller asks
673 // for would always come back empty and a Site Health snapshot would
674 // under-report available updates.
675 if ( ! function_exists( 'get_plugin_updates' ) ) {
676 require_once ABSPATH . 'wp-admin/includes/update.php';
677 }
678 $plugin_updates = function_exists( 'get_plugin_updates' ) ? get_plugin_updates() : array();
679
680 $plugins = get_plugins();
681 $status = strtolower( Utils::to_str( $flags['status'] ?? 'all', 'all' ) );
682
683 $out = array();
684 foreach ( $plugins as $file => $data ) {
685 $active = is_plugin_active( $file );
686 if ( 'active' === $status && ! $active ) {
687 continue;
688 }
689 if ( 'inactive' === $status && $active ) {
690 continue;
691 }
692 $has_update = isset( $plugin_updates[ $file ] );
693 $update_version = '';
694 if ( $has_update ) {
695 $entry = $plugin_updates[ $file ];
696 if ( isset( $entry->update ) && is_object( $entry->update ) && isset( $entry->update->new_version ) ) {
697 $update_version = Utils::to_str( $entry->update->new_version );
698 }
699 }
700 $out[] = array(
701 'name' => sanitize_title( Utils::to_str( $data['Name'] ?? basename( dirname( $file ) ) ) ),
702 'file' => $file,
703 'title' => $data['Name'] ?? '',
704 'status' => $active ? 'active' : 'inactive',
705 'version' => $data['Version'] ?? '',
706 // Mirror WP-CLI's `wp plugin list` columns: 'available' | 'none'.
707 'update' => $has_update ? 'available' : 'none',
708 'update_version' => $update_version,
709 'description' => wp_trim_words( wp_strip_all_tags( Utils::to_str( $data['Description'] ?? '' ) ), 30 ),
710 'author' => wp_strip_all_tags( Utils::to_str( $data['Author'] ?? '' ) ),
711 );
712 }
713 return Response::success( $out );
714 }
715
716 /**
717 * Handle "plugin get <slug>".
718 *
719 * @param string[] $positional Remaining positional args.
720 * @param array<string,mixed> $flags Parsed flags.
721 * @return array<string,mixed>
722 */
723 private function handle_plugin_get( array $positional, array $flags ) {
724 unset( $flags );
725 $slug = $positional[0] ?? '';
726 if ( '' === $slug ) {
727 return Response::error( 'Usage: plugin get <slug>' );
728 }
729 if ( ! function_exists( 'get_plugins' ) ) {
730 require_once ABSPATH . 'wp-admin/includes/plugin.php';
731 }
732 $file = $this->resolve_plugin_file( $slug );
733 if ( null === $file ) {
734 return Response::error( sprintf( 'Plugin "%s" not found.', $slug ) );
735 }
736 $plugins = get_plugins();
737 $data = $plugins[ $file ] ?? array();
738 return Response::success(
739 array(
740 'file' => $file,
741 'name' => sanitize_title( Utils::to_str( $data['Name'] ?? $slug ) ),
742 'title' => $data['Name'] ?? '',
743 'status' => is_plugin_active( $file ) ? 'active' : 'inactive',
744 'version' => $data['Version'] ?? '',
745 'description' => wp_strip_all_tags( Utils::to_str( $data['Description'] ?? '' ) ),
746 'author' => wp_strip_all_tags( Utils::to_str( $data['Author'] ?? '' ) ),
747 'requires_wp' => $data['RequiresWP'] ?? '',
748 'requires_php' => $data['RequiresPHP'] ?? '',
749 )
750 );
751 }
752
753 /**
754 * Handle "plugin is-active".
755 *
756 * @param string[] $positional Remaining positional args.
757 * @return array<string,mixed>
758 */
759 private function handle_plugin_is_active( array $positional ): array {
760 $slug = $positional[0] ?? '';
761 if ( '' === $slug ) {
762 return Response::error( 'Usage: plugin is-active <slug>' );
763 }
764 if ( ! function_exists( 'is_plugin_active' ) ) {
765 require_once ABSPATH . 'wp-admin/includes/plugin.php';
766 }
767 $file = $this->resolve_plugin_file( $slug );
768 if ( null === $file ) {
769 return Response::success( array( 'active' => false ) );
770 }
771 return Response::success( array( 'active' => is_plugin_active( $file ) ) );
772 }
773
774 /**
775 * Resolve a plugin slug or folder name to its main plugin file.
776 *
777 * @param string $slug Slug or "folder/file.php".
778 * @return string|null Plugin file relative to plugins/, or null if not found.
779 */
780 private function resolve_plugin_file( string $slug ): ?string {
781 if ( ! function_exists( 'get_plugins' ) ) {
782 require_once ABSPATH . 'wp-admin/includes/plugin.php';
783 }
784 $plugins = get_plugins();
785 if ( isset( $plugins[ $slug ] ) ) {
786 return $slug;
787 }
788 foreach ( array_keys( $plugins ) as $file ) {
789 if ( 0 === strpos( $file, $slug . '/' ) || 0 === strcasecmp( dirname( $file ), $slug ) ) {
790 return $file;
791 }
792 }
793 return null;
794 }
795
796 // ─── Theme handlers ─────────────────────────────────────────────────────────
797
798 /**
799 * Handle "theme list".
800 *
801 * @param array<string,mixed> $flags Parsed flags.
802 * @return array<string,mixed>
803 */
804 private function handle_theme_list( array $flags ): array {
805 $status = strtolower( Utils::to_str( $flags['status'] ?? 'all', 'all' ) );
806 $current = get_option( 'stylesheet' );
807
808 // Cached theme-update data (`update_themes` transient) — same
809 // rationale as plugin updates: read-only, no forced wp.org call,
810 // keeps the `update` / `update_version` columns populated.
811 if ( ! function_exists( 'get_theme_updates' ) ) {
812 require_once ABSPATH . 'wp-admin/includes/update.php';
813 }
814 $theme_updates = function_exists( 'get_theme_updates' ) ? get_theme_updates() : array();
815
816 $out = array();
817 foreach ( wp_get_themes() as $stylesheet => $theme ) {
818 $is_active = ( $stylesheet === $current );
819 if ( 'active' === $status && ! $is_active ) {
820 continue;
821 }
822 if ( 'inactive' === $status && $is_active ) {
823 continue;
824 }
825 // `get_theme_updates()` returns WP_Theme objects with an `update`
826 // array property (keyed by stylesheet) when an update is offered.
827 $has_update = isset( $theme_updates[ $stylesheet ] );
828 $update_version = '';
829 if ( $has_update ) {
830 $entry = $theme_updates[ $stylesheet ];
831 // get_theme_updates() sets ->update to the update-data array; the
832 // WP_Theme stub types that dynamic property as bool, so capture it
833 // before checking.
834 /**
835 * Narrowed type for `$update_data`.
836 *
837 * @var array<string,mixed>|bool $update_data
838 */
839 $update_data = $entry->update;
840 if ( is_array( $update_data ) && isset( $update_data['new_version'] ) ) {
841 $update_version = Utils::to_str( $update_data['new_version'] );
842 }
843 }
844 $out[] = array(
845 'name' => $stylesheet,
846 'title' => $theme->get( 'Name' ),
847 'status' => $is_active ? 'active' : 'inactive',
848 'version' => $theme->get( 'Version' ),
849 'update' => $has_update ? 'available' : 'none',
850 'update_version' => $update_version,
851 'author' => wp_strip_all_tags( (string) $theme->get( 'Author' ) ),
852 );
853 }
854 return Response::success( $out );
855 }
856
857 /**
858 * Handle "theme get".
859 *
860 * @param string[] $positional Remaining positional args.
861 * @param array<string,mixed> $flags Parsed flags.
862 * @return array<string,mixed>
863 */
864 private function handle_theme_get( array $positional, array $flags ) {
865 unset( $flags );
866 $slug = $positional[0] ?? '';
867 $theme = '' === $slug ? wp_get_theme() : wp_get_theme( $slug );
868 if ( ! $theme->exists() ) {
869 return Response::error( sprintf( 'Theme "%s" not found.', $slug ) );
870 }
871 return Response::success(
872 array(
873 'name' => $theme->get_stylesheet(),
874 'title' => $theme->get( 'Name' ),
875 'status' => ( $theme->get_stylesheet() === get_option( 'stylesheet' ) ) ? 'active' : 'inactive',
876 'version' => $theme->get( 'Version' ),
877 'author' => wp_strip_all_tags( (string) $theme->get( 'Author' ) ),
878 'description' => wp_strip_all_tags( (string) $theme->get( 'Description' ) ),
879 'parent' => $theme->parent() ? $theme->parent()->get_stylesheet() : '',
880 'template' => $theme->get_template(),
881 )
882 );
883 }
884
885 /**
886 * Handle "theme activate".
887 *
888 * @param string[] $positional Remaining positional args.
889 * @return array<string,mixed>
890 */
891 private function handle_theme_activate( array $positional ): array {
892 $slug = $positional[0] ?? '';
893 if ( '' === $slug ) {
894 return Response::error( 'Usage: theme activate <slug>' );
895 }
896 $theme = wp_get_theme( $slug );
897 if ( ! $theme->exists() ) {
898 return Response::error( sprintf( 'Theme "%s" not found.', $slug ) );
899 }
900 switch_theme( $slug );
901 return Response::success(
902 array(
903 'activated' => $slug,
904 )
905 );
906 }
907
908 // ─── Option handlers ────────────────────────────────────────────────────────
909
910 /**
911 * Handle "option get".
912 *
913 * @param string[] $positional Remaining positional args.
914 * @param array<string,mixed> $flags Parsed flags.
915 * @return array<string,mixed>
916 */
917 private function handle_option_get( array $positional, array $flags ) {
918 unset( $flags );
919 if ( empty( $positional ) ) {
920 return Response::error( 'Usage: option get <key> [<key2> …]' );
921 }
922 $out = array();
923 foreach ( $positional as $key ) {
924 $out[ $key ] = get_option( $key );
925 }
926 return Response::success( 1 === count( $out ) ? reset( $out ) : $out );
927 }
928
929 /**
930 * Handle "option list".
931 *
932 * @param array<string,mixed> $flags Parsed flags.
933 * @return array<string,mixed>
934 */
935 private function handle_option_list( array $flags ): array {
936 global $wpdb;
937 /**
938 * Narrowed type for `$wpdb`.
939 *
940 * @var \wpdb $wpdb
941 */
942 $search = Utils::to_str( $flags['search'] ?? '' );
943 $limit = Utils::to_int( $flags['limit'] ?? 200, 200 );
944 $limit = max( 1, min( $limit, 1000 ) );
945
946 // --autoload=on|off|yes|no — useful for performance audits ("what's
947 // loaded on every request?"). Maps to the `yes`/`no` values stored in
948 // the wp_options.autoload column.
949 $autoload_filter = '';
950 if ( isset( $flags['autoload'] ) ) {
951 $raw = strtolower( Utils::to_str( $flags['autoload'] ) );
952 if ( in_array( $raw, array( 'on', 'yes', 'true', '1' ), true ) ) {
953 $autoload_filter = 'yes';
954 } elseif ( in_array( $raw, array( 'off', 'no', 'false', '0' ), true ) ) {
955 $autoload_filter = 'no';
956 }
957 }
958
959 $sql = 'SELECT option_name, autoload FROM %i';
960 $bindings = array( $wpdb->options );
961 if ( '' !== $search && '' !== $autoload_filter ) {
962 $sql .= ' WHERE option_name LIKE %s AND autoload = %s';
963 $bindings[] = '%' . $wpdb->esc_like( $search ) . '%';
964 $bindings[] = $autoload_filter;
965 } elseif ( '' !== $search ) {
966 $sql .= ' WHERE option_name LIKE %s';
967 $bindings[] = '%' . $wpdb->esc_like( $search ) . '%';
968 } elseif ( '' !== $autoload_filter ) {
969 $sql .= ' WHERE autoload = %s';
970 $bindings[] = $autoload_filter;
971 }
972 $sql .= ' ORDER BY option_name ASC LIMIT %d';
973 $bindings[] = $limit;
974 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query is built from literal fragments + %i/%d placeholders and run through $wpdb->prepare().
975 $rows = $wpdb->get_results( $wpdb->prepare( $sql, $bindings ), ARRAY_A );
976
977 return Response::success( is_array( $rows ) ? $rows : array() );
978 }
979
980 /**
981 * Handle "option update <key> <value>".
982 *
983 * Mirrors WP-CLI's `wp option update <key> <value> [--format=json]`:
984 * - Idempotent: it creates the option when the option is missing.
985 * - `--format=json`: it parses `<value>` as JSON. So callers can pass
986 * arrays or objects. They do not serialize by hand.
987 *
988 * The code refuses keys in
989 * `Protected_Options_Filter::write_protected_keys()` upfront with a clear
990 * error. These are brick-risk options. They also include keys this generic
991 * primitive must not reach (`active_plugins`, `template`, `stylesheet`,
992 * `upload_path`, `upload_url_path`, `{prefix}user_roles`).
993 * `Protected_Options_Filter` also intercepts the `update_option()` call.
994 * This is a second backstop for the filter-installed subset. The upfront
995 * check means the code never claims success on a write that the filter
996 * turned into a no-op. That failure mode broke theme-activate before the
997 * protect-list fix.
998 *
999 * @param string[] $positional Remaining positional args ([key, value]).
1000 * @param array<string,mixed> $flags Parsed flags (`--format=json` recognised).
1001 * @return array<string,mixed>
1002 */
1003 private function handle_option_update( array $positional, array $flags ): array {
1004 $key = isset( $positional[0] ) ? (string) $positional[0] : '';
1005 if ( '' === $key ) {
1006 return Response::error( 'Usage: option update <key> <value> [--format=json]' );
1007 }
1008
1009 if ( \ZipAI\MCP\Classes\Security\Protected_Options_Filter::is_write_protected( $key ) ) {
1010 return Response::error(
1011 sprintf(
1012 'Security policy: option "%s" is protected and cannot be modified via the AI agent. Edit manually in wp-admin → Settings, or use the dedicated ability for it (theme activate / plugin activate).',
1013 $key
1014 )
1015 );
1016 }
1017
1018 // Value: positional[1] is the raw string. `--format=json` reinterprets
1019 // it as a JSON literal (lets callers store arrays/objects).
1020 if ( ! array_key_exists( 1, $positional ) ) {
1021 return Response::error( 'Usage: option update <key> <value> [--format=json]' );
1022 }
1023 $value = $positional[1];
1024 if ( isset( $flags['format'] ) && 'json' === strtolower( Utils::to_str( $flags['format'] ) ) ) {
1025 $decoded = json_decode( $value, true );
1026 if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) {
1027 return Response::error( sprintf( 'Invalid JSON value: %s.', json_last_error_msg() ) );
1028 }
1029 $value = $decoded;
1030 }
1031
1032 $ok = update_option( $key, $value );
1033
1034 // `update_option()` returns false for BOTH "value already at this
1035 // state" and "filter blocked the write" — indistinguishable from
1036 // the return value alone. Read back and compare to catch the
1037 // latter case: if the post-write value still differs from what we
1038 // tried to write, a `pre_update_option_<key>` filter (security
1039 // plugin / theme / other code) refused it.
1040 $post = get_option( $key );
1041 if ( ! $ok && \maybe_serialize( $post ) !== \maybe_serialize( $value ) ) {
1042 return Response::error(
1043 sprintf(
1044 'Option "%s" did not update — a `pre_update_option_%s` filter (security plugin, theme, or other code) is blocking the write.',
1045 $key,
1046 $key
1047 )
1048 );
1049 }
1050
1051 return Response::success(
1052 array(
1053 'key' => $key,
1054 'updated' => (bool) $ok,
1055 'message' => $ok
1056 ? sprintf( 'Option "%s" updated.', $key )
1057 : sprintf( 'Option "%s" already at this value; no write.', $key ),
1058 )
1059 );
1060 }
1061
1062 /**
1063 * Handle "option delete <key>".
1064 *
1065 * Idempotent on missing options (returns success with `deleted: false`).
1066 * Brick-risk keys refuse upfront — see handle_option_update for rationale.
1067 *
1068 * @param string[] $positional Remaining positional args ([key]).
1069 * @return array<string,mixed>
1070 */
1071 private function handle_option_delete( array $positional ): array {
1072 $key = isset( $positional[0] ) ? (string) $positional[0] : '';
1073 if ( '' === $key ) {
1074 return Response::error( 'Usage: option delete <key>' );
1075 }
1076
1077 if ( \ZipAI\MCP\Classes\Security\Protected_Options_Filter::is_write_protected( $key ) ) {
1078 return Response::error(
1079 sprintf(
1080 'Security policy: option "%s" is protected and cannot be deleted via the AI agent.',
1081 $key
1082 )
1083 );
1084 }
1085
1086 // `false` from `get_option` with no default = option doesn't exist.
1087 // Idempotent: treat absent-option as a successful no-op.
1088 if ( false === get_option( $key, false ) ) {
1089 return Response::success(
1090 array(
1091 'key' => $key,
1092 'deleted' => false,
1093 'message' => sprintf( 'Option "%s" did not exist; nothing to delete.', $key ),
1094 )
1095 );
1096 }
1097
1098 $ok = delete_option( $key );
1099 if ( ! $ok ) {
1100 return Response::error( sprintf( 'Failed to delete option "%s".', $key ) );
1101 }
1102
1103 return Response::success(
1104 array(
1105 'key' => $key,
1106 'deleted' => true,
1107 'message' => sprintf( 'Option "%s" deleted.', $key ),
1108 )
1109 );
1110 }
1111
1112 // ─── Transient handlers ─────────────────────────────────────────────────────
1113
1114 /**
1115 * Handle "transient get".
1116 *
1117 * @param string[] $positional Remaining positional args.
1118 * @return array<string,mixed>
1119 */
1120 private function handle_transient_get( array $positional ): array {
1121 $key = $positional[0] ?? '';
1122 if ( '' === $key ) {
1123 return Response::error( 'Usage: transient get <key>' );
1124 }
1125 return Response::success( array( 'value' => get_transient( $key ) ) );
1126 }
1127
1128 /**
1129 * Handle "transient list [--search=…] [--limit=…] [--network]".
1130 *
1131 * Lists transient keys directly from `wp_options` (or `wp_sitemeta` when
1132 * `--network` is passed on multisite). Returns only the key name, expiration
1133 * timestamp, and seconds-until-expiry — NOT the value, because transient
1134 * payloads can be large serialised structures that blow the response cap.
1135 * Use `transient get <key>` to fetch a specific value.
1136 *
1137 * @param array<string,mixed> $flags Parsed flags.
1138 * @return array<string,mixed>
1139 */
1140 private function handle_transient_list( array $flags ): array {
1141 global $wpdb;
1142 /**
1143 * Narrowed type for `$wpdb`.
1144 *
1145 * @var \wpdb $wpdb
1146 */
1147 $is_network = ! empty( $flags['network'] );
1148 if ( $is_network && ! is_multisite() ) {
1149 return Response::error( '--network requires a multisite install.' );
1150 }
1151
1152 $search = isset( $flags['search'] ) ? Utils::to_str( $flags['search'] ) : '';
1153 $limit = isset( $flags['limit'] ) ? max( 1, min( 1000, Utils::to_int( $flags['limit'] ) ) ) : 200;
1154
1155 $prefix = $is_network ? '_site_transient_' : '_transient_';
1156 $timeout_prefix = $is_network ? '_site_transient_timeout_' : '_transient_timeout_';
1157
1158 if ( $is_network ) {
1159 $table = (string) $wpdb->sitemeta;
1160 $name_column = 'meta_key';
1161 } else {
1162 $table = (string) $wpdb->options;
1163 $name_column = 'option_name';
1164 }
1165
1166 $sql = 'SELECT %i AS k FROM %i WHERE %i LIKE %s AND %i NOT LIKE %s';
1167 $args = array(
1168 $name_column,
1169 $table,
1170 $name_column,
1171 $wpdb->esc_like( $prefix ) . '%',
1172 $name_column,
1173 $wpdb->esc_like( $timeout_prefix ) . '%',
1174 );
1175 if ( $is_network ) {
1176 $sql .= ' AND site_id = %d';
1177 $args[] = get_current_network_id();
1178 }
1179 if ( '' !== $search ) {
1180 $sql .= ' AND %i LIKE %s';
1181 $args[] = $name_column;
1182 $args[] = '%' . $wpdb->esc_like( $search ) . '%';
1183 }
1184 $sql .= ' ORDER BY %i ASC LIMIT %d';
1185 $args[] = $name_column;
1186 $args[] = $limit;
1187
1188 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query is built from literal fragments + %i/%s/%d placeholders and run through $wpdb->prepare().
1189 $rows = $wpdb->get_col( $wpdb->prepare( $sql, $args ) );
1190
1191 $now = time();
1192 $result = array();
1193 foreach ( (array) $rows as $option_name ) {
1194 $key = substr( Utils::to_str( $option_name ), strlen( $prefix ) );
1195 $timeout_option = $timeout_prefix . $key;
1196 $expiration = $is_network
1197 ? get_site_option( $timeout_option )
1198 : get_option( $timeout_option );
1199 $expiration = Utils::to_int( $expiration );
1200 $result[] = array(
1201 'key' => $key,
1202 'expiration' => $expiration,
1203 'expires_in' => $expiration > 0 ? max( 0, $expiration - $now ) : null,
1204 'expired' => $expiration > 0 && $expiration < $now,
1205 );
1206 }
1207
1208 return Response::success(
1209 array(
1210 'scope' => $is_network ? 'site' : 'blog',
1211 'count' => count( $result ),
1212 'items' => $result,
1213 )
1214 );
1215 }
1216
1217 /**
1218 * Handle "transient type" — return the storage backend.
1219 *
1220 * @return array<string,mixed>
1221 */
1222 private function handle_transient_type(): array {
1223 return Response::success(
1224 array(
1225 'storage' => wp_using_ext_object_cache() ? 'object-cache' : 'database',
1226 'using_ext_object_cache' => wp_using_ext_object_cache(),
1227 'multisite' => is_multisite(),
1228 // Hint for the LLM about where `transient list` is currently
1229 // reading from — when object-cache is on, the option-table
1230 // listing won't see transients that live only in the cache.
1231 'list_visibility' => wp_using_ext_object_cache()
1232 ? 'transient list reads the option/sitemeta table only; transients held entirely in the persistent object cache are NOT enumerated here.'
1233 : 'transient list enumerates every site/network transient from the option/sitemeta table.',
1234 )
1235 );
1236 }
1237
1238 /**
1239 * Handle "transient delete --expired | --all". Specific-key deletion
1240 * (`transient delete <key>`) is refused upstream by
1241 * `verify_command_security` — the LLM shouldn't be picking individual
1242 * transient keys to nuke.
1243 *
1244 * @param array<string,mixed> $flags Parsed flags.
1245 * @return array<string,mixed>
1246 */
1247 private function handle_transient_delete( array $flags ): array {
1248 $expired_only = ! empty( $flags['expired'] );
1249 $all = ! empty( $flags['all'] );
1250
1251 // Defence in depth — the security verifier already gates these two cases.
1252 if ( ! $expired_only && ! $all ) {
1253 return Response::error( 'Usage: transient delete --expired | --all' );
1254 }
1255 if ( $expired_only && $all ) {
1256 return Response::error( 'Pass either --expired or --all, not both.' );
1257 }
1258
1259 if ( $expired_only ) {
1260 $deleted = $this->delete_expired_transients( false );
1261 // On multisite also clean network transients for the current network.
1262 if ( is_multisite() ) {
1263 $deleted += $this->delete_expired_transients( true );
1264 }
1265 return Response::success(
1266 array(
1267 'mode' => 'expired',
1268 'transients_freed' => $deleted,
1269 'message' => sprintf( 'Deleted %d expired transient entr%s.', $deleted, 1 === $deleted ? 'y' : 'ies' ),
1270 )
1271 );
1272 }
1273
1274 // --all: nuke every transient. Mirrors `wp transient delete --all`.
1275 $deleted = $this->delete_all_transients( false );
1276 if ( is_multisite() ) {
1277 $deleted += $this->delete_all_transients( true );
1278 }
1279 if ( wp_using_ext_object_cache() ) {
1280 // Transients in the persistent object cache live outside the
1281 // options/sitemeta tables; flush the cache so they're gone too.
1282 wp_cache_flush();
1283 }
1284 return Response::success(
1285 array(
1286 'mode' => 'all',
1287 'transients_freed' => $deleted,
1288 'object_cache_flushed' => wp_using_ext_object_cache(),
1289 'message' => sprintf( 'Deleted %d transient entr%s.', $deleted, 1 === $deleted ? 'y' : 'ies' ),
1290 )
1291 );
1292 }
1293
1294 /**
1295 * Delete expired transient + timeout option pairs.
1296 *
1297 * @param bool $network When true, operate on `_site_transient_*` entries
1298 * in $wpdb->sitemeta. Otherwise blog-level options.
1299 * @return int Number of `_transient_*` (non-timeout) options removed.
1300 */
1301 private function delete_expired_transients( bool $network ): int {
1302 global $wpdb;
1303 /**
1304 * Narrowed type for `$wpdb`.
1305 *
1306 * @var \wpdb $wpdb
1307 */
1308 $now = time();
1309 if ( $network ) {
1310 $table = (string) $wpdb->sitemeta;
1311 $name_column = 'meta_key';
1312 $value_column = 'meta_value';
1313 $timeout_prefix = '_site_transient_timeout_';
1314 $transient_prefix = '_site_transient_';
1315 } else {
1316 $table = (string) $wpdb->options;
1317 $name_column = 'option_name';
1318 $value_column = 'option_value';
1319 $timeout_prefix = '_transient_timeout_';
1320 $transient_prefix = '_transient_';
1321 }
1322
1323 // Find expired timeout entries.
1324 $sql = 'SELECT %i FROM %i WHERE %i LIKE %s AND %i < %d';
1325 $args = array( $name_column, $table, $name_column, $wpdb->esc_like( $timeout_prefix ) . '%', $value_column, $now );
1326 if ( $network ) {
1327 $sql .= ' AND site_id = %d';
1328 $args[] = get_current_network_id();
1329 }
1330 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query is built from literal fragments + %i/%s/%d placeholders and run through $wpdb->prepare().
1331 $expired_timeout_names = $wpdb->get_col( $wpdb->prepare( $sql, $args ) );
1332 if ( empty( $expired_timeout_names ) ) {
1333 return 0;
1334 }
1335
1336 $deleted = 0;
1337 foreach ( $expired_timeout_names as $timeout_option ) {
1338 $key = substr( Utils::to_str( $timeout_option ), strlen( $timeout_prefix ) );
1339 $pair = $transient_prefix . $key;
1340 if ( $network ) {
1341 if ( delete_site_transient( $key ) ) {
1342 ++$deleted;
1343 } else {
1344 // Belt-and-braces — drop dangling rows directly.
1345 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1346 $wpdb->delete(
1347 $table,
1348 array(
1349 $name_column => $pair,
1350 'site_id' => get_current_network_id(),
1351 )
1352 );
1353 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1354 $wpdb->delete(
1355 $table,
1356 array(
1357 $name_column => $timeout_option,
1358 'site_id' => get_current_network_id(),
1359 )
1360 );
1361 }
1362 } else {
1363 if ( delete_transient( $key ) ) {
1364 ++$deleted;
1365 } else {
1366 delete_option( $pair );
1367 delete_option( Utils::to_str( $timeout_option ) );
1368 }
1369 }
1370 }
1371 return $deleted;
1372 }
1373
1374 /**
1375 * Delete every transient (both `_transient_*` and `_transient_timeout_*`).
1376 *
1377 * Walks the discovered key list and calls core's `delete_transient()` /
1378 * `delete_site_transient()` for each one. Slower than a raw DELETE but
1379 * keeps the in-memory option/transient cache coherent — non-autoloaded
1380 * transient rows aren't in `alloptions`, so a bulk SQL DELETE alone
1381 * would leave stale values in the per-option cache.
1382 *
1383 * @param bool $network See delete_expired_transients().
1384 * @return int Number of transient entries removed.
1385 */
1386 private function delete_all_transients( bool $network ): int {
1387 global $wpdb;
1388 /**
1389 * Narrowed type for `$wpdb`.
1390 *
1391 * @var \wpdb $wpdb
1392 */
1393 if ( $network ) {
1394 $table = (string) $wpdb->sitemeta;
1395 $name_column = 'meta_key';
1396 $prefix = '_site_transient_';
1397 $timeout_prefix = '_site_transient_timeout_';
1398 } else {
1399 $table = (string) $wpdb->options;
1400 $name_column = 'option_name';
1401 $prefix = '_transient_';
1402 $timeout_prefix = '_transient_timeout_';
1403 }
1404
1405 // Discover every transient name (skip the timeout pairs — delete_*_transient
1406 // removes them along with the main entry).
1407 $sql = 'SELECT %i FROM %i WHERE %i LIKE %s AND %i NOT LIKE %s';
1408 $args = array( $name_column, $table, $name_column, $wpdb->esc_like( $prefix ) . '%', $name_column, $wpdb->esc_like( $timeout_prefix ) . '%' );
1409 if ( $network ) {
1410 $sql .= ' AND site_id = %d';
1411 $args[] = get_current_network_id();
1412 }
1413 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query is built from literal fragments + %i/%s/%d placeholders and run through $wpdb->prepare().
1414 $names = $wpdb->get_col( $wpdb->prepare( $sql, $args ) );
1415 if ( empty( $names ) ) {
1416 return 0;
1417 }
1418
1419 $deleted = 0;
1420 foreach ( $names as $option_name ) {
1421 $key = substr( Utils::to_str( $option_name ), strlen( $prefix ) );
1422 if ( '' === $key ) {
1423 continue;
1424 }
1425 $ok = $network ? delete_site_transient( $key ) : delete_transient( $key );
1426 if ( $ok ) {
1427 ++$deleted;
1428 }
1429 }
1430 return $deleted;
1431 }
1432
1433 // ─── Pagination helpers ─────────────────────────────────────────────────────
1434
1435 /**
1436 * Resolve the page window (page size + zero-based offset) for a list
1437 * command from its flags.
1438 *
1439 * Accepts both the WP-CLI input style (`--posts_per_page` / `--number`,
1440 * `--offset`) and the WP REST style (`--per_page`, `--page`). When an
1441 * explicit `--offset` is absent it is derived from `--page` (1-based).
1442 *
1443 * @param array<string,mixed> $flags Parsed command flags.
1444 * @param string $size_flag Primary page-size flag for this
1445 * command (`posts_per_page` for
1446 * posts, `number` for users/terms).
1447 * @param int $default_size Fallback page size when none is given.
1448 * @return array{0:int,1:int} [per_page, offset] — both clamped to safe bounds.
1449 */
1450 private function resolve_page_window( array $flags, string $size_flag, int $default_size ): array {
1451 $raw = $flags[ $size_flag ] ?? $flags['per_page'] ?? null;
1452
1453 // `-1` (or any negative) is the WP-CLI idiom for "all rows". Return the
1454 // sentinel -1 so handlers can request an unbounded query instead of
1455 // silently clamping the universal "give me everything" request to a
1456 // single row.
1457 if ( null !== $raw && Utils::to_int( $raw ) < 0 ) {
1458 return array( -1, 0 );
1459 }
1460
1461 $per_page = Utils::to_int( $raw, $default_size );
1462 $per_page = max( 1, min( $per_page, 1000 ) );
1463
1464 if ( isset( $flags['offset'] ) ) {
1465 $offset = max( 0, Utils::to_int( $flags['offset'] ) );
1466 } elseif ( isset( $flags['page'] ) ) {
1467 $offset = ( max( 1, Utils::to_int( $flags['page'] ) ) - 1 ) * $per_page;
1468 } else {
1469 $offset = 0;
1470 }
1471
1472 return array( $per_page, $offset );
1473 }
1474
1475 /**
1476 * Wrap a page of rows with WP REST-standard pagination metadata.
1477 *
1478 * Mirrors the WP REST API contract (the `X-WP-Total` / `X-WP-TotalPages`
1479 * headers) in the response body so the agent can page deterministically
1480 * with `--page` instead of guessing completeness from row count — a
1481 * silently-capped page is otherwise indistinguishable from a full result.
1482 *
1483 * @param array<int|string,mixed> $items Rows for the current page.
1484 * @param int $total Total matching rows across every page.
1485 * @param int $per_page Page size actually applied.
1486 * @param int $offset Zero-based offset of the current page.
1487 * @return array<string,mixed> Standardized success response with `data.pagination`.
1488 */
1489 private function paginated_response( array $items, int $total, int $per_page, int $offset ): array {
1490 $per_page = max( 1, $per_page );
1491
1492 return Response::success(
1493 $items,
1494 array(
1495 'pagination' => array(
1496 'total' => $total,
1497 'total_pages' => (int) ceil( $total / $per_page ),
1498 'page' => (int) floor( $offset / $per_page ) + 1,
1499 'per_page' => $per_page,
1500 'has_more' => ( $offset + count( $items ) ) < $total,
1501 ),
1502 )
1503 );
1504 }
1505
1506 /**
1507 * Whether the command requested a bare count (`--format=count`), mirroring
1508 * WP-CLI's `--format=count` which prints only the total number of rows.
1509 *
1510 * @param array<string,mixed> $flags Parsed command flags.
1511 * @return bool
1512 */
1513 private function wants_count( array $flags ): bool {
1514 return 'count' === strtolower( Utils::to_str( $flags['format'] ?? '' ) );
1515 }
1516
1517 /**
1518 * Whether the command requested only IDs (`--format=ids`), mirroring
1519 * WP-CLI's `--format=ids` which prints just the row identifiers. The
1520 * native dispatcher returns them as a flat JSON array so the agent can
1521 * feed them straight into a follow-up bulk operation.
1522 *
1523 * @param array<string,mixed> $flags Parsed command flags.
1524 * @return bool
1525 */
1526 private function wants_ids( array $flags ): bool {
1527 return 'ids' === strtolower( Utils::to_str( $flags['format'] ?? '' ) );
1528 }
1529
1530 /**
1531 * Normalize a `--post_status` flag to a value WP_Query understands.
1532 *
1533 * - `all` → every registered status (incl. trash/draft), matching the
1534 * agent's intent of "every post regardless of status". WP_Query has no
1535 * native `all`, so left raw it silently matches nothing.
1536 * - Comma lists (`publish,draft`) → array, mirroring WP-CLI.
1537 * - Anything else (`publish`, `any`, …) → passed through untouched.
1538 *
1539 * @param mixed $status Raw flag value.
1540 * @return string|array<int|string,mixed>
1541 */
1542 private function normalize_post_status( $status ) {
1543 if ( is_array( $status ) ) {
1544 return $status;
1545 }
1546 $status = Utils::to_str( $status );
1547 if ( 'all' === strtolower( $status ) ) {
1548 return array_values( get_post_stati() );
1549 }
1550 if ( false !== strpos( $status, ',' ) ) {
1551 return array_values( array_filter( array_map( 'trim', explode( ',', $status ) ), static fn ( string $s ): bool => '' !== $s ) );
1552 }
1553 return $status;
1554 }
1555
1556 // ─── Post handlers ──────────────────────────────────────────────────────────
1557
1558 /**
1559 * Handle "post list".
1560 *
1561 * @param array<string,mixed> $flags Parsed flags.
1562 * @return array<string,mixed>
1563 */
1564 private function handle_post_list( array $flags ): array {
1565 $post_type = Utils::to_str( $flags['post_type'] ?? 'post', 'post' );
1566 // Attachments are ALWAYS stored with post_status='inherit', never
1567 // 'publish'. Defaulting to 'publish' (correct for posts/pages)
1568 // silently returns zero media for `post list --post_type=attachment`,
1569 // which made the agent report "no media files" on sites that have
1570 // plenty. Mirror WP-CLI: when no --post_status is given, attachments
1571 // default to 'inherit'; everything else keeps 'publish'. An explicit
1572 // `--post_status=any` still overrides either way.
1573 $default_status = 'attachment' === $post_type ? 'inherit' : 'publish';
1574 list( $per_page, $offset ) = $this->resolve_page_window( $flags, 'posts_per_page', 20 );
1575 $all_rows = ( -1 === $per_page );
1576 $query_args = array(
1577 'post_type' => $post_type,
1578 'post_status' => $this->normalize_post_status( $flags['post_status'] ?? $default_status ),
1579 'posts_per_page' => $all_rows ? -1 : $per_page,
1580 'offset' => $all_rows ? 0 : $offset,
1581 'orderby' => $flags['orderby'] ?? 'date',
1582 'order' => strtoupper( Utils::to_str( $flags['order'] ?? 'DESC', 'DESC' ) ),
1583 );
1584 if ( ! empty( $flags['s'] ) ) {
1585 $query_args['s'] = Utils::to_str( $flags['s'] );
1586 }
1587
1588 // `--format=count`: mirror WP-CLI and return only the total, computed
1589 // from found_posts so it is accurate regardless of page size.
1590 if ( $this->wants_count( $flags ) ) {
1591 $count_query = new \WP_Query(
1592 array_merge(
1593 $query_args,
1594 array(
1595 'fields' => 'ids',
1596 'posts_per_page' => 1,
1597 'offset' => 0,
1598 )
1599 )
1600 );
1601 return Response::success( (int) $count_query->found_posts );
1602 }
1603
1604 // `--format=ids`: flat list of post IDs for chaining into bulk ops.
1605 if ( $this->wants_ids( $flags ) ) {
1606 $id_query = new \WP_Query( array_merge( $query_args, array( 'fields' => 'ids' ) ) );
1607 $ids = array_map( static fn ( $p ) => is_scalar( $p ) ? (int) $p : 0, $id_query->posts );
1608 return $this->paginated_response(
1609 $ids,
1610 (int) $id_query->found_posts,
1611 $all_rows ? max( 1, (int) $id_query->found_posts ) : $per_page,
1612 $all_rows ? 0 : $offset
1613 );
1614 }
1615
1616 $fields = $this->resolve_fields_flag( $flags, array( 'ID', 'post_title', 'post_status', 'post_date', 'post_type' ) );
1617
1618 $query = new \WP_Query( $query_args );
1619 $out = array();
1620 foreach ( $query->posts as $post ) {
1621 if ( ! $post instanceof \WP_Post ) {
1622 continue;
1623 }
1624 $out[] = $this->post_to_array( $post, $fields );
1625 }
1626 $total = (int) $query->found_posts;
1627 return $this->paginated_response( $out, $total, $all_rows ? max( 1, $total ) : $per_page, $all_rows ? 0 : $offset );
1628 }
1629
1630 /**
1631 * Handle "post get".
1632 *
1633 * @param string[] $positional Remaining positional args.
1634 * @param array<string,mixed> $flags Parsed flags.
1635 * @return array<string,mixed>
1636 */
1637 private function handle_post_get( array $positional, array $flags ) {
1638 $post_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1639 if ( $post_id <= 0 ) {
1640 return Response::error( 'Usage: post get <ID>' );
1641 }
1642 $post = get_post( $post_id );
1643 if ( ! $post ) {
1644 return Response::error( sprintf( 'Post %d not found.', $post_id ) );
1645 }
1646 $fields = $this->resolve_fields_flag( $flags, null );
1647 return Response::success( $this->post_to_array( $post, $fields ) );
1648 }
1649
1650 /**
1651 * Handle "post create".
1652 *
1653 * @param array<string,mixed> $flags Parsed flags.
1654 * @return array<string,mixed>
1655 */
1656 private function handle_post_create( array $flags ) {
1657 $data = $this->flags_to_post_data( $flags );
1658 if ( isset( $data['post_type'] ) && $this->is_managed_post_type( $data['post_type'] ) ) {
1659 return $this->managed_post_type_error( $data['post_type'] );
1660 }
1661 // wp_insert_post() runs wp_unslash() on the data — without wp_slash()
1662 // a literal backslash in a title/excerpt ("C:\new deals") is eaten.
1663 // The comment handlers on the identical contract already slash.
1664 $result = wp_insert_post( wp_slash( $data ), true );
1665 if ( is_wp_error( $result ) ) {
1666 return Response::error( $result->get_error_message() );
1667 }
1668 return Response::success( array( 'ID' => (int) $result ) );
1669 }
1670
1671 /**
1672 * Handle "post update".
1673 *
1674 * @param string[] $positional Remaining positional args.
1675 * @param array<string,mixed> $flags Parsed flags.
1676 * @return array<string,mixed>
1677 */
1678 private function handle_post_update( array $positional, array $flags ) {
1679 $post_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1680 if ( $post_id <= 0 ) {
1681 return Response::error( 'Usage: post update <ID> --field=value ...' );
1682 }
1683 // Site-infrastructure posts are off-limits for the GENERIC post
1684 // primitive in BOTH directions: retyping a managed post detaches it
1685 // (a wp_template_part turned "post" drops the site chrome), and any
1686 // sibling-field write on one degrades it (drafting a wp_global_styles
1687 // post pulls the design tokens off the front end) — the exact outcome
1688 // the --post_content guard already exists to prevent, one field over.
1689 $target = get_post( $post_id );
1690 if ( $target && $this->is_managed_post_type( (string) $target->post_type ) ) {
1691 return $this->managed_post_type_error( (string) $target->post_type );
1692 }
1693 $data = $this->flags_to_post_data( $flags );
1694 if ( isset( $data['post_type'] ) && $this->is_managed_post_type( $data['post_type'] ) ) {
1695 return $this->managed_post_type_error( $data['post_type'] );
1696 }
1697 $data['ID'] = $post_id;
1698 // wp_update_post() runs wp_unslash() on the data — see handle_post_create.
1699 $result = wp_update_post( wp_slash( $data ), true );
1700 if ( is_wp_error( $result ) ) {
1701 return Response::error( $result->get_error_message() );
1702 }
1703 return Response::success( array( 'ID' => (int) $result ) );
1704 }
1705
1706 /**
1707 * Post types the generic post primitives must not touch — WordPress site
1708 * infrastructure owned by dedicated tools (template editing, navigation,
1709 * global styles, the font library).
1710 *
1711 * @param string $post_type Post type slug.
1712 * @return bool
1713 */
1714 private function is_managed_post_type( string $post_type ): bool {
1715 return in_array(
1716 $post_type,
1717 array( 'wp_global_styles', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_font_family', 'wp_font_face' ),
1718 true
1719 );
1720 }
1721
1722 /**
1723 * The refusal for a managed post type, pointing at the owning tools.
1724 *
1725 * @param string $post_type Post type slug.
1726 * @return array<string,mixed>
1727 */
1728 private function managed_post_type_error( string $post_type ): array {
1729 return Response::error(
1730 sprintf(
1731 'Refused: `%s` posts are WordPress site infrastructure (templates, navigation, global styles, fonts). Generic post commands cannot create, retype, restate or delete them. Use the dedicated theme/navigation/style tools instead.',
1732 $post_type
1733 )
1734 );
1735 }
1736
1737 /**
1738 * Handle "post delete <ID> [<ID2> …] [--force]".
1739 *
1740 * Mirrors WP-CLI's native multi-id syntax. `wp post delete 12 34 56`
1741 * processes all three ids. The earlier single-id code dropped everything
1742 * after positional[0]. It reported success on the partial outcome. The LLM
1743 * could not know it deleted too few posts.
1744 *
1745 * Partial-success semantics:
1746 * - At least one delete works → `success: true`. The result holds
1747 * `deleted` (the list of ids that landed) and `failed` (a reason per
1748 * id). The caller phrases the response on the real counts.
1749 * - No delete works (every id is missing or a hook refused it) →
1750 * `Response::error`. So the caller does not claim success on a no-op
1751 * turn.
1752 *
1753 * @param string[] $positional Remaining positional args (one or more post IDs).
1754 * @param array<string,mixed> $flags Parsed flags (`--force` recognised).
1755 * @return array<string,mixed>
1756 */
1757 private function handle_post_delete( array $positional, array $flags ): array {
1758 if ( empty( $positional ) ) {
1759 return Response::error( 'Usage: post delete <ID> [<ID2> …] [--force]' );
1760 }
1761 $force = ! empty( $flags['force'] );
1762
1763 $deleted = array();
1764 $failed = array();
1765
1766 foreach ( $positional as $arg ) {
1767 $post_id = (int) $arg;
1768 if ( $post_id <= 0 ) {
1769 $failed[] = array(
1770 'id' => (string) $arg,
1771 'reason' => 'not a positive integer',
1772 );
1773 continue;
1774 }
1775 // Same infrastructure guard as post update — deleting a
1776 // wp_template_part / wp_navigation / wp_global_styles post through
1777 // the generic primitive detaches site chrome or design tokens.
1778 $target = get_post( $post_id );
1779 if ( $target && $this->is_managed_post_type( (string) $target->post_type ) ) {
1780 $failed[] = array(
1781 'id' => $post_id,
1782 'reason' => "post type `{$target->post_type}` is site infrastructure, use the dedicated theme/navigation/style tools",
1783 );
1784 continue;
1785 }
1786 $result = wp_delete_post( $post_id, $force );
1787 if ( $result ) {
1788 $deleted[] = $post_id;
1789 } else {
1790 $failed[] = array(
1791 'id' => $post_id,
1792 'reason' => 'wp_delete_post returned false (post not found, already trashed when --force omitted, or hook refused)',
1793 );
1794 }
1795 }
1796
1797 // All-fail → error, so the caller doesn't claim success on zero deletes.
1798 // Mirrors the same truth-telling rule core__bulk_run_wp_cli enforces:
1799 // if NOTHING landed, don't mark dependent todos done.
1800 if ( empty( $deleted ) ) {
1801 $ids = array_map( static fn( $f ) => (string) $f['id'], $failed );
1802 $details = array_map( static fn( $f ) => $f['id'] . ' (' . $f['reason'] . ')', $failed );
1803 return Response::error(
1804 sprintf(
1805 'No posts deleted. Attempted IDs: %s. Failures: %s.',
1806 implode( ', ', $ids ),
1807 implode( '; ', $details )
1808 )
1809 );
1810 }
1811
1812 return Response::success(
1813 array(
1814 'deleted' => $deleted,
1815 'failed' => $failed,
1816 'forced' => $force,
1817 )
1818 );
1819 }
1820
1821 // ─── Comment handlers ───────────────────────────────────────────────────────
1822
1823 /**
1824 * Handle "comment list".
1825 *
1826 * @param array<string,mixed> $flags Parsed flags.
1827 * @return array<string,mixed>
1828 */
1829 private function handle_comment_list( array $flags ): array {
1830 list( $per_page, $offset ) = $this->resolve_page_window( $flags, 'number', 20 );
1831 $all_rows = ( -1 === $per_page );
1832 $query_args = array(
1833 // WP_Comment_Query's 'all' covers approved + pending; spam/trash
1834 // rows only appear when requested explicitly via --status.
1835 'status' => Utils::to_str( $flags['status'] ?? 'all', 'all' ),
1836 'number' => $all_rows ? 0 : $per_page,
1837 'offset' => $all_rows ? 0 : $offset,
1838 'orderby' => $flags['orderby'] ?? 'comment_date_gmt',
1839 'order' => strtoupper( Utils::to_str( $flags['order'] ?? 'DESC', 'DESC' ) ),
1840 );
1841 // WP-CLI uses --comment_post_ID; accept --post_id as the friendlier alias.
1842 $post_id = Utils::to_int( $flags['comment_post_ID'] ?? $flags['post_id'] ?? 0 );
1843 if ( $post_id > 0 ) {
1844 $query_args['post_id'] = $post_id;
1845 }
1846 if ( ! empty( $flags['search'] ) ) {
1847 $query_args['search'] = Utils::to_str( $flags['search'] );
1848 }
1849 if ( ! empty( $flags['type'] ) ) {
1850 $query_args['type'] = Utils::to_str( $flags['type'] );
1851 }
1852
1853 $count_args = array_merge(
1854 $query_args,
1855 array(
1856 'count' => true,
1857 'number' => 0,
1858 'offset' => 0,
1859 )
1860 );
1861
1862 // `--format=count`: total matching comments, no rows fetched.
1863 if ( $this->wants_count( $flags ) ) {
1864 return Response::success( (int) ( new \WP_Comment_Query() )->query( $count_args ) );
1865 }
1866
1867 $total = (int) ( new \WP_Comment_Query() )->query( $count_args );
1868
1869 // `--format=ids`: flat list of comment IDs for chaining into bulk ops.
1870 if ( $this->wants_ids( $flags ) ) {
1871 $ids = (array) ( new \WP_Comment_Query() )->query( array_merge( $query_args, array( 'fields' => 'ids' ) ) );
1872 return $this->paginated_response(
1873 array_map( static fn ( $id ) => is_scalar( $id ) ? (int) $id : 0, $ids ),
1874 $total,
1875 $all_rows ? max( 1, $total ) : $per_page,
1876 $all_rows ? 0 : $offset
1877 );
1878 }
1879
1880 $fields = $this->resolve_fields_flag(
1881 $flags,
1882 array( 'comment_ID', 'comment_post_ID', 'comment_author', 'comment_author_email', 'comment_date', 'status' )
1883 );
1884 $comments = (array) ( new \WP_Comment_Query() )->query( $query_args );
1885 $out = array();
1886 foreach ( $comments as $comment ) {
1887 if ( ! $comment instanceof \WP_Comment ) {
1888 continue;
1889 }
1890 $out[] = $this->comment_to_array( $comment, $fields );
1891 }
1892 return $this->paginated_response( $out, $total, $all_rows ? max( 1, $total ) : $per_page, $all_rows ? 0 : $offset );
1893 }
1894
1895 /**
1896 * Handle "comment get".
1897 *
1898 * @param string[] $positional Remaining positional args.
1899 * @param array<string,mixed> $flags Parsed flags.
1900 * @return array<string,mixed>
1901 */
1902 private function handle_comment_get( array $positional, array $flags ) {
1903 $comment_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1904 if ( $comment_id <= 0 ) {
1905 return Response::error( 'Usage: comment get <ID>' );
1906 }
1907 $comment = get_comment( $comment_id );
1908 if ( ! $comment ) {
1909 return Response::error( sprintf( 'Comment %d not found.', $comment_id ) );
1910 }
1911 $fields = $this->resolve_fields_flag( $flags, null );
1912 return Response::success( $this->comment_to_array( $comment, $fields ) );
1913 }
1914
1915 /**
1916 * Handle "comment count [<post-ID>]".
1917 *
1918 * @param string[] $positional Remaining positional args.
1919 * @return array<string,mixed>
1920 */
1921 private function handle_comment_count( array $positional ): array {
1922 $post_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1923 $counts = wp_count_comments( $post_id );
1924 return Response::success( (array) $counts );
1925 }
1926
1927 /**
1928 * Handle "comment exists <ID>".
1929 *
1930 * @param string[] $positional Remaining positional args.
1931 * @return array<string,mixed>
1932 */
1933 private function handle_comment_exists( array $positional ): array {
1934 $comment_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1935 if ( $comment_id <= 0 ) {
1936 return Response::error( 'Usage: comment exists <ID>' );
1937 }
1938 return Response::success( array( 'exists' => (bool) get_comment( $comment_id ) ) );
1939 }
1940
1941 /**
1942 * Handle "comment status <ID>".
1943 *
1944 * @param string[] $positional Remaining positional args.
1945 * @return array<string,mixed>
1946 */
1947 private function handle_comment_status( array $positional ): array {
1948 $comment_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1949 if ( $comment_id <= 0 ) {
1950 return Response::error( 'Usage: comment status <ID>' );
1951 }
1952 $status = wp_get_comment_status( $comment_id );
1953 if ( false === $status ) {
1954 return Response::error( sprintf( 'Comment %d not found.', $comment_id ) );
1955 }
1956 return Response::success( array( 'status' => $status ) );
1957 }
1958
1959 /**
1960 * Handle "comment create".
1961 *
1962 * @param array<string,mixed> $flags Parsed flags.
1963 * @return array<string,mixed>
1964 */
1965 private function handle_comment_create( array $flags ) {
1966 $data = $this->flags_to_comment_data( $flags );
1967
1968 $post_id = (int) ( $data['comment_post_ID'] ?? 0 );
1969 if ( $post_id <= 0 ) {
1970 return Response::error( 'Usage: comment create --comment_post_ID=<post-ID> --comment_content="…" [--comment_author=…] [--comment_author_email=…] [--comment_approved=0|1]' );
1971 }
1972 if ( ! get_post( $post_id ) ) {
1973 return Response::error( sprintf( 'Post %d not found — cannot attach a comment to it.', $post_id ) );
1974 }
1975
1976 // Mirror WP-CLI: comments created by the tool are approved unless told otherwise.
1977 if ( ! array_key_exists( 'comment_approved', $data ) ) {
1978 $data['comment_approved'] = 1;
1979 }
1980
1981 $comment_id = wp_insert_comment( wp_slash( $data ) );
1982 if ( ! $comment_id ) {
1983 return Response::error( 'wp_insert_comment failed (hook refused or invalid data).' );
1984 }
1985 return Response::success( array( 'comment_ID' => (int) $comment_id ) );
1986 }
1987
1988 /**
1989 * Handle "comment update".
1990 *
1991 * @param string[] $positional Remaining positional args.
1992 * @param array<string,mixed> $flags Parsed flags.
1993 * @return array<string,mixed>
1994 */
1995 private function handle_comment_update( array $positional, array $flags ) {
1996 $comment_id = isset( $positional[0] ) ? (int) $positional[0] : 0;
1997 if ( $comment_id <= 0 ) {
1998 return Response::error( 'Usage: comment update <ID> --field=value ...' );
1999 }
2000 if ( ! get_comment( $comment_id ) ) {
2001 return Response::error( sprintf( 'Comment %d not found.', $comment_id ) );
2002 }
2003 $data = $this->flags_to_comment_data( $flags );
2004 if ( empty( $data ) ) {
2005 return Response::error( 'No updatable fields supplied. Pass flags like --comment_content="…".' );
2006 }
2007 $data['comment_ID'] = $comment_id;
2008
2009 $result = wp_update_comment( wp_slash( $data ), true );
2010 if ( is_wp_error( $result ) ) {
2011 return Response::error( $result->get_error_message() );
2012 }
2013 return Response::success(
2014 array(
2015 'comment_ID' => $comment_id,
2016 'updated' => (bool) $result,
2017 )
2018 );
2019 }
2020
2021 /**
2022 * Handle "comment delete <ID> [<ID2> …] [--force]".
2023 *
2024 * Same partial-success semantics as `post delete`: report what landed
2025 * and what failed; all-fail → error so the caller doesn't claim success
2026 * on a no-op turn. Without --force the comment is trashed (WP default);
2027 * with --force it is permanently deleted.
2028 *
2029 * @param string[] $positional Remaining positional args (one or more comment IDs).
2030 * @param array<string,mixed> $flags Parsed flags (`--force` recognised).
2031 * @return array<string,mixed>
2032 */
2033 private function handle_comment_delete( array $positional, array $flags ): array {
2034 if ( empty( $positional ) ) {
2035 return Response::error( 'Usage: comment delete <ID> [<ID2> …] [--force]' );
2036 }
2037 $force = ! empty( $flags['force'] );
2038
2039 $deleted = array();
2040 $failed = array();
2041
2042 foreach ( $positional as $arg ) {
2043 $comment_id = (int) $arg;
2044 if ( $comment_id <= 0 ) {
2045 $failed[] = array(
2046 'id' => (string) $arg,
2047 'reason' => 'not a positive integer',
2048 );
2049 continue;
2050 }
2051 $result = wp_delete_comment( $comment_id, $force );
2052 if ( $result ) {
2053 $deleted[] = $comment_id;
2054 } else {
2055 $failed[] = array(
2056 'id' => $comment_id,
2057 'reason' => 'wp_delete_comment returned false (comment not found, already trashed when --force omitted, or hook refused)',
2058 );
2059 }
2060 }
2061
2062 if ( empty( $deleted ) ) {
2063 $ids = array_map( static fn( $f ) => (string) $f['id'], $failed );
2064 $details = array_map( static fn( $f ) => $f['id'] . ' (' . $f['reason'] . ')', $failed );
2065 return Response::error(
2066 sprintf(
2067 'No comments deleted. Attempted IDs: %s. Failures: %s.',
2068 implode( ', ', $ids ),
2069 implode( '; ', $details )
2070 )
2071 );
2072 }
2073
2074 return Response::success(
2075 array(
2076 'deleted' => $deleted,
2077 'failed' => $failed,
2078 'forced' => $force,
2079 )
2080 );
2081 }
2082
2083 /**
2084 * Handle "comment approve|unapprove|spam|unspam|trash|untrash <ID> [<ID2> …]".
2085 *
2086 * Same partial-success semantics as `comment delete`.
2087 *
2088 * @param string $verb Moderation verb.
2089 * @param string[] $positional One or more comment IDs.
2090 * @return array<string,mixed>
2091 */
2092 private function handle_comment_set_status( string $verb, array $positional ): array {
2093 if ( empty( $positional ) ) {
2094 return Response::error( sprintf( 'Usage: comment %s <ID> [<ID2> …]', $verb ) );
2095 }
2096
2097 $changed = array();
2098 $failed = array();
2099
2100 foreach ( $positional as $arg ) {
2101 $comment_id = (int) $arg;
2102 if ( $comment_id <= 0 ) {
2103 $failed[] = array(
2104 'id' => (string) $arg,
2105 'reason' => 'not a positive integer',
2106 );
2107 continue;
2108 }
2109 if ( ! get_comment( $comment_id ) ) {
2110 $failed[] = array(
2111 'id' => $comment_id,
2112 'reason' => 'comment not found',
2113 );
2114 continue;
2115 }
2116
2117 switch ( $verb ) {
2118 case 'approve':
2119 $result = wp_set_comment_status( $comment_id, 'approve', true );
2120 break;
2121 case 'unapprove':
2122 $result = wp_set_comment_status( $comment_id, 'hold', true );
2123 break;
2124 case 'spam':
2125 $result = wp_spam_comment( $comment_id );
2126 break;
2127 case 'unspam':
2128 $result = wp_unspam_comment( $comment_id );
2129 break;
2130 case 'trash':
2131 $result = wp_trash_comment( $comment_id );
2132 break;
2133 case 'untrash':
2134 $result = wp_untrash_comment( $comment_id );
2135 break;
2136 default:
2137 return Response::error( sprintf( 'Unsupported comment moderation verb: "%s".', $verb ) );
2138 }
2139
2140 if ( is_wp_error( $result ) ) {
2141 $failed[] = array(
2142 'id' => $comment_id,
2143 'reason' => $result->get_error_message(),
2144 );
2145 } elseif ( $result ) {
2146 $changed[] = $comment_id;
2147 } else {
2148 $failed[] = array(
2149 'id' => $comment_id,
2150 'reason' => 'status change refused (already in target status, or hook refused)',
2151 );
2152 }
2153 }
2154
2155 if ( empty( $changed ) ) {
2156 $details = array_map( static fn( $f ) => $f['id'] . ' (' . $f['reason'] . ')', $failed );
2157 return Response::error(
2158 sprintf(
2159 'No comments %sd. Failures: %s.',
2160 $verb,
2161 implode( '; ', $details )
2162 )
2163 );
2164 }
2165
2166 return Response::success(
2167 array(
2168 'action' => $verb,
2169 'changed' => $changed,
2170 'failed' => $failed,
2171 )
2172 );
2173 }
2174
2175 /**
2176 * Handle "comment recount <post-ID> [<post-ID2> …]".
2177 *
2178 * Recalculates the cached comment_count on each post.
2179 *
2180 * @param string[] $positional One or more post IDs.
2181 * @return array<string,mixed>
2182 */
2183 private function handle_comment_recount( array $positional ): array {
2184 if ( empty( $positional ) ) {
2185 return Response::error( 'Usage: comment recount <post-ID> [<post-ID2> …]' );
2186 }
2187
2188 $out = array();
2189 foreach ( $positional as $arg ) {
2190 $post_id = (int) $arg;
2191 $post = $post_id > 0 ? get_post( $post_id ) : null;
2192 if ( ! $post ) {
2193 $out[] = array(
2194 'post_ID' => (string) $arg,
2195 'error' => 'post not found',
2196 );
2197 continue;
2198 }
2199 wp_update_comment_count_now( $post_id );
2200 $out[] = array(
2201 'post_ID' => $post_id,
2202 'comment_count' => (int) get_post( $post_id )->comment_count,
2203 );
2204 }
2205 return Response::success( $out );
2206 }
2207
2208 /**
2209 * Serialise a WP_Comment to a JSON-friendly array.
2210 *
2211 * Includes a derived `status` (approved/unapproved/spam/trash) so the
2212 * agent never has to decode raw comment_approved values ('0'/'1'/'spam').
2213 *
2214 * @param \WP_Comment $comment Comment.
2215 * @param string[]|null $fields Field subset; null for all.
2216 * @return array<string,mixed>
2217 */
2218 private function comment_to_array( \WP_Comment $comment, ?array $fields ): array {
2219 $all = array(
2220 'comment_ID' => (int) $comment->comment_ID,
2221 'comment_post_ID' => (int) $comment->comment_post_ID,
2222 'comment_author' => $comment->comment_author,
2223 'comment_author_email' => $comment->comment_author_email,
2224 'comment_author_url' => $comment->comment_author_url,
2225 'comment_date' => $comment->comment_date,
2226 'comment_content' => $comment->comment_content,
2227 'comment_type' => $comment->comment_type,
2228 'comment_parent' => (int) $comment->comment_parent,
2229 'comment_approved' => $comment->comment_approved,
2230 'user_id' => (int) $comment->user_id,
2231 'status' => wp_get_comment_status( $comment ),
2232 );
2233 if ( null === $fields ) {
2234 return $all;
2235 }
2236 $out = array();
2237 foreach ( $fields as $f ) {
2238 $out[ $f ] = $all[ $f ] ?? null;
2239 }
2240 return $out;
2241 }
2242
2243 /**
2244 * Translate WP-CLI-style --flags into a wp_insert_comment/wp_update_comment array.
2245 *
2246 * @param array<string,mixed> $flags Parsed flags.
2247 * @return array{comment_post_ID?:int,comment_content?:string,comment_author?:string,comment_author_email?:string,comment_author_url?:string,comment_approved?:string,comment_parent?:int,comment_type?:string,comment_date?:string,user_id?:int}
2248 */
2249 private function flags_to_comment_data( array $flags ): array {
2250 $out = array();
2251 if ( array_key_exists( 'comment_post_ID', $flags ) ) {
2252 $out['comment_post_ID'] = Utils::to_int( $flags['comment_post_ID'] );
2253 }
2254 if ( array_key_exists( 'comment_content', $flags ) ) {
2255 $out['comment_content'] = Utils::to_str( $flags['comment_content'] );
2256 }
2257 if ( array_key_exists( 'comment_author', $flags ) ) {
2258 $out['comment_author'] = Utils::to_str( $flags['comment_author'] );
2259 }
2260 if ( array_key_exists( 'comment_author_email', $flags ) ) {
2261 $out['comment_author_email'] = Utils::to_str( $flags['comment_author_email'] );
2262 }
2263 if ( array_key_exists( 'comment_author_url', $flags ) ) {
2264 $out['comment_author_url'] = Utils::to_str( $flags['comment_author_url'] );
2265 }
2266 if ( array_key_exists( 'comment_approved', $flags ) ) {
2267 $out['comment_approved'] = Utils::to_str( $flags['comment_approved'] );
2268 }
2269 if ( array_key_exists( 'comment_parent', $flags ) ) {
2270 $out['comment_parent'] = Utils::to_int( $flags['comment_parent'] );
2271 }
2272 if ( array_key_exists( 'comment_type', $flags ) ) {
2273 $out['comment_type'] = Utils::to_str( $flags['comment_type'] );
2274 }
2275 if ( array_key_exists( 'comment_date', $flags ) ) {
2276 $out['comment_date'] = Utils::to_str( $flags['comment_date'] );
2277 }
2278 if ( array_key_exists( 'user_id', $flags ) ) {
2279 $out['user_id'] = Utils::to_int( $flags['user_id'] );
2280 }
2281 return $out;
2282 }
2283
2284 // ─── Meta (post + user + comment) ───────────────────────────────────────────
2285
2286 /**
2287 * Route post-meta / user-meta / comment-meta subcommands.
2288 *
2289 * @param string $object "post", "user" or "comment".
2290 * @param string[] $positional Remaining positional args after "<object> meta".
2291 * @param array<string,mixed> $flags Parsed flags.
2292 * @return array<string,mixed>
2293 */
2294 private function route_meta( string $object, array $positional, array $flags ): array {
2295 unset( $flags );
2296 $verb = strtolower( $positional[0] ?? '' );
2297 $id = isset( $positional[1] ) ? (int) $positional[1] : 0;
2298
2299 // Writes are blocked in verify_command_security(); only reads reach here.
2300 $get_fns = array(
2301 'user' => 'get_user_meta',
2302 'comment' => 'get_comment_meta',
2303 );
2304 $get_fn = $get_fns[ $object ] ?? 'get_post_meta';
2305
2306 if ( 'get' === $verb ) {
2307 $key = $positional[2] ?? '';
2308 if ( $id <= 0 || '' === $key ) {
2309 return Response::error( sprintf( 'Usage: %s meta get <id> <key>', $object ) );
2310 }
2311 return Response::success( array( 'value' => $get_fn( $id, $key, true ) ) );
2312 }
2313
2314 if ( 'list' === $verb ) {
2315 if ( $id <= 0 ) {
2316 return Response::error( sprintf( 'Usage: %s meta list <id>', $object ) );
2317 }
2318 // Return all meta as { key: value } — single-element arrays are
2319 // unwrapped to the scalar so the LLM sees a clean key/value map.
2320 $all = $get_fn( $id );
2321 if ( ! is_array( $all ) ) {
2322 return Response::success( array() );
2323 }
2324 $out = array();
2325 foreach ( $all as $k => $v ) {
2326 if ( is_array( $v ) && 1 === count( $v ) ) {
2327 $first = reset( $v );
2328 $out[ $k ] = is_string( $first ) ? maybe_unserialize( $first ) : $first;
2329 } else {
2330 $out[ $k ] = $v;
2331 }
2332 }
2333 return Response::success( $out );
2334 }
2335
2336 // Anything else (add/update/set/delete/patch) reaches this only if the
2337 // security check failed to fire — defensive fallback with a clear
2338 // "not supported" rather than the misleading "writes are blocked".
2339 if ( in_array( $verb, array( 'add', 'update', 'set', 'delete', 'patch' ), true ) ) {
2340 return Response::error( sprintf( 'Security policy: %s meta writes are blocked. Use a dedicated ability for the specific meta key.', $object ) );
2341 }
2342 return Response::error( sprintf( 'Unsupported %s meta verb: "%s". Supported: get, list.', $object, $verb ) );
2343 }
2344
2345 // ─── User handlers ──────────────────────────────────────────────────────────
2346
2347 /**
2348 * Handle "user list".
2349 *
2350 * @param array<string,mixed> $flags Parsed flags.
2351 * @return array<string,mixed>
2352 */
2353 private function handle_user_list( array $flags ): array {
2354 list( $per_page, $offset ) = $this->resolve_page_window( $flags, 'number', 50 );
2355 $all_rows = ( -1 === $per_page );
2356 $query_args = array(
2357 'number' => $all_rows ? -1 : $per_page,
2358 'offset' => $all_rows ? 0 : $offset,
2359 'count_total' => true,
2360 );
2361 if ( ! empty( $flags['role'] ) ) {
2362 $query_args['role'] = Utils::to_str( $flags['role'] );
2363 }
2364 if ( ! empty( $flags['search'] ) ) {
2365 $query_args['search'] = '*' . Utils::to_str( $flags['search'] ) . '*';
2366 }
2367
2368 // `--format=count`: total matching users, no rows fetched.
2369 if ( $this->wants_count( $flags ) ) {
2370 $count_query = new \WP_User_Query(
2371 array_merge(
2372 $query_args,
2373 array(
2374 'number' => 1,
2375 'offset' => 0,
2376 'fields' => 'ID',
2377 )
2378 )
2379 );
2380 return Response::success( (int) $count_query->get_total() );
2381 }
2382
2383 // `--format=ids`: flat list of user IDs.
2384 if ( $this->wants_ids( $flags ) ) {
2385 $id_query = new \WP_User_Query( array_merge( $query_args, array( 'fields' => 'ID' ) ) );
2386 $ids = array_map( static fn ( $u ) => is_scalar( $u ) ? (int) $u : 0, $id_query->get_results() );
2387 return $this->paginated_response(
2388 $ids,
2389 (int) $id_query->get_total(),
2390 $all_rows ? max( 1, (int) $id_query->get_total() ) : $per_page,
2391 $all_rows ? 0 : $offset
2392 );
2393 }
2394
2395 $query = new \WP_User_Query( $query_args );
2396 $fields = $this->resolve_fields_flag( $flags, array( 'ID', 'user_login', 'user_email', 'display_name', 'roles' ) );
2397
2398 $out = array();
2399 foreach ( $query->get_results() as $user ) {
2400 if ( ! $user instanceof \WP_User ) {
2401 continue;
2402 }
2403 $out[] = $this->user_to_array( $user, $fields );
2404 }
2405 $total = (int) $query->get_total();
2406 return $this->paginated_response( $out, $total, $all_rows ? max( 1, $total ) : $per_page, $all_rows ? 0 : $offset );
2407 }
2408
2409 /**
2410 * Handle "user get".
2411 *
2412 * @param string[] $positional Remaining positional args.
2413 * @param array<string,mixed> $flags Parsed flags.
2414 * @return array<string,mixed>
2415 */
2416 private function handle_user_get( array $positional, array $flags ) {
2417 $ident = $positional[0] ?? '';
2418 if ( '' === $ident ) {
2419 return Response::error( 'Usage: user get <id-or-login>' );
2420 }
2421 $user = is_numeric( $ident ) ? get_user_by( 'id', (int) $ident ) : get_user_by( 'login', $ident );
2422 if ( ! $user ) {
2423 return Response::error( sprintf( 'User "%s" not found.', $ident ) );
2424 }
2425 $fields = $this->resolve_fields_flag( $flags, null );
2426 return Response::success( $this->user_to_array( $user, $fields ) );
2427 }
2428
2429 /**
2430 * Handle `user add-role|remove-role|set-role <id-or-login> <role>`.
2431 *
2432 * Admin/super-admin roles are refused in `verify_command_security()`;
2433 * by the time we reach here the role is editor/author/contributor/
2434 * subscriber/custom-non-admin.
2435 *
2436 * @param string $action 'add' | 'remove' | 'set'.
2437 * @param string[] $positional Remaining tokens — [id-or-login, role].
2438 * @return array<string,mixed>
2439 */
2440 private function handle_user_role_change( string $action, array $positional ): array {
2441 $ident = $positional[0] ?? '';
2442 $role = strtolower( $positional[1] ?? '' );
2443 if ( '' === $ident || '' === $role ) {
2444 return Response::error( sprintf( 'Usage: user %s-role <id-or-login> <role>', $action ) );
2445 }
2446 // Defense in depth — the security verifier already blocks
2447 // administrator/super-admin, but a future caller (or a verifier
2448 // regression) shouldn't be able to elevate a user here.
2449 if ( in_array( $role, array( 'administrator', 'super-admin' ), true ) ) {
2450 return Response::error( sprintf( 'Security policy: role "%s" cannot be changed via this endpoint.', $role ) );
2451 }
2452 $user = is_numeric( $ident ) ? get_user_by( 'id', (int) $ident ) : get_user_by( 'login', $ident );
2453 if ( ! $user ) {
2454 return Response::error( sprintf( 'User "%s" not found.', $ident ) );
2455 }
2456 // Validate the role exists.
2457 if ( ! get_role( $role ) ) {
2458 return Response::error( sprintf( 'Role "%s" is not registered. Available roles: %s.', $role, implode( ', ', array_keys( wp_roles()->roles ) ) ) );
2459 }
2460
2461 switch ( $action ) {
2462 case 'add':
2463 $user->add_role( $role );
2464 break;
2465 case 'remove':
2466 $user->remove_role( $role );
2467 break;
2468 case 'set':
2469 $user->set_role( $role );
2470 break;
2471 default:
2472 return Response::error( sprintf( 'Unsupported role action: "%s".', $action ) );
2473 }
2474
2475 // Refresh user object for the response.
2476 $refreshed = get_user_by( 'id', $user->ID );
2477 return Response::success(
2478 array(
2479 'ID' => (int) $user->ID,
2480 'login' => $user->user_login,
2481 'roles' => $refreshed instanceof \WP_User ? array_values( $refreshed->roles ) : array(),
2482 'action' => $action,
2483 'role' => $role,
2484 )
2485 );
2486 }
2487
2488 /**
2489 * Handle `user add-cap|remove-cap <id-or-login> <capability>`.
2490 *
2491 * Admin-class capabilities are refused in `verify_command_security()`.
2492 *
2493 * @param string $action 'add' | 'remove'.
2494 * @param string[] $positional [id-or-login, capability].
2495 * @return array<string,mixed>
2496 */
2497 private function handle_user_cap_change( string $action, array $positional ): array {
2498 $ident = $positional[0] ?? '';
2499 $cap = strtolower( $positional[1] ?? '' );
2500 if ( '' === $ident || '' === $cap ) {
2501 return Response::error( sprintf( 'Usage: user %s-cap <id-or-login> <capability>', $action ) );
2502 }
2503 $user = is_numeric( $ident ) ? get_user_by( 'id', (int) $ident ) : get_user_by( 'login', $ident );
2504 if ( ! $user ) {
2505 return Response::error( sprintf( 'User "%s" not found.', $ident ) );
2506 }
2507
2508 if ( 'add' === $action ) {
2509 $user->add_cap( $cap );
2510 } elseif ( 'remove' === $action ) {
2511 $user->remove_cap( $cap );
2512 } else {
2513 return Response::error( sprintf( 'Unsupported cap action: "%s".', $action ) );
2514 }
2515
2516 return Response::success(
2517 array(
2518 'ID' => (int) $user->ID,
2519 'login' => $user->user_login,
2520 'capability' => $cap,
2521 'action' => $action,
2522 'has_now' => user_can( $user->ID, $cap ),
2523 )
2524 );
2525 }
2526
2527 // ─── Menu handlers ──────────────────────────────────────────────────────────
2528
2529 /**
2530 * Handle "menu list".
2531 *
2532 * @param array<string,mixed> $flags Parsed flags.
2533 * @return array<string,mixed>
2534 */
2535 private function handle_menu_list( array $flags ): array {
2536 unset( $flags );
2537 $menus = wp_get_nav_menus();
2538 $out = array();
2539 foreach ( $menus as $m ) {
2540 $out[] = array(
2541 'term_id' => (int) $m->term_id,
2542 'name' => $m->name,
2543 'slug' => $m->slug,
2544 'count' => (int) $m->count,
2545 );
2546 }
2547 return Response::success( $out );
2548 }
2549
2550 /**
2551 * Handle "menu create".
2552 *
2553 * @param string[] $positional Remaining positional args.
2554 * @return array<string,mixed>
2555 */
2556 private function handle_menu_create( array $positional ): array {
2557 $name = $positional[0] ?? '';
2558 if ( '' === $name ) {
2559 return Response::error( 'Usage: menu create <name>' );
2560 }
2561 $id = wp_create_nav_menu( $name );
2562 if ( is_wp_error( $id ) ) {
2563 return Response::error( $id->get_error_message() );
2564 }
2565 return Response::success( array( 'term_id' => (int) $id ) );
2566 }
2567
2568 /**
2569 * Route "menu item ..." subcommands.
2570 *
2571 * @param string[] $positional Remaining positional args after "menu item".
2572 * @param array<string,mixed> $flags Parsed flags.
2573 * @return array<string,mixed>
2574 */
2575 private function route_menu_item( array $positional, array $flags ): array {
2576 $verb = strtolower( $positional[0] ?? '' );
2577
2578 switch ( $verb ) {
2579 case 'list':
2580 $menu = $positional[1] ?? '';
2581 if ( '' === $menu ) {
2582 return Response::error( 'Usage: menu item list <menu>' );
2583 }
2584 $items = wp_get_nav_menu_items( $menu );
2585 if ( false === $items ) {
2586 return Response::error( sprintf( 'Menu "%s" not found.', $menu ) );
2587 }
2588 $out = array();
2589 foreach ( $items as $item ) {
2590 if ( ! is_object( $item ) ) {
2591 continue;
2592 }
2593 /**
2594 * Narrowed type for `$item`.
2595 *
2596 * @var object{db_id:int,type:string,object:string,object_id:int,title:string,url:string,menu_item_parent:int} $item
2597 */
2598 $out[] = array(
2599 'db_id' => Utils::to_int( $item->db_id ),
2600 'type' => $item->type,
2601 'object' => $item->object,
2602 'object_id' => Utils::to_int( $item->object_id ),
2603 'title' => $item->title,
2604 'url' => $item->url,
2605 'parent' => Utils::to_int( $item->menu_item_parent ),
2606 );
2607 }
2608 return Response::success( $out );
2609
2610 case 'add-post':
2611 case 'add_post':
2612 $menu_id = isset( $positional[1] ) ? (int) $positional[1] : 0;
2613 $post_id = isset( $positional[2] ) ? (int) $positional[2] : 0;
2614 if ( $menu_id <= 0 || $post_id <= 0 ) {
2615 return Response::error( 'Usage: menu item add-post <menu-id> <post-id>' );
2616 }
2617 $post = get_post( $post_id );
2618 if ( ! $post ) {
2619 return Response::error( sprintf( 'Post %d not found.', $post_id ) );
2620 }
2621 $item_id = wp_update_nav_menu_item(
2622 $menu_id,
2623 0,
2624 array(
2625 'menu-item-title' => $post->post_title,
2626 'menu-item-object' => $post->post_type,
2627 'menu-item-object-id' => $post_id,
2628 'menu-item-type' => 'post_type',
2629 'menu-item-status' => 'publish',
2630 'menu-item-parent-id' => isset( $flags['parent-id'] ) ? Utils::to_int( $flags['parent-id'] ) : 0,
2631 )
2632 );
2633 if ( is_wp_error( $item_id ) ) {
2634 return Response::error( $item_id->get_error_message() );
2635 }
2636 return Response::success( array( 'menu_item_id' => (int) $item_id ) );
2637 }
2638
2639 return Response::error( sprintf( 'Unknown "menu item" subcommand: "%s"', $verb ) );
2640 }
2641
2642 // ─── Sidebar / widget / cron handlers ───────────────────────────────────────
2643
2644 /**
2645 * Handle "sidebar list".
2646 *
2647 * @return array<string,mixed>
2648 */
2649 private function handle_sidebar_list(): array {
2650 global $wp_registered_sidebars;
2651 $out = array();
2652 foreach ( (array) $wp_registered_sidebars as $id => $sidebar ) {
2653 if ( ! is_array( $sidebar ) ) {
2654 continue;
2655 }
2656 $out[] = array(
2657 'id' => $id,
2658 'name' => $sidebar['name'] ?? '',
2659 'description' => $sidebar['description'] ?? '',
2660 );
2661 }
2662 return Response::success( $out );
2663 }
2664
2665 /**
2666 * Handle "widget list <sidebar-id>".
2667 *
2668 * @param string[] $positional Remaining positional args.
2669 * @return array<string,mixed>
2670 */
2671 private function handle_widget_list( array $positional ): array {
2672 $sidebar_id = $positional[0] ?? '';
2673 if ( '' === $sidebar_id ) {
2674 return Response::error( 'Usage: widget list <sidebar-id>' );
2675 }
2676 // `wp_get_sidebars_widgets()` is internal/private in WP core and is
2677 // blocked by WPCS. Read the `sidebars_widgets` option directly — it
2678 // carries the same sidebar→widgets map.
2679 $map = get_option( 'sidebars_widgets', array() );
2680 if ( ! is_array( $map ) ) {
2681 $map = array();
2682 }
2683 $widgets = isset( $map[ $sidebar_id ] ) && is_array( $map[ $sidebar_id ] ) ? $map[ $sidebar_id ] : array();
2684 return Response::success( array_values( $widgets ) );
2685 }
2686
2687 /**
2688 * Handle "cron event list".
2689 *
2690 * @return array<string,mixed>
2691 */
2692 private function handle_cron_event_list(): array {
2693 $crons = _get_cron_array();
2694 $out = array();
2695 foreach ( $crons as $timestamp => $hooks ) {
2696 foreach ( (array) $hooks as $hook => $dings ) {
2697 foreach ( (array) $dings as $sig => $data ) {
2698 if ( ! is_array( $data ) ) {
2699 continue;
2700 }
2701 $out[] = array(
2702 'hook' => $hook,
2703 'next_run' => (int) $timestamp,
2704 'schedule' => $data['schedule'] ?? false,
2705 'interval' => $data['interval'] ?? null,
2706 'sig' => $sig,
2707 'args_count' => is_array( $data['args'] ?? null ) ? count( $data['args'] ) : 0,
2708 );
2709 }
2710 }
2711 }
2712 return Response::success( $out );
2713 }
2714
2715 // ─── Term handlers ──────────────────────────────────────────────────────────
2716
2717 /**
2718 * Handle "term list <taxonomy>".
2719 *
2720 * @param string[] $positional Remaining positional args (taxonomy at [0]).
2721 * @param array<string,mixed> $flags Parsed flags.
2722 * @return array<string,mixed>
2723 */
2724 private function handle_term_list( array $positional, array $flags ): array {
2725 $taxonomy = Utils::to_str( $positional[0] ?? $flags['taxonomy'] ?? '' );
2726 if ( '' === $taxonomy ) {
2727 return Response::error( 'Usage: term list <taxonomy> [--number=…] [--hide_empty=true|false]' );
2728 }
2729 if ( ! taxonomy_exists( $taxonomy ) ) {
2730 return Response::error( sprintf( 'Taxonomy "%s" is not registered.', $taxonomy ) );
2731 }
2732
2733 $hide_empty = isset( $flags['hide_empty'] )
2734 ? filter_var( $flags['hide_empty'], FILTER_VALIDATE_BOOLEAN )
2735 : false;
2736
2737 $filter_args = array(
2738 'taxonomy' => $taxonomy,
2739 'hide_empty' => $hide_empty,
2740 );
2741 if ( isset( $flags['parent'] ) ) {
2742 $filter_args['parent'] = Utils::to_int( $flags['parent'] );
2743 }
2744
2745 // Total matching terms across every page — used for `--format=count`
2746 // and the pagination envelope.
2747 $count = wp_count_terms( $filter_args );
2748 $total = is_wp_error( $count ) ? 0 : (int) $count;
2749 if ( $this->wants_count( $flags ) ) {
2750 return Response::success( $total );
2751 }
2752
2753 // Terms return ALL matches unless a page size is given (mirrors
2754 // `wp term list`); `--per_page=-1` is the explicit "all" idiom. When a
2755 // positive size is given, page deterministically. per_page === 0 means
2756 // "no limit" for get_terms.
2757 $size_raw = $flags['number'] ?? $flags['per_page'] ?? null;
2758 if ( null === $size_raw || Utils::to_int( $size_raw ) < 0 ) {
2759 $per_page = 0;
2760 } else {
2761 $per_page = max( 1, min( Utils::to_int( $size_raw ), 1000 ) );
2762 }
2763 if ( isset( $flags['offset'] ) ) {
2764 $offset = max( 0, Utils::to_int( $flags['offset'] ) );
2765 } elseif ( $per_page && isset( $flags['page'] ) ) {
2766 $offset = ( max( 1, Utils::to_int( $flags['page'] ) ) - 1 ) * $per_page;
2767 } else {
2768 $offset = 0;
2769 }
2770
2771 $query_args = $filter_args;
2772 if ( $per_page ) {
2773 $query_args['number'] = $per_page;
2774 $query_args['offset'] = $offset;
2775 }
2776
2777 // `--format=ids`: flat list of term IDs.
2778 if ( $this->wants_ids( $flags ) ) {
2779 $ids = get_terms( array_merge( $query_args, array( 'fields' => 'ids' ) ) );
2780 if ( is_wp_error( $ids ) ) {
2781 return Response::error( $ids->get_error_message() );
2782 }
2783 $ids = array_map( 'intval', $ids );
2784 return $this->paginated_response( $ids, $total, $per_page ? $per_page : max( 1, count( $ids ) ), $offset );
2785 }
2786
2787 $terms = get_terms( $query_args );
2788 if ( is_wp_error( $terms ) ) {
2789 return Response::error( $terms->get_error_message() );
2790 }
2791
2792 $out = array();
2793 foreach ( $terms as $term ) {
2794 $out[] = array(
2795 'term_id' => (int) $term->term_id,
2796 'name' => $term->name,
2797 'slug' => $term->slug,
2798 'count' => (int) $term->count,
2799 'taxonomy' => $term->taxonomy,
2800 'description' => $term->description,
2801 'parent' => (int) $term->parent,
2802 );
2803 }
2804 return $this->paginated_response( $out, $total, $per_page ? $per_page : max( 1, count( $out ) ), $offset );
2805 }
2806
2807 // ─── Schema discovery handlers (post-type / taxonomy list) ──────────────────
2808
2809 /**
2810 * Handle `post-type list [--public=true|false] [--hierarchical=true|false]
2811 * [--show_in_menu=…] [--show_in_rest=…] [--format=…]`.
2812 *
2813 * Mirrors WP-CLI's `wp post-type list` — returns one row per registered
2814 * post type with the fields the LLM most commonly needs to decide
2815 * downstream operations (which post type to query, whether it's REST-
2816 * exposed, what its rest_base is for /wp/v2/<base>).
2817 *
2818 * @param array<string,mixed> $flags Parsed flags.
2819 * @return array<string,mixed>
2820 */
2821 private function handle_post_type_list( array $flags ): array {
2822 $query = array();
2823 $bool_filters = array( 'public', 'hierarchical', 'show_in_menu', 'show_in_rest', 'show_ui', 'has_archive' );
2824 foreach ( $bool_filters as $key ) {
2825 if ( isset( $flags[ $key ] ) ) {
2826 $query[ $key ] = filter_var( $flags[ $key ], FILTER_VALIDATE_BOOLEAN );
2827 }
2828 }
2829
2830 $types = get_post_types( $query, 'objects' );
2831
2832 $out = array();
2833 foreach ( $types as $type ) {
2834 /**
2835 * Narrowed type for `$cap_type`.
2836 *
2837 * @var string|array<int,string> $cap_type
2838 */
2839 $cap_type = $type->capability_type;
2840 $out[] = array(
2841 'name' => $type->name,
2842 'label' => $type->label,
2843 'description' => $type->description,
2844 'public' => (bool) $type->public,
2845 'hierarchical' => (bool) $type->hierarchical,
2846 'show_in_menu' => is_bool( $type->show_in_menu ) ? $type->show_in_menu : (bool) $type->show_in_menu,
2847 'show_in_rest' => (bool) $type->show_in_rest,
2848 'rest_base' => $type->rest_base ? $type->rest_base : $type->name,
2849 'has_archive' => is_bool( $type->has_archive ) ? $type->has_archive : (bool) $type->has_archive,
2850 'capability_type' => is_array( $cap_type ) ? implode( ',', $cap_type ) : (string) $cap_type,
2851 '_builtin' => (bool) $type->_builtin,
2852 );
2853 }
2854 return Response::success( $out );
2855 }
2856
2857 /**
2858 * Handle `taxonomy list [--public=true|false] [--hierarchical=…]
2859 * [--object_type=<post_type>] [--format=…]`.
2860 *
2861 * Mirrors WP-CLI's `wp taxonomy list`.
2862 *
2863 * @param array<string,mixed> $flags Parsed flags.
2864 * @return array<string,mixed>
2865 */
2866 private function handle_taxonomy_list( array $flags ): array {
2867 $query = array();
2868 $bool_filters = array( 'public', 'hierarchical', 'show_in_menu', 'show_in_rest', 'show_ui' );
2869 foreach ( $bool_filters as $key ) {
2870 if ( isset( $flags[ $key ] ) ) {
2871 $query[ $key ] = filter_var( $flags[ $key ], FILTER_VALIDATE_BOOLEAN );
2872 }
2873 }
2874
2875 $object_type_filter = isset( $flags['object_type'] ) ? Utils::to_str( $flags['object_type'] ) : '';
2876
2877 $taxonomies = get_taxonomies( $query, 'objects' );
2878
2879 $out = array();
2880 foreach ( $taxonomies as $tax ) {
2881 // --object_type filters down to taxonomies attached to that post type.
2882 if ( '' !== $object_type_filter && ! in_array( $object_type_filter, $tax->object_type, true ) ) {
2883 continue;
2884 }
2885 $out[] = array(
2886 'name' => $tax->name,
2887 'label' => $tax->label,
2888 'description' => $tax->description,
2889 'public' => (bool) $tax->public,
2890 'hierarchical' => (bool) $tax->hierarchical,
2891 'object_type' => array_values( $tax->object_type ),
2892 'show_in_rest' => (bool) $tax->show_in_rest,
2893 'rest_base' => $tax->rest_base ? $tax->rest_base : $tax->name,
2894 '_builtin' => (bool) $tax->_builtin,
2895 );
2896 }
2897 return Response::success( $out );
2898 }
2899
2900 // ─── Diagnostics: role / db / rewrite / cron / env ──────────────────────────
2901
2902 /**
2903 * Handle `role list [--fields=role,name,capabilities] [--format=json]`.
2904 *
2905 * Mirrors `wp role list`. Returns one row per registered role with a
2906 * count of granted capabilities and a `builtin` flag for the WP core
2907 * roles. Use `role list-caps <role>` for the full cap set.
2908 *
2909 * @param array<string,mixed> $flags Parsed flags (unused except format passthrough).
2910 * @return array<string,mixed>
2911 */
2912 private function handle_role_list( array $flags ): array {
2913 unset( $flags );
2914 $roles_obj = wp_roles();
2915 $builtin = array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' );
2916 $out = array();
2917 foreach ( $roles_obj->roles as $slug => $info ) {
2918 $caps_array = isset( $info['capabilities'] ) && is_array( $info['capabilities'] ) ? $info['capabilities'] : array();
2919 // Only count caps explicitly granted (true). WordPress stores
2920 // removed caps as `false`, which `array_filter` strips out.
2921 $granted = array_filter( $caps_array );
2922 $out[] = array(
2923 'role' => $slug,
2924 'name' => isset( $info['name'] ) ? translate_user_role( Utils::to_str( $info['name'] ) ) : $slug,
2925 'capabilities' => count( $granted ),
2926 'builtin' => in_array( $slug, $builtin, true ),
2927 );
2928 }
2929 return Response::success( $out );
2930 }
2931
2932 /**
2933 * Handle `role list-caps <role>`.
2934 *
2935 * Returns the capability set explicitly granted to a role. Capabilities
2936 * stored as `false` (explicit removals) are omitted from the list to
2937 * match WP-CLI's `wp role list-caps` behaviour.
2938 *
2939 * @param string[] $positional Remaining tokens after `role list-caps`.
2940 * @return array<string,mixed>
2941 */
2942 private function handle_role_list_caps( array $positional ): array {
2943 $slug = strtolower( $positional[0] ?? '' );
2944 if ( '' === $slug ) {
2945 return Response::error( 'Usage: role list-caps <role>' );
2946 }
2947 $role = get_role( $slug );
2948 if ( ! $role instanceof \WP_Role ) {
2949 return Response::error( sprintf( 'Role "%s" is not registered. Use "role list" to see available roles.', $slug ) );
2950 }
2951 $granted = array_keys( array_filter( $role->capabilities ) );
2952 sort( $granted );
2953 return Response::success(
2954 array(
2955 'role' => $slug,
2956 'capabilities' => $granted,
2957 'count' => count( $granted ),
2958 )
2959 );
2960 }
2961
2962 /**
2963 * Handle `db size [--tables] [--human-readable]`.
2964 *
2965 * Read-only summary built from SHOW TABLE STATUS. Honors the wpdb
2966 * prefix so multisite blogs only count their own tables when invoked
2967 * inside a subsite context. Returns total size + optional per-table
2968 * breakdown when --tables is set.
2969 *
2970 * No arbitrary SQL is exposed; the verifier's `db query/import/...`
2971 * block remains in effect. This is a bounded read.
2972 *
2973 * @param array<string,mixed> $flags Parsed flags.
2974 * @return array<string,mixed>
2975 */
2976 private function handle_db_size( array $flags ): array {
2977 global $wpdb;
2978 /**
2979 * Narrowed type for `$wpdb`.
2980 *
2981 * @var \wpdb $wpdb
2982 */
2983 $show_tables = ! empty( $flags['tables'] );
2984 $human = ! empty( $flags['human-readable'] ) || ! empty( $flags['human_readable'] );
2985 $prefix_like = $wpdb->esc_like( $wpdb->prefix ) . '%';
2986
2987 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
2988 $rows = $wpdb->get_results( $wpdb->prepare( 'SHOW TABLE STATUS LIKE %s', $prefix_like ), ARRAY_A );
2989 if ( ! is_array( $rows ) ) {
2990 return Response::error( 'Failed to read table status from MySQL.' );
2991 }
2992
2993 $total = 0;
2994 $tables_out = array();
2995 foreach ( $rows as $row ) {
2996 $data_len = isset( $row['Data_length'] ) ? Utils::to_int( $row['Data_length'] ) : 0;
2997 $index_len = isset( $row['Index_length'] ) ? Utils::to_int( $row['Index_length'] ) : 0;
2998 $size = $data_len + $index_len;
2999 $total += $size;
3000 if ( $show_tables ) {
3001 $entry = array(
3002 'name' => $row['Name'] ?? '',
3003 'rows' => isset( $row['Rows'] ) ? Utils::to_int( $row['Rows'] ) : null,
3004 'bytes' => $size,
3005 );
3006 if ( $human ) {
3007 $entry['size'] = size_format( $size, 2 );
3008 }
3009 $tables_out[] = $entry;
3010 }
3011 }
3012
3013 if ( $show_tables ) {
3014 // Sort descending by bytes — largest tables first, which is
3015 // what someone debugging size issues actually wants to see.
3016 usort(
3017 $tables_out,
3018 static function ( $a, $b ) {
3019 return $b['bytes'] - $a['bytes'];
3020 }
3021 );
3022 }
3023
3024 $payload = array(
3025 'total_bytes' => $total,
3026 );
3027 if ( $human ) {
3028 $payload['total_size'] = size_format( $total, 2 );
3029 }
3030 if ( $show_tables ) {
3031 $payload['tables'] = $tables_out;
3032 }
3033 return Response::success( $payload );
3034 }
3035
3036 /**
3037 * Handle `rewrite list`.
3038 *
3039 * Returns the cached rewrite_rules option as an array of
3040 * { match, query } entries. Useful for debugging "why is /foo/ returning
3041 * 404?" or "what URL pattern resolves to which post/term?".
3042 *
3043 * @return array<string,mixed>
3044 */
3045 private function handle_rewrite_list(): array {
3046 $rules = get_option( 'rewrite_rules' );
3047 if ( ! is_array( $rules ) || empty( $rules ) ) {
3048 return Response::success(
3049 array(
3050 'count' => 0,
3051 'rules' => array(),
3052 'note' => 'No rewrite rules cached. Try `rewrite flush` to regenerate them.',
3053 )
3054 );
3055 }
3056 $out = array();
3057 foreach ( $rules as $match => $query ) {
3058 $out[] = array(
3059 'match' => (string) $match,
3060 'query' => Utils::to_str( $query ),
3061 );
3062 }
3063 return Response::success(
3064 array(
3065 'count' => count( $out ),
3066 'rules' => $out,
3067 )
3068 );
3069 }
3070
3071 /**
3072 * Handle `cron schedule list`.
3073 *
3074 * Returns every registered cron interval (`hourly`, `daily`, `twicedaily`,
3075 * `weekly`, plus any custom schedules registered via the
3076 * `cron_schedules` filter). Used when scheduling a new cron event needs
3077 * to pick a valid recurrence.
3078 *
3079 * @return array<string,mixed>
3080 */
3081 private function handle_cron_schedule_list(): array {
3082 $schedules = wp_get_schedules();
3083 $out = array();
3084 foreach ( $schedules as $slug => $info ) {
3085 $out[] = array(
3086 'name' => (string) $slug,
3087 'display' => (string) $info['display'],
3088 'interval' => (int) $info['interval'],
3089 );
3090 }
3091 usort(
3092 $out,
3093 static function ( $a, $b ) {
3094 return $a['interval'] - $b['interval'];
3095 }
3096 );
3097 return Response::success( $out );
3098 }
3099
3100 /**
3101 * Handle `env`.
3102 *
3103 * Synthesised environment / diagnostic report covering the questions
3104 * users ask most often during troubleshooting: which PHP / MySQL /
3105 * WordPress are running, where is the install, multisite status,
3106 * debug flags, file-mod gates, memory limits, active locale.
3107 *
3108 * Read-only. Does NOT expose secrets (DB password, auth keys, etc.).
3109 *
3110 * @return array<string,mixed>
3111 */
3112 private function handle_env(): array {
3113 global $wpdb;
3114 /**
3115 * Narrowed type for `$wpdb`.
3116 *
3117 * @var \wpdb $wpdb
3118 */
3119 $mysql_version = '';
3120 if ( isset( $wpdb->dbh ) ) {
3121 // $wpdb exposes a public helper since WP 5.1.
3122 $mysql_version = (string) $wpdb->db_version();
3123 }
3124
3125 return Response::success(
3126 array(
3127 'wp' => array(
3128 'version' => get_bloginfo( 'version' ),
3129 'site_url' => get_option( 'siteurl' ),
3130 'home_url' => get_option( 'home' ),
3131 'abspath' => defined( 'ABSPATH' ) ? ABSPATH : '',
3132 'language' => get_locale(),
3133 'multisite' => is_multisite(),
3134 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG,
3135 'debug_log' => defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG,
3136 'script_debug' => defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG,
3137 'disallow_file_mods' => ! wp_is_file_mod_allowed( 'zipai_env_report' ),
3138 'disallow_file_edit' => defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT,
3139 'fs_method' => defined( 'FS_METHOD' ) ? Utils::to_str( constant( 'FS_METHOD' ) ) : '',
3140 'memory_limit' => defined( 'WP_MEMORY_LIMIT' ) ? WP_MEMORY_LIMIT : '',
3141 'max_memory' => defined( 'WP_MAX_MEMORY_LIMIT' ) ? WP_MAX_MEMORY_LIMIT : '',
3142 'table_prefix' => $wpdb->prefix,
3143 ),
3144 'php' => array(
3145 'version' => PHP_VERSION,
3146 'sapi' => PHP_SAPI,
3147 'memory_limit' => (string) ini_get( 'memory_limit' ),
3148 'max_execution_time' => (int) ini_get( 'max_execution_time' ),
3149 'upload_max_filesize' => (string) ini_get( 'upload_max_filesize' ),
3150 'post_max_size' => (string) ini_get( 'post_max_size' ),
3151 'extensions' => array(
3152 'curl' => extension_loaded( 'curl' ),
3153 'gd' => extension_loaded( 'gd' ),
3154 'imagick' => extension_loaded( 'imagick' ),
3155 'mbstring' => extension_loaded( 'mbstring' ),
3156 'mysqli' => extension_loaded( 'mysqli' ),
3157 'openssl' => extension_loaded( 'openssl' ),
3158 'sodium' => extension_loaded( 'sodium' ),
3159 'zip' => extension_loaded( 'zip' ),
3160 ),
3161 ),
3162 'mysql' => array(
3163 'version' => $mysql_version,
3164 ),
3165 'theme' => array(
3166 'stylesheet' => Utils::to_str( get_option( 'stylesheet' ) ),
3167 'template' => Utils::to_str( get_option( 'template' ) ),
3168 ),
3169 )
3170 );
3171 }
3172
3173 /**
3174 * Handle `core check-update [--format=json]`.
3175 *
3176 * Reports core updates WordPress already knows about, read from the
3177 * cached `update_core` site transient that core's own scheduled
3178 * `wp_version_check()` populates. It does NOT force a live wp.org
3179 * request: a web/REST request must stay fast and cannot block on an
3180 * outbound call, and WordPress's own Site Health screen reads the
3181 * same cached data. Returns an empty `updates` list when the install
3182 * is up to date (or when the cache hasn't been primed yet).
3183 *
3184 * Mirrors the useful half of `wp core check-update`.
3185 *
3186 * @return array<string,mixed>
3187 */
3188 private function handle_core_check_update(): array {
3189 if ( ! function_exists( 'get_core_updates' ) ) {
3190 require_once ABSPATH . 'wp-admin/includes/update.php';
3191 }
3192
3193 $current = get_bloginfo( 'version' );
3194 $updates = function_exists( 'get_core_updates' ) ? get_core_updates() : array();
3195 if ( ! is_array( $updates ) ) {
3196 $updates = array();
3197 }
3198
3199 $available = array();
3200 foreach ( $updates as $update ) {
3201 if ( ! is_object( $update ) ) {
3202 continue;
3203 }
3204 // `response === 'upgrade'` is the only state that offers a newer
3205 // core build; 'latest' / 'development' rows are not actionable.
3206 if ( ! isset( $update->response ) || 'upgrade' !== $update->response ) {
3207 continue;
3208 }
3209 $offered = isset( $update->current ) ? Utils::to_str( $update->current ) : '';
3210 if ( '' !== $offered && version_compare( $offered, $current, '<=' ) ) {
3211 continue;
3212 }
3213 $available[] = array(
3214 'version' => $offered,
3215 'locale' => isset( $update->locale ) ? Utils::to_str( $update->locale, 'en_US' ) : 'en_US',
3216 'package' => ( isset( $update->packages ) && is_object( $update->packages ) && isset( $update->packages->full ) ) ? Utils::to_str( $update->packages->full ) : '',
3217 'php_version' => isset( $update->php_version ) ? Utils::to_str( $update->php_version ) : '',
3218 'mysql_version' => isset( $update->mysql_version ) ? Utils::to_str( $update->mysql_version ) : '',
3219 );
3220 }
3221
3222 return Response::success(
3223 array(
3224 'current_version' => $current,
3225 'update_available' => ! empty( $available ),
3226 'updates' => $available,
3227 'note' => empty( $updates )
3228 ? 'Core update cache is empty — WordPress has not run its scheduled version check yet. "update_available: false" here means "no known update", not a guaranteed up-to-date result.'
3229 : '',
3230 )
3231 );
3232 }
3233
3234 /**
3235 * Handle `cli info`.
3236 *
3237 * There is no WP-CLI binary in web/REST context, so report the PHP and
3238 * WordPress runtime the request is actually executing under — the half
3239 * of `wp cli info` that is meaningful here. Keeps callers that probe
3240 * `cli info` for runtime facts (e.g. a Site Health snapshot) working
3241 * instead of erroring out on the default "not available" branch.
3242 *
3243 * @return array<string,mixed>
3244 */
3245 private function handle_cli_info(): array {
3246 return Response::success(
3247 array(
3248 'php_binary' => defined( 'PHP_BINARY' ) ? PHP_BINARY : '',
3249 'php_version' => PHP_VERSION,
3250 'php_sapi' => PHP_SAPI,
3251 'wp_version' => get_bloginfo( 'version' ),
3252 'wp_cli' => false,
3253 'context' => 'web-rest',
3254 'note' => 'Running in web/REST context — no WP-CLI runtime. Reported values reflect the PHP/WordPress process serving this request.',
3255 )
3256 );
3257 }
3258
3259 // ─── Search-replace handler ─────────────────────────────────────────────────
3260 // `handle_search_replace()` and its serialized-PHP walker live in
3261 // Search_Replace_Engine_Trait (search-replace-engine-trait.php).
3262
3263 // ─── Shared helpers ─────────────────────────────────────────────────────────
3264
3265 /**
3266 * Split parsed args into positional tokens and --flag map.
3267 *
3268 * Supports `--key=value`, `--key value`, and boolean `--key`.
3269 *
3270 * @param string[] $args Parsed command tokens.
3271 * @return array{0:string[],1:array<string,bool|string>}
3272 */
3273 private function parse_flags( array $args ): array {
3274 $positional = array();
3275 $flags = array();
3276 $pending_key = null;
3277 $value_flags = array(
3278 'status',
3279 'format',
3280 'fields',
3281 'post_type',
3282 'post_status',
3283 'posts_per_page',
3284 'per_page',
3285 'page',
3286 'offset',
3287 'orderby',
3288 'order',
3289 's',
3290 'search',
3291 'number',
3292 'role',
3293 'hide_empty',
3294 'expiration',
3295 'autoload',
3296 'reassign',
3297 'parent-id',
3298 'post_title',
3299 'post_content',
3300 'post_name',
3301 'post_excerpt',
3302 'post_author',
3303 'menu_order',
3304 'slug',
3305 'parent',
3306 'description',
3307 'name',
3308 'user_login',
3309 'user_email',
3310 'user_pass',
3311 'first_name',
3312 'last_name',
3313 'display_name',
3314 'limit',
3315 'skip-columns',
3316 'include-columns',
3317 'taxonomy',
3318 // comment list filters + create/update fields.
3319 'post_id',
3320 'type',
3321 'comment_post_ID',
3322 'comment_content',
3323 'comment_author',
3324 'comment_author_email',
3325 'comment_author_url',
3326 'comment_approved',
3327 'comment_parent',
3328 'comment_type',
3329 'comment_date',
3330 'user_id',
3331 // post-type / taxonomy schema-discovery filters.
3332 'public',
3333 'hierarchical',
3334 'show_in_menu',
3335 'show_in_rest',
3336 'show_ui',
3337 'has_archive',
3338 'object_type',
3339 );
3340
3341 foreach ( $args as $arg ) {
3342 if ( null !== $pending_key ) {
3343 $flags[ $pending_key ] = $arg;
3344 $pending_key = null;
3345 continue;
3346 }
3347 if ( str_starts_with( $arg, '--' ) ) {
3348 $body = substr( $arg, 2 );
3349 if ( str_contains( $body, '=' ) ) {
3350 list( $k, $v ) = explode( '=', $body, 2 );
3351 $flags[ $k ] = $v;
3352 } elseif ( in_array( $body, $value_flags, true ) ) {
3353 // Value follows in the next token.
3354 $pending_key = $body;
3355 } else {
3356 $flags[ $body ] = true;
3357 }
3358 continue;
3359 }
3360 $positional[] = $arg;
3361 }
3362
3363 if ( null !== $pending_key ) {
3364 // Trailing `--key` with no value — treat as boolean true.
3365 $flags[ $pending_key ] = true;
3366 }
3367
3368 return array( $positional, $flags );
3369 }
3370
3371 /**
3372 * Normalise the --fields flag.
3373 *
3374 * @param array<string,mixed> $flags Parsed flags.
3375 * @param string[]|null $default Default field set, or null for "all".
3376 * @return string[]|null
3377 */
3378 private function resolve_fields_flag( array $flags, ?array $default ): ?array {
3379 if ( empty( $flags['fields'] ) ) {
3380 return $default;
3381 }
3382 $raw = is_array( $flags['fields'] ) ? $flags['fields'] : explode( ',', Utils::to_str( $flags['fields'] ) );
3383 return array_values( array_filter( array_map( static fn ( $v ) => trim( is_scalar( $v ) ? (string) $v : '' ), $raw ), static fn ( string $s ): bool => '' !== $s ) );
3384 }
3385
3386 /**
3387 * Serialise a WP_Post to a JSON-friendly array.
3388 *
3389 * @param \WP_Post $post Post.
3390 * @param string[]|null $fields Field subset; null for all.
3391 * @return array<string,mixed>
3392 */
3393 private function post_to_array( \WP_Post $post, ?array $fields ): array {
3394 $all = array(
3395 'ID' => (int) $post->ID,
3396 'post_title' => $post->post_title,
3397 'post_status' => $post->post_status,
3398 'post_type' => $post->post_type,
3399 'post_date' => $post->post_date,
3400 'post_modified' => $post->post_modified,
3401 'post_author' => (int) $post->post_author,
3402 'post_parent' => (int) $post->post_parent,
3403 'post_name' => $post->post_name,
3404 'post_excerpt' => $post->post_excerpt,
3405 'menu_order' => (int) $post->menu_order,
3406 'comment_status' => $post->comment_status,
3407 'ping_status' => $post->ping_status,
3408 'guid' => $post->guid,
3409 );
3410 if ( null === $fields ) {
3411 return $all;
3412 }
3413 $out = array();
3414 foreach ( $fields as $f ) {
3415 $out[ $f ] = $all[ $f ] ?? null;
3416 }
3417 return $out;
3418 }
3419
3420 /**
3421 * Serialise a WP_User to a JSON-friendly array.
3422 *
3423 * @param \WP_User $user User.
3424 * @param string[]|null $fields Field subset; null for all safe fields.
3425 * @return array<string,mixed>
3426 */
3427 private function user_to_array( \WP_User $user, ?array $fields ): array {
3428 $all = array(
3429 'ID' => (int) $user->ID,
3430 'user_login' => $user->user_login,
3431 'user_email' => $user->user_email,
3432 'user_nicename' => $user->user_nicename,
3433 'display_name' => $user->display_name,
3434 'user_registered' => $user->user_registered,
3435 'roles' => $user->roles,
3436 );
3437 if ( null === $fields ) {
3438 return $all;
3439 }
3440 $out = array();
3441 foreach ( $fields as $f ) {
3442 $out[ $f ] = $all[ $f ] ?? null;
3443 }
3444 return $out;
3445 }
3446
3447 /**
3448 * Translate WP-CLI-style --flags into a wp_insert_post/wp_update_post array.
3449 * Skips --post_content (blocked by `verify_command_security`).
3450 *
3451 * @param array<string,mixed> $flags Parsed flags.
3452 * @return array{post_title?:string,post_status?:string,post_type?:string,post_name?:string,post_excerpt?:string,post_author?:int,post_parent?:int,menu_order?:int,comment_status?:string,ping_status?:string,post_date?:string,post_password?:string}
3453 */
3454 private function flags_to_post_data( array $flags ): array {
3455 $out = array();
3456 if ( array_key_exists( 'post_title', $flags ) ) {
3457 $out['post_title'] = Utils::to_str( $flags['post_title'] );
3458 }
3459 if ( array_key_exists( 'post_status', $flags ) ) {
3460 $out['post_status'] = Utils::to_str( $flags['post_status'] );
3461 }
3462 if ( array_key_exists( 'post_type', $flags ) ) {
3463 $out['post_type'] = Utils::to_str( $flags['post_type'] );
3464 }
3465 if ( array_key_exists( 'post_name', $flags ) ) {
3466 $out['post_name'] = Utils::to_str( $flags['post_name'] );
3467 }
3468 if ( array_key_exists( 'post_excerpt', $flags ) ) {
3469 $out['post_excerpt'] = Utils::to_str( $flags['post_excerpt'] );
3470 }
3471 if ( array_key_exists( 'post_author', $flags ) ) {
3472 $out['post_author'] = Utils::to_int( $flags['post_author'] );
3473 }
3474 if ( array_key_exists( 'post_parent', $flags ) ) {
3475 $out['post_parent'] = Utils::to_int( $flags['post_parent'] );
3476 }
3477 if ( array_key_exists( 'menu_order', $flags ) ) {
3478 $out['menu_order'] = Utils::to_int( $flags['menu_order'] );
3479 }
3480 if ( array_key_exists( 'comment_status', $flags ) ) {
3481 $out['comment_status'] = Utils::to_str( $flags['comment_status'] );
3482 }
3483 if ( array_key_exists( 'ping_status', $flags ) ) {
3484 $out['ping_status'] = Utils::to_str( $flags['ping_status'] );
3485 }
3486 if ( array_key_exists( 'post_date', $flags ) ) {
3487 $out['post_date'] = Utils::to_str( $flags['post_date'] );
3488 }
3489 if ( array_key_exists( 'post_password', $flags ) ) {
3490 $out['post_password'] = Utils::to_str( $flags['post_password'] );
3491 }
3492 return $out;
3493 }
3494
3495 // ─── Command parsing ────────────────────────────────────────────────────────
3496 // `parse_command_to_args()` and `parse_output()` live in
3497 // Command_Parser_Trait (command-parser-trait.php).
3498 }
3499