Settings fixes: - MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings table (DB > ENV > default), so dashboard/settings changes apply without restart. Sync cache refreshed on every save or delete. - /api/me reports the live DB value for max_sites_per_user. Admin improvements: - Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS). - Admin Overview now shows platform stats: registered users, new users (7d), total user sites, available tools. Plugin cleanup: - Appwrite and Directus plugins removed from the active registry (8 plugins now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase, OpenPanel, Coolify). Plugin code is retained for future re-enabling. - Settings page plugin visibility list updated to match. Mobile onboarding: - Stepper steps on narrow viewports stack vertically with correct full border and rounded corners on each step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
359 lines
17 KiB
Python
359 lines
17 KiB
Python
"""WordPress Specialist Plugin (F.19) — companion-backed advanced
|
|
management for WordPress professionals.
|
|
|
|
The companion-backed surface that replaced the deprecated
|
|
``wordpress_advanced`` plugin (sunset 2026-05-04 in F.19.3.2-.3):
|
|
|
|
* ``wordpress_specialist`` — plugins / themes / users / options / cron /
|
|
maintenance / page editing / site config + layout / db inspection /
|
|
bulk fan-out via the Airano MCP Bridge companion plugin. Requires
|
|
only ``url`` + ``username`` + ``app_password`` (the WP user needs
|
|
``manage_options``). No Docker socket dependency.
|
|
|
|
Tool surface (51 tools across read / editor / install / settings /
|
|
admin tiers — see ``get_tool_specifications`` for the per-section
|
|
breakdown). Plugin remains admin-only by default — not in
|
|
``ENABLED_PLUGINS`` until F.19.4 (companion v3.0 wp.org republish)
|
|
closes the certification loop.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from plugins.base import BasePlugin
|
|
from plugins.wordpress.client import WordPressClient
|
|
from plugins.wordpress_specialist import handlers
|
|
|
|
|
|
class WordPressSpecialistPlugin(BasePlugin):
|
|
"""Advanced WordPress management for specialists, companion-backed."""
|
|
|
|
@staticmethod
|
|
def get_plugin_name() -> str:
|
|
return "wordpress_specialist"
|
|
|
|
@staticmethod
|
|
def get_required_config_keys() -> list[str]:
|
|
# No ``container`` — this plugin never shells into WP-CLI.
|
|
return ["url", "username", "app_password"]
|
|
|
|
def __init__(self, config: dict[str, Any], project_id: str | None = None) -> None:
|
|
super().__init__(config, project_id=project_id)
|
|
|
|
self.client = WordPressClient(
|
|
site_url=config["url"],
|
|
username=config["username"],
|
|
app_password=config["app_password"],
|
|
)
|
|
|
|
# F.19.1: read-only management handler. F.19.2 will introduce
|
|
# additional handlers (e.g. installer.py, users_admin.py) but
|
|
# all will share self.client and stay companion-backed.
|
|
self.management = handlers.ManagementHandler(self.client)
|
|
# F.19.5: page editing handler — Gutenberg blocks + Elementor
|
|
# + Classic. Companion-backed except for the two read paths
|
|
# that go through stock REST.
|
|
self.pages = handlers.PagesHandler(self.client)
|
|
# F.19.7: theme dev surface — install + activate + delete +
|
|
# file CRUD. Companion-backed (Airano MCP Bridge v2.14.0+).
|
|
self.themes = handlers.ThemesHandler(self.client)
|
|
# F.19.2.1: plugin write management — install / activate /
|
|
# deactivate / update / delete. Companion-backed (Airano MCP
|
|
# Bridge v2.15.0+). First handler to use the install + admin
|
|
# tiers introduced by F.19.2.0.
|
|
self.plugins = handlers.PluginsHandler(self.client)
|
|
# F.19.6.A: site config — identity / reading / permalinks.
|
|
# Companion-backed (Airano MCP Bridge v2.16.0+). First consumer
|
|
# of the ``settings`` tier introduced by F.19.2.0.
|
|
self.site_config = handlers.SiteConfigHandler(self.client)
|
|
# F.19.6.B: site layout — menus / widgets / customizer.
|
|
# Companion-backed (Airano MCP Bridge v2.17.0+). Same
|
|
# ``settings`` tier as site config — closes the Settings →
|
|
# Menus + Appearance → Widgets + Customizer gaps.
|
|
self.site_layout = handlers.SiteLayoutHandler(self.client)
|
|
# F.19.3.2-.3: database inspection (companion v2.18.0+) — three
|
|
# read-only tools (db/size, db/tables, db/search). Bundled with
|
|
# the wordpress_advanced sunset; no SQL exposure (S-25).
|
|
self.database = handlers.DatabaseHandler(self.client)
|
|
# F.19.3.2-.3: bulk fan-out — post + term updates via stock
|
|
# REST batch. No companion route needed; per-item permission
|
|
# checks happen at the WP layer (S-26 caps at 50 items/call).
|
|
self.bulk = handlers.BulkHandler(self.client)
|
|
|
|
@staticmethod
|
|
def get_tool_specifications() -> list[dict[str, Any]]:
|
|
"""Return all tool specs.
|
|
|
|
Currently exposes:
|
|
* F.19.1 read surface — 6 tools (plugins/themes/users/options/
|
|
cron/maintenance)
|
|
* F.19.3.1 ports — 3 tools (system_info / phpinfo / disk_usage)
|
|
* F.19.5 page editing — 11 tools (4 Gutenberg + 6 Elementor + 1
|
|
Classic)
|
|
* F.19.7 theme dev surface — 7 tools (3 management + 4 file CRUD)
|
|
* F.19.2.1 plugin write management — 6 tools (4 install-tier +
|
|
2 admin-tier)
|
|
* F.19.6.A site config — 6 tools (identity + reading + permalinks)
|
|
* F.19.6.B site layout — 7 tools (3 menu + 3 widget + 1 customizer)
|
|
* F.19.3.2 database inspection — 3 tools (db/size + db/tables + db/search)
|
|
* F.19.3.3 bulk fan-out — 2 tools (post + term updates)
|
|
|
|
Total: 51 tools.
|
|
"""
|
|
return [
|
|
*handlers.get_management_specs(),
|
|
*handlers.get_pages_specs(),
|
|
*handlers.get_themes_specs(),
|
|
*handlers.get_plugins_specs(),
|
|
*handlers.get_site_config_specs(),
|
|
*handlers.get_site_layout_specs(),
|
|
*handlers.get_database_specs(),
|
|
*handlers.get_bulk_specs(),
|
|
]
|
|
|
|
async def health_check(self) -> dict[str, Any]:
|
|
"""Probe the companion's admin namespace via the cheapest route.
|
|
|
|
``GET /admin/maintenance`` is the smallest payload that exercises
|
|
the same auth + capability path the rest of the surface uses.
|
|
Failure surfaces actionable hints: missing companion, missing
|
|
manage_options, or unreachable site.
|
|
"""
|
|
try:
|
|
payload = await self.management.wp_maintenance_status()
|
|
return {
|
|
"healthy": True,
|
|
"companion": True,
|
|
"admin_namespace": True,
|
|
"maintenance_enabled": bool(payload.get("enabled", False)),
|
|
}
|
|
except Exception as exc: # pragma: no cover — surfaced to dashboard
|
|
return {
|
|
"healthy": False,
|
|
"companion": False,
|
|
"error": str(exc),
|
|
"hint": (
|
|
"Install Airano MCP Bridge v2.11.0+ on this WordPress "
|
|
"site and ensure the Application Password belongs to a "
|
|
"user with manage_options."
|
|
),
|
|
}
|
|
|
|
# ----------------------------------------------------------
|
|
# Method delegation — one per tool spec, mirrored to handlers
|
|
# ----------------------------------------------------------
|
|
|
|
async def wp_plugin_list(self, **kwargs):
|
|
result = await self.management.wp_plugin_list(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_theme_list(self, **kwargs):
|
|
result = await self.management.wp_theme_list(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_user_list(self, **kwargs):
|
|
result = await self.management.wp_user_list(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_option_get(self, **kwargs):
|
|
result = await self.management.wp_option_get(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_cron_list(self, **kwargs):
|
|
result = await self.management.wp_cron_list(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_maintenance_status(self, **kwargs):
|
|
result = await self.management.wp_maintenance_status(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
# F.19.3.1 — system ports (companion v2.12.0+)
|
|
async def wp_system_info(self, **kwargs):
|
|
result = await self.management.wp_system_info(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_php_info(self, **kwargs):
|
|
result = await self.management.wp_php_info(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_disk_usage(self, **kwargs):
|
|
result = await self.management.wp_disk_usage(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
# F.19.5 — Page editing (Gutenberg blocks + Elementor + Classic, companion v2.13.0+)
|
|
async def wp_blocks_get(self, **kwargs):
|
|
result = await self.pages.wp_blocks_get(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_blocks_replace(self, **kwargs):
|
|
result = await self.pages.wp_blocks_replace(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_blocks_insert_at(self, **kwargs):
|
|
result = await self.pages.wp_blocks_insert_at(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_blocks_remove_at(self, **kwargs):
|
|
result = await self.pages.wp_blocks_remove_at(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_elementor_detect(self, **kwargs):
|
|
result = await self.pages.wp_elementor_detect(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_elementor_get(self, **kwargs):
|
|
result = await self.pages.wp_elementor_get(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_elementor_set(self, **kwargs):
|
|
result = await self.pages.wp_elementor_set(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_elementor_render_css(self, **kwargs):
|
|
result = await self.pages.wp_elementor_render_css(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_elementor_template_list(self, **kwargs):
|
|
result = await self.pages.wp_elementor_template_list(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_elementor_template_apply(self, **kwargs):
|
|
result = await self.pages.wp_elementor_template_apply(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_classic_html_replace(self, **kwargs):
|
|
result = await self.pages.wp_classic_html_replace(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
# F.19.7 — Theme dev surface (install + file CRUD, companion v2.14.0+)
|
|
async def wp_theme_install_from_zip(self, **kwargs):
|
|
result = await self.themes.wp_theme_install_from_zip(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_theme_activate(self, **kwargs):
|
|
result = await self.themes.wp_theme_activate(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_theme_delete(self, **kwargs):
|
|
result = await self.themes.wp_theme_delete(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_theme_file_list(self, **kwargs):
|
|
result = await self.themes.wp_theme_file_list(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_theme_file_read(self, **kwargs):
|
|
result = await self.themes.wp_theme_file_read(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_theme_file_write(self, **kwargs):
|
|
result = await self.themes.wp_theme_file_write(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_theme_file_delete(self, **kwargs):
|
|
result = await self.themes.wp_theme_file_delete(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
# F.19.2.1 — Plugin write management (install + admin, companion v2.15.0+)
|
|
async def wp_plugin_install_from_slug(self, **kwargs):
|
|
result = await self.plugins.wp_plugin_install_from_slug(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_plugin_install_from_zip(self, **kwargs):
|
|
result = await self.plugins.wp_plugin_install_from_zip(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_plugin_activate(self, **kwargs):
|
|
result = await self.plugins.wp_plugin_activate(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_plugin_deactivate(self, **kwargs):
|
|
result = await self.plugins.wp_plugin_deactivate(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_plugin_update(self, **kwargs):
|
|
result = await self.plugins.wp_plugin_update(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_plugin_delete(self, **kwargs):
|
|
result = await self.plugins.wp_plugin_delete(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
# F.19.6.A — Site config (identity + reading + permalinks, companion v2.16.0+)
|
|
async def wp_site_identity_get(self, **kwargs):
|
|
result = await self.site_config.wp_site_identity_get(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_site_identity_set(self, **kwargs):
|
|
result = await self.site_config.wp_site_identity_set(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_reading_settings_get(self, **kwargs):
|
|
result = await self.site_config.wp_reading_settings_get(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_reading_settings_set(self, **kwargs):
|
|
result = await self.site_config.wp_reading_settings_set(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_permalinks_get(self, **kwargs):
|
|
result = await self.site_config.wp_permalinks_get(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_permalinks_set(self, **kwargs):
|
|
result = await self.site_config.wp_permalinks_set(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
# F.19.6.B — Site layout (menus + widgets + customizer, companion v2.17.0+)
|
|
async def wp_menu_list(self, **kwargs):
|
|
result = await self.site_layout.wp_menu_list(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_menu_get(self, **kwargs):
|
|
result = await self.site_layout.wp_menu_get(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_menu_set(self, **kwargs):
|
|
result = await self.site_layout.wp_menu_set(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_widget_areas_list(self, **kwargs):
|
|
result = await self.site_layout.wp_widget_areas_list(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_widget_get(self, **kwargs):
|
|
result = await self.site_layout.wp_widget_get(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_widget_set(self, **kwargs):
|
|
result = await self.site_layout.wp_widget_set(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_customizer_changeset(self, **kwargs):
|
|
result = await self.site_layout.wp_customizer_changeset(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
# F.19.3.2 — Database inspection (read-only, companion v2.18.0+)
|
|
async def wp_db_size(self, **kwargs):
|
|
result = await self.database.wp_db_size(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_db_tables(self, **kwargs):
|
|
result = await self.database.wp_db_tables(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_db_search(self, **kwargs):
|
|
result = await self.database.wp_db_search(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
# F.19.3.3 — Bulk fan-out (editor tier, stock REST batch)
|
|
async def wp_bulk_post_update(self, **kwargs):
|
|
result = await self.bulk.wp_bulk_post_update(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
|
|
|
async def wp_bulk_term_update(self, **kwargs):
|
|
result = await self.bulk.wp_bulk_term_update(**kwargs)
|
|
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|