feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats
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>
This commit is contained in:
@@ -3,37 +3,44 @@ Plugins package
|
||||
|
||||
All project type plugins are here.
|
||||
|
||||
F.19.3.2-.3 (2026-05-04): wordpress_advanced sunset — the deprecated
|
||||
Docker-socket / WP-CLI plugin was removed once
|
||||
wordpress_specialist absorbed db inspection +
|
||||
bulk fan-out (companion v2.18.0).
|
||||
F.19.1 (2026-05-01): WordPress Specialist Plugin added (companion-backed,
|
||||
no Docker socket).
|
||||
v2.8.0 (Phase J): Directus CMS Plugin added
|
||||
v2.6.0 (Phase I): Appwrite Backend Plugin added
|
||||
v2.5.0 (Phase H): OpenPanel Analytics Plugin added
|
||||
v2.3.0 (Phase G): Supabase Self-Hosted Plugin added
|
||||
|
||||
Note: Appwrite and Directus plugins are retained in the codebase but are
|
||||
no longer registered by default. They require review and testing before
|
||||
re-enabling. To restore them, add their imports and registry.register()
|
||||
calls below.
|
||||
"""
|
||||
|
||||
from plugins.appwrite.plugin import AppwritePlugin
|
||||
from plugins.base import BasePlugin, PluginRegistry
|
||||
from plugins.coolify.plugin import CoolifyPlugin
|
||||
from plugins.directus.plugin import DirectusPlugin
|
||||
from plugins.gitea.plugin import GiteaPlugin
|
||||
from plugins.n8n.plugin import N8nPlugin
|
||||
from plugins.openpanel.plugin import OpenPanelPlugin
|
||||
from plugins.supabase.plugin import SupabasePlugin
|
||||
from plugins.woocommerce.plugin import WooCommercePlugin
|
||||
from plugins.wordpress.plugin import WordPressPlugin
|
||||
from plugins.wordpress_advanced.plugin import WordPressAdvancedPlugin
|
||||
from plugins.wordpress_specialist.plugin import WordPressSpecialistPlugin
|
||||
|
||||
# Create global registry
|
||||
registry = PluginRegistry()
|
||||
|
||||
# Register available plugins
|
||||
# Register available plugins (8 active plugins)
|
||||
registry.register("wordpress", WordPressPlugin)
|
||||
registry.register("woocommerce", WooCommercePlugin)
|
||||
registry.register("wordpress_advanced", WordPressAdvancedPlugin)
|
||||
registry.register("wordpress_specialist", WordPressSpecialistPlugin)
|
||||
registry.register("gitea", GiteaPlugin)
|
||||
registry.register("n8n", N8nPlugin)
|
||||
registry.register("supabase", SupabasePlugin)
|
||||
registry.register("openpanel", OpenPanelPlugin)
|
||||
registry.register("appwrite", AppwritePlugin)
|
||||
registry.register("directus", DirectusPlugin)
|
||||
registry.register("coolify", CoolifyPlugin)
|
||||
|
||||
__all__ = [
|
||||
@@ -42,12 +49,10 @@ __all__ = [
|
||||
"registry",
|
||||
"WordPressPlugin",
|
||||
"WooCommercePlugin",
|
||||
"WordPressAdvancedPlugin",
|
||||
"WordPressSpecialistPlugin",
|
||||
"GiteaPlugin",
|
||||
"N8nPlugin",
|
||||
"SupabasePlugin",
|
||||
"OpenPanelPlugin",
|
||||
"AppwritePlugin",
|
||||
"DirectusPlugin",
|
||||
"CoolifyPlugin",
|
||||
]
|
||||
|
||||
@@ -124,15 +124,18 @@ class OpenRouterProvider(BaseImageProvider):
|
||||
requested_model = request.model or _DEFAULT_MODEL
|
||||
replacement = _DEPRECATED_MODELS.get(requested_model)
|
||||
if replacement is not None:
|
||||
raise ProviderError(
|
||||
"PROVIDER_MODEL_DEPRECATED",
|
||||
(
|
||||
f"OpenRouter model '{requested_model}' is deprecated. "
|
||||
f"Use '{replacement}' instead."
|
||||
),
|
||||
{"requested_model": requested_model, "replacement_model": replacement},
|
||||
# Auto-substitute deprecated → GA so per-site defaults pinned
|
||||
# before the GA promotion don't break the generate-and-upload
|
||||
# flow. Logged at WARNING so operators notice and can
|
||||
# repin the site default in the dashboard.
|
||||
_logger.warning(
|
||||
"openrouter: model %r is deprecated; using %r instead",
|
||||
requested_model,
|
||||
replacement,
|
||||
)
|
||||
model = requested_model
|
||||
model = replacement
|
||||
else:
|
||||
model = requested_model
|
||||
|
||||
# Chat-completions shape with image modality declared. The
|
||||
# prompt is sent as the single user message; size hints are
|
||||
|
||||
@@ -370,16 +370,86 @@ async def _apply_metadata_and_attach(
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status["warnings"].append(f"featured_set_failed (product): {exc}")
|
||||
else:
|
||||
try:
|
||||
await wp_set_featured_media(client, attach_to_post, media["id"])
|
||||
# Issue #90 — set_featured used to hit /wp/v2/posts/{id}
|
||||
# only, which 404s for pages and other CPTs. Now: try
|
||||
# `posts` first (the common case), and on miss, walk the
|
||||
# thumbnail-supporting rest_bases (pages, then any other
|
||||
# CPT that opted in via add_theme_support('post-thumbnails')
|
||||
# / declares ``thumbnail`` in its supports list).
|
||||
ctx = await _set_featured_with_fallback(client, attach_to_post, media["id"])
|
||||
if ctx is not None:
|
||||
status["featured_set"] = True
|
||||
status["featured_context"] = "post"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
status["warnings"].append(f"featured_set_failed (post): {exc}")
|
||||
status["featured_context"] = ctx
|
||||
else:
|
||||
status["warnings"].append(
|
||||
f"featured_set_failed: post {attach_to_post} not found "
|
||||
"in any thumbnail-supporting post type"
|
||||
)
|
||||
|
||||
return status
|
||||
|
||||
|
||||
async def _set_featured_with_fallback(
|
||||
client: WordPressClient,
|
||||
target_id: int,
|
||||
media_id: int,
|
||||
) -> str | None:
|
||||
"""Set featured image on ``target_id`` across post / page / CPT.
|
||||
|
||||
Tries ``posts`` first (the 99% case — and the path covered by
|
||||
``wp_set_featured_media`` to keep existing test seams stable),
|
||||
then falls back to ``pages`` and any other rest_base that declares
|
||||
``thumbnail`` support via ``/wp/v2/types``. Returns the rest_base on
|
||||
success, ``None`` when nothing accepted the write.
|
||||
"""
|
||||
try:
|
||||
await wp_set_featured_media(client, target_id, media_id)
|
||||
return "post"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
body = {"featured_media": media_id}
|
||||
|
||||
try:
|
||||
await client.post(f"pages/{target_id}", json_data=body)
|
||||
return "page"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
types_data = await client.get("types")
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(types_data, dict):
|
||||
return None
|
||||
|
||||
for slug, data in types_data.items():
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
rest_base = data.get("rest_base") or slug
|
||||
if rest_base in ("posts", "pages", "media", "attachment"):
|
||||
continue
|
||||
supports = data.get("supports") or {}
|
||||
# `supports` is sometimes a list (older WP / some plugins) and
|
||||
# sometimes a dict keyed by feature. Treat both as truthy when
|
||||
# ``thumbnail`` is present.
|
||||
if isinstance(supports, dict):
|
||||
if not supports.get("thumbnail"):
|
||||
continue
|
||||
elif isinstance(supports, list):
|
||||
if "thumbnail" not in supports:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
try:
|
||||
await client.post(f"{rest_base}/{target_id}", json_data=body)
|
||||
return rest_base
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_upload_result(media: dict[str, Any], *, source: str) -> dict[str, Any]:
|
||||
title = media.get("title")
|
||||
rendered_title = title.get("rendered") if isinstance(title, dict) else title
|
||||
|
||||
@@ -147,6 +147,109 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "list_navigations",
|
||||
"method_name": "list_navigations",
|
||||
"description": (
|
||||
"List wp_navigation posts (block-theme navigation menus). "
|
||||
"These power the <!-- wp:navigation --> block in Site Editor. "
|
||||
"Distinct from classic menus (use list_menus for those)."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"description": "Results per page (1-100)",
|
||||
"default": 20,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Status filter",
|
||||
"enum": ["publish", "draft", "pending", "private", "any"],
|
||||
"default": "any",
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_navigation",
|
||||
"method_name": "get_navigation",
|
||||
"description": (
|
||||
"Get a single wp_navigation post by ID, including its raw "
|
||||
"block-markup content. Use the returned content to plan an "
|
||||
"update_navigation call (e.g. fix href values inside <!-- "
|
||||
"wp:navigation-link --> blocks)."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"navigation_id": {
|
||||
"type": "integer",
|
||||
"description": "wp_navigation post ID",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["navigation_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "update_navigation",
|
||||
"method_name": "update_navigation",
|
||||
"description": (
|
||||
"Update a wp_navigation post (block-theme menu). Writes "
|
||||
"post_content (block markup) and/or title/status/slug/meta. "
|
||||
"Use this to fix navigation-link hrefs that the agent set "
|
||||
"up in Site Editor without leaving the MCP loop."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"navigation_id": {
|
||||
"type": "integer",
|
||||
"description": "wp_navigation post ID",
|
||||
"minimum": 1,
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Navigation title",
|
||||
},
|
||||
"content": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": (
|
||||
"Block markup (e.g. <!-- wp:navigation-link "
|
||||
'{"label":"About","url":"/about/"} /-->). '
|
||||
"Replaces the entire post_content."
|
||||
),
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Publication status",
|
||||
"enum": ["publish", "draft", "pending", "private"],
|
||||
},
|
||||
"slug": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Navigation slug",
|
||||
},
|
||||
"meta": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Post meta key/value map (registered keys only).",
|
||||
},
|
||||
},
|
||||
"required": ["navigation_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -401,3 +504,123 @@ class MenusHandler:
|
||||
{"error": str(e), "message": f"Failed to update menu item {item_id}: {str(e)}"},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
async def list_navigations(self, per_page: int = 20, page: int = 1, status: str = "any") -> str:
|
||||
"""List wp_navigation posts (block-theme menus)."""
|
||||
try:
|
||||
params = {"per_page": per_page, "page": page, "status": status}
|
||||
items = await self.client.get("navigation", params=params)
|
||||
|
||||
if isinstance(items, list):
|
||||
summary = [
|
||||
{
|
||||
"id": n.get("id"),
|
||||
"title": (n.get("title") or {}).get("rendered", ""),
|
||||
"slug": n.get("slug"),
|
||||
"status": n.get("status"),
|
||||
"modified": n.get("modified"),
|
||||
}
|
||||
for n in items
|
||||
]
|
||||
else:
|
||||
summary = items
|
||||
|
||||
return json.dumps(
|
||||
{"total": len(summary) if isinstance(summary, list) else 0, "navigations": summary},
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"error": str(e), "message": f"Failed to list navigations: {str(e)}"}, indent=2
|
||||
)
|
||||
|
||||
async def get_navigation(self, navigation_id: int) -> str:
|
||||
"""Get a single wp_navigation post (with raw block content)."""
|
||||
try:
|
||||
# context=edit returns content.raw so the agent can round-trip it
|
||||
params = {"context": "edit"}
|
||||
nav = await self.client.get(f"navigation/{navigation_id}", params=params)
|
||||
|
||||
content = nav.get("content") or {}
|
||||
raw_content = content.get("raw") if isinstance(content, dict) else None
|
||||
rendered = content.get("rendered") if isinstance(content, dict) else None
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"id": nav.get("id"),
|
||||
"title": (nav.get("title") or {}).get("rendered", ""),
|
||||
"slug": nav.get("slug"),
|
||||
"status": nav.get("status"),
|
||||
"content_raw": raw_content,
|
||||
"content_rendered": rendered,
|
||||
"modified": nav.get("modified"),
|
||||
"link": nav.get("link"),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": str(e),
|
||||
"message": f"Failed to get navigation {navigation_id}: {str(e)}",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
async def update_navigation(
|
||||
self,
|
||||
navigation_id: int,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
status: str | None = None,
|
||||
slug: str | None = None,
|
||||
meta: dict | None = None,
|
||||
) -> str:
|
||||
"""Update a wp_navigation post (block-theme menu)."""
|
||||
try:
|
||||
data: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
data["title"] = title
|
||||
if content is not None:
|
||||
data["content"] = content
|
||||
if status is not None:
|
||||
data["status"] = status
|
||||
if slug is not None:
|
||||
data["slug"] = slug
|
||||
if meta is not None:
|
||||
data["meta"] = meta
|
||||
|
||||
if not data:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "no_fields",
|
||||
"message": (
|
||||
"update_navigation requires at least one of "
|
||||
"title, content, status, slug, meta."
|
||||
),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
nav = await self.client.post(f"navigation/{navigation_id}", json_data=data)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"id": nav.get("id"),
|
||||
"title": (nav.get("title") or {}).get("rendered", ""),
|
||||
"slug": nav.get("slug"),
|
||||
"status": nav.get("status"),
|
||||
"modified": nav.get("modified"),
|
||||
"link": nav.get("link"),
|
||||
"message": f"Navigation {navigation_id} updated successfully",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": str(e),
|
||||
"message": f"Failed to update navigation {navigation_id}: {str(e)}",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
@@ -292,7 +292,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
{
|
||||
"name": "update_page",
|
||||
"method_name": "update_page",
|
||||
"description": "Update an existing WordPress page. Can update title, content, status, slug, and parent page.",
|
||||
"description": "Update an existing WordPress page. Can update title, content, status, slug, parent page, featured image, and post meta (e.g. Yoast fields).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -322,6 +322,14 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Parent page ID",
|
||||
},
|
||||
"featured_media": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Featured image attachment ID. Pass 0 to clear.",
|
||||
},
|
||||
"meta": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Post meta key/value map forwarded as REST `meta` (e.g. Yoast fields like `_yoast_wpseo_metadesc`). Only registered/whitelisted keys are persisted by WordPress.",
|
||||
},
|
||||
},
|
||||
"required": ["page_id"],
|
||||
},
|
||||
@@ -892,6 +900,8 @@ class PostsHandler:
|
||||
status: str | None = None,
|
||||
slug: str | None = None,
|
||||
parent: int | None = None,
|
||||
featured_media: int | None = None,
|
||||
meta: dict | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Update an existing WordPress page.
|
||||
@@ -903,6 +913,8 @@ class PostsHandler:
|
||||
status: Publication status
|
||||
slug: Page URL slug
|
||||
parent: Parent page ID
|
||||
featured_media: Featured image attachment ID (0 clears it)
|
||||
meta: Post meta key/value map (e.g. Yoast fields)
|
||||
|
||||
Returns:
|
||||
JSON string with updated page data
|
||||
@@ -920,6 +932,10 @@ class PostsHandler:
|
||||
data["slug"] = slug
|
||||
if parent is not None:
|
||||
data["parent"] = parent
|
||||
if featured_media is not None:
|
||||
data["featured_media"] = featured_media
|
||||
if meta is not None:
|
||||
data["meta"] = meta
|
||||
|
||||
page = await self.client.post(f"pages/{page_id}", json_data=data)
|
||||
|
||||
|
||||
@@ -119,7 +119,9 @@ class WordPressPlugin(BasePlugin):
|
||||
else:
|
||||
self.wp_cli = None
|
||||
|
||||
# Note: Database, Bulk, and System operations moved to wordpress_advanced plugin
|
||||
# Database, bulk, and system operations live on
|
||||
# ``wordpress_specialist`` (companion-backed; no Docker socket).
|
||||
# The legacy ``wordpress_advanced`` plugin was sunset 2026-05-04.
|
||||
|
||||
@staticmethod
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
@@ -156,11 +158,12 @@ class WordPressPlugin(BasePlugin):
|
||||
|
||||
# Advanced content handlers
|
||||
specs.extend(handlers.get_seo_specs()) # 4 tools
|
||||
specs.extend(handlers.get_menus_specs()) # 5 tools
|
||||
specs.extend(handlers.get_menus_specs()) # 9 tools (incl. wp_navigation CRUD)
|
||||
specs.extend(handlers.get_wp_cli_specs()) # 15 tools
|
||||
|
||||
# Note: WooCommerce specs moved to woocommerce plugin (Phase D.1)
|
||||
# Note: Database, Bulk, and System specs in wordpress_advanced plugin
|
||||
# Database / bulk / system specs live on wordpress_specialist
|
||||
# (companion-backed; legacy wordpress_advanced was sunset 2026-05-04).
|
||||
|
||||
return specs
|
||||
|
||||
@@ -479,6 +482,15 @@ class WordPressPlugin(BasePlugin):
|
||||
async def update_menu_item(self, **kwargs):
|
||||
return await self.menus.update_menu_item(**kwargs)
|
||||
|
||||
async def list_navigations(self, **kwargs):
|
||||
return await self.menus.list_navigations(**kwargs)
|
||||
|
||||
async def get_navigation(self, **kwargs):
|
||||
return await self.menus.get_navigation(**kwargs)
|
||||
|
||||
async def update_navigation(self, **kwargs):
|
||||
return await self.menus.update_navigation(**kwargs)
|
||||
|
||||
# === WP-CLI Operations ===
|
||||
async def wp_cache_flush(self, **kwargs):
|
||||
if self.wp_cli:
|
||||
@@ -555,7 +567,8 @@ class WordPressPlugin(BasePlugin):
|
||||
return await self.wp_cli.wp_core_update(**kwargs)
|
||||
return '{"error": "WP-CLI not available. Container not configured."}'
|
||||
|
||||
# Note: Database, Bulk, and System operations moved to wordpress_advanced plugin
|
||||
# Database, bulk, and system operations live on wordpress_specialist
|
||||
# (companion-backed; legacy wordpress_advanced was sunset 2026-05-04).
|
||||
|
||||
# === Legacy compatibility methods ===
|
||||
# These methods are kept for potential backward compatibility
|
||||
|
||||
@@ -30,7 +30,8 @@ from plugins.wordpress.schemas.product import (
|
||||
)
|
||||
from plugins.wordpress.schemas.seo import SEOData, SEOUpdate
|
||||
|
||||
# Note: Database, Bulk, and System schemas moved to wordpress_advanced plugin
|
||||
# Database / bulk / system schemas live on wordpress_specialist
|
||||
# (companion-backed; legacy wordpress_advanced was sunset 2026-05-04).
|
||||
|
||||
__all__ = [
|
||||
# Common
|
||||
@@ -64,5 +65,4 @@ __all__ = [
|
||||
# SEO
|
||||
"SEOData",
|
||||
"SEOUpdate",
|
||||
# Note: Database, Bulk, and System schemas moved to wordpress_advanced plugin
|
||||
]
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
# WordPress Advanced Plugin
|
||||
|
||||
> **Advanced WordPress management features requiring elevated permissions**
|
||||
|
||||
## Overview
|
||||
|
||||
The WordPress Advanced plugin provides 22 powerful tools for advanced WordPress management, separated from the core WordPress plugin for better security and tool visibility.
|
||||
|
||||
### Why Separated?
|
||||
|
||||
**Phase D (WordPress Advanced Split)** separates advanced management features into their own plugin for:
|
||||
|
||||
1. **Better Security** 🔒
|
||||
- Separate API keys for basic vs advanced operations
|
||||
- Advanced operations require explicit permission
|
||||
- Reduces risk of accidental database modifications
|
||||
|
||||
2. **Better Tool Visibility** 👁️
|
||||
- Basic users see only 95 WordPress tools (not 117)
|
||||
- Advanced users explicitly enable advanced features
|
||||
- Cleaner tool list for most users
|
||||
|
||||
3. **Granular Access Control** 🎯
|
||||
- Per-project API keys can grant access to:
|
||||
- WordPress Core only (95 tools)
|
||||
- WordPress Advanced only (22 tools)
|
||||
- Both (117 tools total)
|
||||
|
||||
## Features
|
||||
|
||||
### 22 Advanced Tools
|
||||
|
||||
#### Database Operations (7 tools)
|
||||
- `wp_db_export` - Export WordPress database to SQL file
|
||||
- `wp_db_import` - Import SQL file to WordPress database
|
||||
- `wp_db_size` - Get database size and table information
|
||||
- `wp_db_tables` - List all database tables with sizes
|
||||
- `wp_db_search` - Search database for specific content
|
||||
- `wp_db_query` - Execute read-only SQL queries
|
||||
- `wp_db_repair` - Repair and optimize database tables
|
||||
|
||||
#### Bulk Operations (8 tools)
|
||||
- `bulk_update_posts` - Update multiple posts in parallel (max 100)
|
||||
- `bulk_delete_posts` - Delete multiple posts in parallel (max 100)
|
||||
- `bulk_update_products` - Update multiple products in parallel (max 100)
|
||||
- `bulk_delete_products` - Delete multiple products in parallel (max 100)
|
||||
- `bulk_delete_media` - Delete multiple media files in parallel (max 100)
|
||||
- `bulk_assign_categories` - Assign categories to multiple posts
|
||||
- `bulk_assign_tags` - Assign tags to multiple posts
|
||||
- `bulk_trash_posts` - Move multiple posts to trash
|
||||
|
||||
#### System Operations (7 tools)
|
||||
- `system_info` - Get WordPress system information (PHP, MySQL, server)
|
||||
- `system_phpinfo` - Get detailed PHP configuration
|
||||
- `system_disk_usage` - Get disk usage for WordPress installation
|
||||
- `system_clear_all_caches` - Clear all WordPress caches
|
||||
- `cron_list` - List all WordPress cron jobs
|
||||
- `cron_run` - Run specific cron job immediately
|
||||
- `error_log` - Get WordPress error log
|
||||
|
||||
## Requirements
|
||||
|
||||
### Core Requirements
|
||||
- WordPress site with REST API enabled
|
||||
- WordPress application password
|
||||
- **Docker container name** (for WP-CLI access) - REQUIRED!
|
||||
|
||||
### WP-CLI Access
|
||||
All WordPress Advanced features require WP-CLI access through Docker:
|
||||
|
||||
```bash
|
||||
# The MCP server must have access to:
|
||||
/var/run/docker.sock # Docker socket
|
||||
|
||||
# And the WordPress container must be accessible:
|
||||
docker exec <container_name> wp --version
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# WordPress Advanced Site 1
|
||||
WORDPRESS_ADVANCED_SITE1_URL=https://example.com
|
||||
WORDPRESS_ADVANCED_SITE1_USERNAME=admin
|
||||
WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_ADVANCED_SITE1_ALIAS=myblog # Optional: friendly name
|
||||
WORDPRESS_ADVANCED_SITE1_CONTAINER=coolify-wp1 # REQUIRED: Docker container name
|
||||
```
|
||||
|
||||
### Finding Container Name
|
||||
|
||||
```bash
|
||||
# List all WordPress containers:
|
||||
docker ps --filter "name=wordpress" --format "{{.Names}}"
|
||||
|
||||
# Test WP-CLI access:
|
||||
docker exec <container_name> wp --info
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Database Operations
|
||||
|
||||
```python
|
||||
# Export database
|
||||
result = await mcp.call_tool("wordpress_advanced_wp_db_export", {
|
||||
"site": "myblog",
|
||||
"output_file": "/backup/db-backup.sql"
|
||||
})
|
||||
|
||||
# Get database size
|
||||
result = await mcp.call_tool("wordpress_advanced_wp_db_size", {
|
||||
"site": "myblog"
|
||||
})
|
||||
|
||||
# Search database
|
||||
result = await mcp.call_tool("wordpress_advanced_wp_db_search", {
|
||||
"site": "myblog",
|
||||
"search_term": "old-domain.com",
|
||||
"tables": ["wp_posts", "wp_options"]
|
||||
})
|
||||
|
||||
# Execute read-only query
|
||||
result = await mcp.call_tool("wordpress_advanced_wp_db_query", {
|
||||
"site": "myblog",
|
||||
"query": "SELECT COUNT(*) as total FROM wp_posts WHERE post_status='publish'"
|
||||
})
|
||||
```
|
||||
|
||||
### Bulk Operations
|
||||
|
||||
```python
|
||||
# Bulk update posts
|
||||
result = await mcp.call_tool("wordpress_advanced_bulk_update_posts", {
|
||||
"site": "myblog",
|
||||
"post_ids": [1, 2, 3, 4, 5],
|
||||
"updates": {
|
||||
"status": "draft",
|
||||
"author": 2
|
||||
}
|
||||
})
|
||||
|
||||
# Bulk delete products
|
||||
result = await mcp.call_tool("wordpress_advanced_bulk_delete_products", {
|
||||
"site": "mystore",
|
||||
"product_ids": [100, 101, 102],
|
||||
"force": False # Move to trash instead of permanent delete
|
||||
})
|
||||
|
||||
# Bulk assign categories
|
||||
result = await mcp.call_tool("wordpress_advanced_bulk_assign_categories", {
|
||||
"site": "myblog",
|
||||
"post_ids": [10, 11, 12],
|
||||
"category_ids": [5, 6]
|
||||
})
|
||||
```
|
||||
|
||||
### System Operations
|
||||
|
||||
```python
|
||||
# Get system information
|
||||
result = await mcp.call_tool("wordpress_advanced_system_info", {
|
||||
"site": "myblog"
|
||||
})
|
||||
|
||||
# Clear all caches
|
||||
result = await mcp.call_tool("wordpress_advanced_system_clear_all_caches", {
|
||||
"site": "myblog"
|
||||
})
|
||||
|
||||
# List cron jobs
|
||||
result = await mcp.call_tool("wordpress_advanced_cron_list", {
|
||||
"site": "myblog"
|
||||
})
|
||||
|
||||
# Get error log
|
||||
result = await mcp.call_tool("wordpress_advanced_error_log", {
|
||||
"site": "myblog",
|
||||
"lines": 100
|
||||
})
|
||||
```
|
||||
|
||||
## Tool Count
|
||||
|
||||
```
|
||||
WordPress Core Plugin: 95 tools ✅ (basic features)
|
||||
WordPress Advanced Plugin: 22 tools 🔒 (advanced features)
|
||||
─────────────────────────────────────────────────
|
||||
Total (if both enabled): 117 tools
|
||||
```
|
||||
|
||||
## API Key Configuration
|
||||
|
||||
### Option 1: WordPress Core Only (Basic Users)
|
||||
```bash
|
||||
# Create API key with wordpress scope only
|
||||
# User gets: 95 WordPress tools
|
||||
# User does NOT see: WordPress Advanced tools
|
||||
```
|
||||
|
||||
### Option 2: WordPress Advanced Only (Power Users)
|
||||
```bash
|
||||
# Create API key with wordpress_advanced scope only
|
||||
# User gets: 22 WordPress Advanced tools
|
||||
# User does NOT see: WordPress Core tools
|
||||
```
|
||||
|
||||
### Option 3: Both Plugins (Admin Users)
|
||||
```bash
|
||||
# Create API key with both scopes
|
||||
# User gets: 117 total tools (95 + 22)
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Database Operations
|
||||
- **wp_db_export**: Exports contain sensitive data - secure storage required
|
||||
- **wp_db_import**: Can overwrite entire database - use with extreme caution
|
||||
- **wp_db_query**: Read-only enforced - write queries are rejected
|
||||
- **wp_db_search**: May expose sensitive information in results
|
||||
|
||||
### Bulk Operations
|
||||
- **Parallel Execution**: Max 10 concurrent operations (controlled by semaphore)
|
||||
- **Batch Limits**: Maximum 100 items per bulk operation
|
||||
- **Error Handling**: Returns success/failure status for each item
|
||||
- **Reversibility**: Most operations support trash (soft delete) before permanent deletion
|
||||
|
||||
### System Operations
|
||||
- **system_clear_all_caches**: May cause temporary performance impact
|
||||
- **cron_run**: Can trigger resource-intensive operations
|
||||
- **error_log**: May contain sensitive information (paths, credentials)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "WP-CLI not available" Error
|
||||
|
||||
**Cause**: Container not configured or Docker socket not mounted
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# 1. Check container name
|
||||
docker ps | grep wordpress
|
||||
|
||||
# 2. Test WP-CLI access
|
||||
docker exec <container_name> wp --info
|
||||
|
||||
# 3. Verify Docker socket in docker-compose.yaml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
```
|
||||
|
||||
### "Database handler not available" Error
|
||||
|
||||
**Cause**: WP-CLI not configured (container name missing)
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Ensure CONTAINER is set in environment variables
|
||||
WORDPRESS_ADVANCED_SITE1_CONTAINER=your-container-name
|
||||
```
|
||||
|
||||
### "Bulk operation failed" Error
|
||||
|
||||
**Cause**: Too many items or invalid IDs
|
||||
|
||||
**Solution**:
|
||||
- Reduce batch size (max 100 items)
|
||||
- Verify all IDs exist
|
||||
- Check error details in response for specific failures
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
plugins/wordpress_advanced/
|
||||
├── __init__.py # Plugin exports
|
||||
├── plugin.py # WordPressAdvancedPlugin class
|
||||
├── README.md # This file
|
||||
│
|
||||
├── schemas/ # Pydantic validation models
|
||||
│ ├── __init__.py
|
||||
│ ├── database.py # Database operation schemas
|
||||
│ ├── bulk.py # Bulk operation schemas
|
||||
│ └── system.py # System operation schemas
|
||||
│
|
||||
└── handlers/ # Tool implementations
|
||||
├── __init__.py
|
||||
├── database.py # Database operations (7 tools)
|
||||
├── bulk.py # Bulk operations (8 tools)
|
||||
└── system.py # System operations (7 tools)
|
||||
```
|
||||
|
||||
## Migration from WordPress Core
|
||||
|
||||
If you previously used WordPress Phase 5 features (database, bulk, system operations):
|
||||
|
||||
### Before (Phase 5 - Single Plugin)
|
||||
```bash
|
||||
# All features in one plugin
|
||||
WORDPRESS_SITE1_CONTAINER=coolify-wp1
|
||||
|
||||
# All 117 tools visible to everyone
|
||||
```
|
||||
|
||||
### After (Phase D - Split Plugins)
|
||||
```bash
|
||||
# Basic WordPress (95 tools)
|
||||
WORDPRESS_SITE1_URL=...
|
||||
WORDPRESS_SITE1_USERNAME=...
|
||||
WORDPRESS_SITE1_APP_PASSWORD=...
|
||||
|
||||
# Advanced WordPress (22 tools) - separate configuration
|
||||
WORDPRESS_ADVANCED_SITE1_URL=...
|
||||
WORDPRESS_ADVANCED_SITE1_USERNAME=...
|
||||
WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=...
|
||||
WORDPRESS_ADVANCED_SITE1_CONTAINER=coolify-wp1 # REQUIRED
|
||||
|
||||
# API Keys can now control access separately
|
||||
```
|
||||
|
||||
### Tool Name Changes
|
||||
|
||||
Tool names now include `wordpress_advanced_` prefix:
|
||||
|
||||
| Before (Phase 5) | After (Phase D) |
|
||||
|-----------------------|---------------------------------------|
|
||||
| `wp_db_export` | `wordpress_advanced_wp_db_export` |
|
||||
| `bulk_update_posts` | `wordpress_advanced_bulk_update_posts`|
|
||||
| `system_info` | `wordpress_advanced_system_info` |
|
||||
|
||||
## Performance
|
||||
|
||||
### Bulk Operations
|
||||
- **Parallel Execution**: Up to 10 concurrent operations
|
||||
- **Semaphore Control**: Prevents server overload
|
||||
- **Progress Tracking**: Per-item success/failure status
|
||||
- **Recommended Batch Size**: 10-50 items for optimal performance
|
||||
|
||||
### Database Operations
|
||||
- **Export**: Time depends on database size (1GB ≈ 30-60 seconds)
|
||||
- **Import**: Slightly slower than export due to indexing
|
||||
- **Search**: Full-text search across specified tables
|
||||
- **Query**: Fast read-only queries with result limits
|
||||
|
||||
### System Operations
|
||||
- **Cache Clear**: 1-5 seconds depending on cache size
|
||||
- **Cron Jobs**: Immediate execution, duration depends on job
|
||||
- **System Info**: Near-instant (<1 second)
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Separate API Keys**: Create different keys for basic and advanced operations
|
||||
2. **Batch Size**: Keep bulk operations under 50 items for optimal performance
|
||||
3. **Database Backups**: Always backup before using wp_db_import
|
||||
4. **Cron Jobs**: Test cron jobs in staging environment first
|
||||
5. **Error Logs**: Regularly check error logs for security issues
|
||||
6. **Disk Usage**: Monitor disk usage before large export operations
|
||||
|
||||
## Support
|
||||
|
||||
For issues, feature requests, or contributions:
|
||||
- GitHub Issues: [mcphub/issues](https://github.com/airano-ir/mcphub/issues)
|
||||
- Documentation: [docs/](../../docs/)
|
||||
- Main README: [../../README.md](../../README.md)
|
||||
|
||||
## License
|
||||
|
||||
Same as main project license.
|
||||
|
||||
---
|
||||
|
||||
**Part of MCP Hub** - Phase D (WordPress Advanced Split)
|
||||
**Version**: 1.0.0
|
||||
**Last Updated**: 2025-11-18
|
||||
@@ -1,12 +0,0 @@
|
||||
"""
|
||||
WordPress Advanced Plugin
|
||||
|
||||
Advanced WordPress management features including database operations,
|
||||
bulk operations, and system management.
|
||||
|
||||
This plugin provides 22 advanced tools for WordPress power users and administrators.
|
||||
"""
|
||||
|
||||
from .plugin import WordPressAdvancedPlugin
|
||||
|
||||
__all__ = ["WordPressAdvancedPlugin"]
|
||||
@@ -1,26 +0,0 @@
|
||||
"""
|
||||
WordPress Advanced Handlers
|
||||
|
||||
Modular handlers for WordPress advanced functionality.
|
||||
Each handler is responsible for a specific domain of WordPress advanced operations.
|
||||
"""
|
||||
|
||||
from plugins.wordpress_advanced.handlers.bulk import BulkHandler
|
||||
from plugins.wordpress_advanced.handlers.bulk import get_tool_specifications as get_bulk_specs
|
||||
from plugins.wordpress_advanced.handlers.database import DatabaseHandler
|
||||
from plugins.wordpress_advanced.handlers.database import (
|
||||
get_tool_specifications as get_database_specs,
|
||||
)
|
||||
from plugins.wordpress_advanced.handlers.system import SystemHandler
|
||||
from plugins.wordpress_advanced.handlers.system import get_tool_specifications as get_system_specs
|
||||
|
||||
__all__ = [
|
||||
# Handlers
|
||||
"DatabaseHandler",
|
||||
"BulkHandler",
|
||||
"SystemHandler",
|
||||
# Tool specifications
|
||||
"get_database_specs",
|
||||
"get_bulk_specs",
|
||||
"get_system_specs",
|
||||
]
|
||||
@@ -1,689 +0,0 @@
|
||||
"""
|
||||
Bulk Operations Handler
|
||||
|
||||
Manages WordPress bulk operations including:
|
||||
- Bulk updates for posts/products/media
|
||||
- Bulk deletions
|
||||
- Bulk category/tag assignments
|
||||
|
||||
All operations use WordPress REST API batch requests for efficiency.
|
||||
Operations are limited to 100 items per request for performance.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress_advanced.schemas.bulk import (
|
||||
BulkOperationResult,
|
||||
)
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator"""
|
||||
return [
|
||||
# Bulk Update Posts
|
||||
{
|
||||
"name": "bulk_update_posts",
|
||||
"method_name": "bulk_update_posts",
|
||||
"description": "Update multiple posts at once. Supports status, author, categories, tags, and more. Max 100 posts per request.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"description": "List of post IDs to update",
|
||||
},
|
||||
"updates": {
|
||||
"type": "object",
|
||||
"description": "Fields to update (status, title, content, author, categories, tags, etc.)",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["publish", "draft", "pending", "private"],
|
||||
},
|
||||
"title": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"excerpt": {"type": "string"},
|
||||
"author": {"type": "integer"},
|
||||
"categories": {"type": "array", "items": {"type": "integer"}},
|
||||
"tags": {"type": "array", "items": {"type": "integer"}},
|
||||
"featured_media": {"type": "integer"},
|
||||
"comment_status": {"type": "string", "enum": ["open", "closed"]},
|
||||
"ping_status": {"type": "string", "enum": ["open", "closed"]},
|
||||
"sticky": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["post_ids", "updates"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# Bulk Delete Posts
|
||||
{
|
||||
"name": "bulk_delete_posts",
|
||||
"method_name": "bulk_delete_posts",
|
||||
"description": "Delete multiple posts at once. Can move to trash or permanently delete. Max 100 posts per request.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"description": "List of post IDs to delete",
|
||||
},
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Force permanent deletion (bypass trash)",
|
||||
},
|
||||
},
|
||||
"required": ["post_ids"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# Bulk Update Products
|
||||
{
|
||||
"name": "bulk_update_products",
|
||||
"method_name": "bulk_update_products",
|
||||
"description": "Update multiple WooCommerce products at once. Supports price, stock, status, and more. Max 100 products per request.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"product_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"description": "List of product IDs to update",
|
||||
},
|
||||
"updates": {
|
||||
"type": "object",
|
||||
"description": "Fields to update (price, stock, status, etc.)",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["draft", "pending", "private", "publish"],
|
||||
},
|
||||
"featured": {"type": "boolean"},
|
||||
"catalog_visibility": {
|
||||
"type": "string",
|
||||
"enum": ["visible", "catalog", "search", "hidden"],
|
||||
},
|
||||
"description": {"type": "string"},
|
||||
"short_description": {"type": "string"},
|
||||
"sku": {"type": "string"},
|
||||
"price": {"type": "string"},
|
||||
"regular_price": {"type": "string"},
|
||||
"sale_price": {"type": "string"},
|
||||
"stock_quantity": {"type": "integer"},
|
||||
"stock_status": {
|
||||
"type": "string",
|
||||
"enum": ["instock", "outofstock", "onbackorder"],
|
||||
},
|
||||
"manage_stock": {"type": "boolean"},
|
||||
"categories": {"type": "array", "items": {"type": "object"}},
|
||||
"tags": {"type": "array", "items": {"type": "object"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["product_ids", "updates"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# Bulk Delete Products
|
||||
{
|
||||
"name": "bulk_delete_products",
|
||||
"method_name": "bulk_delete_products",
|
||||
"description": "Delete multiple WooCommerce products at once. Permanently deletes products. Max 100 products per request.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"product_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"description": "List of product IDs to delete",
|
||||
},
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Force permanent deletion",
|
||||
},
|
||||
},
|
||||
"required": ["product_ids"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# Bulk Assign Categories
|
||||
{
|
||||
"name": "bulk_assign_categories",
|
||||
"method_name": "bulk_assign_categories",
|
||||
"description": "Assign categories to multiple posts/products at once. Can replace or append categories. Max 100 items per request. IMPORTANT: For posts use 'category' taxonomy IDs, for products use 'product_cat' taxonomy IDs.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"description": "List of post/product IDs",
|
||||
},
|
||||
"category_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"minItems": 1,
|
||||
"description": "List of category IDs to assign. For posts: use 'category' taxonomy IDs. For products: use 'product_cat' taxonomy IDs.",
|
||||
},
|
||||
"replace": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Replace existing categories (true) or append (false)",
|
||||
},
|
||||
"item_type": {
|
||||
"type": "string",
|
||||
"enum": ["post", "product"],
|
||||
"default": "post",
|
||||
"description": "Type of items",
|
||||
},
|
||||
},
|
||||
"required": ["item_ids", "category_ids"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# Bulk Assign Tags
|
||||
{
|
||||
"name": "bulk_assign_tags",
|
||||
"method_name": "bulk_assign_tags",
|
||||
"description": "Assign tags to multiple posts/products at once. Can replace or append tags. Max 100 items per request. IMPORTANT: For posts use 'post_tag' taxonomy IDs, for products use 'product_tag' taxonomy IDs.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"description": "List of post/product IDs",
|
||||
},
|
||||
"tag_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"minItems": 1,
|
||||
"description": "List of tag IDs to assign. For posts: use 'post_tag' taxonomy IDs. For products: use 'product_tag' taxonomy IDs.",
|
||||
},
|
||||
"replace": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Replace existing tags (true) or append (false)",
|
||||
},
|
||||
"item_type": {
|
||||
"type": "string",
|
||||
"enum": ["post", "product"],
|
||||
"default": "post",
|
||||
"description": "Type of items",
|
||||
},
|
||||
},
|
||||
"required": ["item_ids", "tag_ids"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# Bulk Update Media
|
||||
{
|
||||
"name": "bulk_update_media",
|
||||
"method_name": "bulk_update_media",
|
||||
"description": "Update multiple media items at once. Supports alt_text, title, caption, description. Max 100 items per request.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"media_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"description": "List of media IDs to update",
|
||||
},
|
||||
"updates": {
|
||||
"type": "object",
|
||||
"description": "Fields to update",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"alt_text": {"type": "string"},
|
||||
"caption": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["media_ids", "updates"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# Bulk Delete Media
|
||||
{
|
||||
"name": "bulk_delete_media",
|
||||
"method_name": "bulk_delete_media",
|
||||
"description": "Delete multiple media items at once. Permanently deletes files from server. Max 100 items per request.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"media_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"minItems": 1,
|
||||
"maxItems": 100,
|
||||
"description": "List of media IDs to delete",
|
||||
},
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Force permanent deletion (media can't be trashed)",
|
||||
},
|
||||
},
|
||||
"required": ["media_ids"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class BulkHandler:
|
||||
"""Handles WordPress bulk operations"""
|
||||
|
||||
def __init__(self, client: WordPressClient):
|
||||
"""
|
||||
Initialize Bulk Handler
|
||||
|
||||
Args:
|
||||
client: WordPress REST API client
|
||||
"""
|
||||
self.client = client
|
||||
self.logger = client.logger
|
||||
|
||||
async def _bulk_operation(
|
||||
self, endpoint: str, item_ids: list[int], operation: str, data: dict[str, Any] | None = None
|
||||
) -> BulkOperationResult:
|
||||
"""
|
||||
Generic bulk operation executor
|
||||
|
||||
Args:
|
||||
endpoint: REST API endpoint (e.g., 'posts', 'products')
|
||||
item_ids: List of item IDs to process
|
||||
operation: 'update' or 'delete'
|
||||
data: Data for update operations
|
||||
|
||||
Returns:
|
||||
BulkOperationResult with success/failure counts
|
||||
"""
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
failed_ids = []
|
||||
errors = []
|
||||
|
||||
# Determine if using WooCommerce API
|
||||
use_woocommerce = endpoint == "products"
|
||||
|
||||
# Determine HTTP method for updates
|
||||
# WordPress REST API uses POST for media/posts updates, PUT for WooCommerce products
|
||||
update_method = "PUT" if use_woocommerce else "POST"
|
||||
|
||||
# Process items in parallel (with limit to avoid overwhelming server)
|
||||
semaphore = asyncio.Semaphore(10) # Max 10 concurrent requests
|
||||
|
||||
async def process_item(item_id: int):
|
||||
nonlocal success_count, failed_count
|
||||
|
||||
async with semaphore:
|
||||
try:
|
||||
# Check if this is a WooCommerce endpoint
|
||||
use_custom_namespace = endpoint.startswith("wc/")
|
||||
|
||||
if operation == "update":
|
||||
await self.client.request(
|
||||
update_method,
|
||||
f"{endpoint}/{item_id}",
|
||||
json_data=data,
|
||||
use_custom_namespace=use_custom_namespace,
|
||||
)
|
||||
elif operation == "delete":
|
||||
params = data or {}
|
||||
await self.client.request(
|
||||
"DELETE",
|
||||
f"{endpoint}/{item_id}",
|
||||
params=params,
|
||||
use_custom_namespace=use_custom_namespace,
|
||||
)
|
||||
|
||||
success_count += 1
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
failed_ids.append(item_id)
|
||||
errors.append({"id": item_id, "error": str(e)})
|
||||
self.logger.error(f"Bulk {operation} failed for {endpoint}/{item_id}: {str(e)}")
|
||||
return False
|
||||
|
||||
# Execute all operations in parallel
|
||||
await asyncio.gather(*[process_item(item_id) for item_id in item_ids])
|
||||
|
||||
return {
|
||||
"success_count": success_count,
|
||||
"failed_count": failed_count,
|
||||
"total": len(item_ids),
|
||||
"failed_ids": failed_ids,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
async def bulk_update_posts(
|
||||
self, post_ids: list[int], updates: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Bulk update posts"""
|
||||
try:
|
||||
result = await self._bulk_operation(
|
||||
endpoint="posts", item_ids=post_ids, operation="update", data=updates
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Updated {result['success_count']}/{result['total']} posts",
|
||||
**result,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Bulk update posts failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def bulk_delete_posts(self, post_ids: list[int], force: bool = False) -> dict[str, Any]:
|
||||
"""Bulk delete posts"""
|
||||
try:
|
||||
result = await self._bulk_operation(
|
||||
endpoint="posts", item_ids=post_ids, operation="delete", data={"force": force}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Deleted {result['success_count']}/{result['total']} posts",
|
||||
"permanent": force,
|
||||
**result,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Bulk delete posts failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def bulk_update_products(
|
||||
self, product_ids: list[int], updates: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Bulk update WooCommerce products"""
|
||||
try:
|
||||
result = await self._bulk_operation(
|
||||
endpoint="wc/v3/products", item_ids=product_ids, operation="update", data=updates
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Updated {result['success_count']}/{result['total']} products",
|
||||
**result,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Bulk update products failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def bulk_delete_products(
|
||||
self, product_ids: list[int], force: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Bulk delete WooCommerce products"""
|
||||
try:
|
||||
result = await self._bulk_operation(
|
||||
endpoint="wc/v3/products",
|
||||
item_ids=product_ids,
|
||||
operation="delete",
|
||||
data={"force": force},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Deleted {result['success_count']}/{result['total']} products",
|
||||
"permanent": force,
|
||||
**result,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Bulk delete products failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def bulk_assign_categories(
|
||||
self,
|
||||
item_ids: list[int],
|
||||
category_ids: list[int],
|
||||
replace: bool = False,
|
||||
item_type: str = "post",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Bulk assign categories to posts/products.
|
||||
|
||||
IMPORTANT:
|
||||
- For posts: use category IDs from 'category' taxonomy
|
||||
- For products: use category IDs from 'product_cat' taxonomy
|
||||
"""
|
||||
try:
|
||||
# Use correct endpoint for posts vs WooCommerce products
|
||||
if item_type == "post":
|
||||
endpoint = "posts"
|
||||
elif item_type == "product":
|
||||
endpoint = "wc/v3/products" # WooCommerce endpoint
|
||||
else:
|
||||
endpoint = "posts" # Default to posts
|
||||
|
||||
# Process each item individually to handle append mode
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
failed_ids = []
|
||||
errors = []
|
||||
|
||||
# Determine if using WooCommerce endpoint
|
||||
use_custom_namespace = endpoint.startswith("wc/")
|
||||
|
||||
for item_id in item_ids:
|
||||
try:
|
||||
# If append mode, get current categories first
|
||||
if not replace:
|
||||
if use_custom_namespace:
|
||||
current_item = await self.client.get(
|
||||
f"{endpoint}/{item_id}", use_custom_namespace=True
|
||||
)
|
||||
current_categories = current_item.get("categories", [])
|
||||
# Extract IDs from current categories
|
||||
current_cat_ids = [
|
||||
cat["id"] for cat in current_categories if "id" in cat
|
||||
]
|
||||
# Merge with new categories (avoid duplicates)
|
||||
all_cat_ids = list(set(current_cat_ids + category_ids))
|
||||
else:
|
||||
current_item = await self.client.get(f"{endpoint}/{item_id}")
|
||||
current_cat_ids = current_item.get("categories", [])
|
||||
# Merge with new categories (avoid duplicates)
|
||||
all_cat_ids = list(set(current_cat_ids + category_ids))
|
||||
else:
|
||||
# Replace mode: use only new categories
|
||||
all_cat_ids = category_ids
|
||||
|
||||
# Format categories based on item type
|
||||
if item_type == "product":
|
||||
# WooCommerce requires categories as objects with id
|
||||
updates = {"categories": [{"id": cat_id} for cat_id in all_cat_ids]}
|
||||
else:
|
||||
# WordPress posts use simple category ID array
|
||||
updates = {"categories": all_cat_ids}
|
||||
|
||||
# Update the item
|
||||
await self.client.request(
|
||||
"PUT" if use_custom_namespace else "POST",
|
||||
f"{endpoint}/{item_id}",
|
||||
json_data=updates,
|
||||
use_custom_namespace=use_custom_namespace,
|
||||
)
|
||||
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
failed_ids.append(item_id)
|
||||
errors.append({"id": item_id, "error": str(e)})
|
||||
self.logger.error(
|
||||
f"Failed to assign categories to {item_type} {item_id}: {str(e)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Assigned categories to {success_count}/{len(item_ids)} {item_type}s",
|
||||
"mode": "replace" if replace else "append",
|
||||
"success_count": success_count,
|
||||
"failed_count": failed_count,
|
||||
"total": len(item_ids),
|
||||
"failed_ids": failed_ids,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Bulk assign categories failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def bulk_assign_tags(
|
||||
self,
|
||||
item_ids: list[int],
|
||||
tag_ids: list[int],
|
||||
replace: bool = False,
|
||||
item_type: str = "post",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Bulk assign tags to posts/products.
|
||||
|
||||
IMPORTANT:
|
||||
- For posts: use tag IDs from 'post_tag' taxonomy
|
||||
- For products: use tag IDs from 'product_tag' taxonomy
|
||||
"""
|
||||
try:
|
||||
# Use correct endpoint for posts vs WooCommerce products
|
||||
if item_type == "post":
|
||||
endpoint = "posts"
|
||||
elif item_type == "product":
|
||||
endpoint = "wc/v3/products" # WooCommerce endpoint
|
||||
else:
|
||||
endpoint = "posts" # Default to posts
|
||||
|
||||
# Process each item individually to handle append mode
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
failed_ids = []
|
||||
errors = []
|
||||
|
||||
# Determine if using WooCommerce endpoint
|
||||
use_custom_namespace = endpoint.startswith("wc/")
|
||||
|
||||
for item_id in item_ids:
|
||||
try:
|
||||
# If append mode, get current tags first
|
||||
if not replace:
|
||||
if use_custom_namespace:
|
||||
current_item = await self.client.get(
|
||||
f"{endpoint}/{item_id}", use_custom_namespace=True
|
||||
)
|
||||
current_tags = current_item.get("tags", [])
|
||||
# Extract IDs from current tags
|
||||
current_tag_ids = [tag["id"] for tag in current_tags if "id" in tag]
|
||||
# Merge with new tags (avoid duplicates)
|
||||
all_tag_ids = list(set(current_tag_ids + tag_ids))
|
||||
else:
|
||||
current_item = await self.client.get(f"{endpoint}/{item_id}")
|
||||
current_tag_ids = current_item.get("tags", [])
|
||||
# Merge with new tags (avoid duplicates)
|
||||
all_tag_ids = list(set(current_tag_ids + tag_ids))
|
||||
else:
|
||||
# Replace mode: use only new tags
|
||||
all_tag_ids = tag_ids
|
||||
|
||||
# Format tags based on item type
|
||||
if item_type == "product":
|
||||
# WooCommerce requires tags as objects with id
|
||||
updates = {"tags": [{"id": tag_id} for tag_id in all_tag_ids]}
|
||||
else:
|
||||
# WordPress posts use simple tag ID array
|
||||
updates = {"tags": all_tag_ids}
|
||||
|
||||
# Update the item
|
||||
await self.client.request(
|
||||
"PUT" if use_custom_namespace else "POST",
|
||||
f"{endpoint}/{item_id}",
|
||||
json_data=updates,
|
||||
use_custom_namespace=use_custom_namespace,
|
||||
)
|
||||
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
failed_ids.append(item_id)
|
||||
errors.append({"id": item_id, "error": str(e)})
|
||||
self.logger.error(f"Failed to assign tags to {item_type} {item_id}: {str(e)}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Assigned tags to {success_count}/{len(item_ids)} {item_type}s",
|
||||
"mode": "replace" if replace else "append",
|
||||
"success_count": success_count,
|
||||
"failed_count": failed_count,
|
||||
"total": len(item_ids),
|
||||
"failed_ids": failed_ids,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Bulk assign tags failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def bulk_update_media(
|
||||
self, media_ids: list[int], updates: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Bulk update media items"""
|
||||
try:
|
||||
result = await self._bulk_operation(
|
||||
endpoint="media", item_ids=media_ids, operation="update", data=updates
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Updated {result['success_count']}/{result['total']} media items",
|
||||
**result,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Bulk update media failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def bulk_delete_media(self, media_ids: list[int], force: bool = True) -> dict[str, Any]:
|
||||
"""Bulk delete media items"""
|
||||
try:
|
||||
result = await self._bulk_operation(
|
||||
endpoint="media", item_ids=media_ids, operation="delete", data={"force": force}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Deleted {result['success_count']}/{result['total']} media items",
|
||||
"permanent": force,
|
||||
**result,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Bulk delete media failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -1,591 +0,0 @@
|
||||
"""
|
||||
Database Operations Handler
|
||||
|
||||
Manages WordPress database operations including:
|
||||
- Export/Import
|
||||
- Backup/Restore
|
||||
- Optimization and Repair
|
||||
- Search and Query operations
|
||||
|
||||
All operations require 'write' or 'admin' scope for security.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.wp_cli import WPCLIManager
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator"""
|
||||
return [
|
||||
# DB Export (already exists in wp_cli.py, documented here for completeness)
|
||||
{
|
||||
"name": "wp_db_export",
|
||||
"method_name": "wp_db_export",
|
||||
"description": "Export WordPress database to SQL file. Creates a timestamped backup file in /tmp directory.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tables": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Specific tables to export (default: all tables)",
|
||||
},
|
||||
"exclude_tables": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Tables to exclude from export",
|
||||
},
|
||||
"add_drop_table": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Include DROP TABLE statements",
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# DB Import
|
||||
{
|
||||
"name": "wp_db_import",
|
||||
"method_name": "wp_db_import",
|
||||
"description": "Import database from SQL file. DESTRUCTIVE: replaces current database. Requires admin scope.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {"type": "string", "description": "Path to SQL file on server"},
|
||||
"url": {"type": "string", "description": "URL to download SQL file from"},
|
||||
"skip_optimization": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Skip database optimization after import",
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# DB Size Info
|
||||
{
|
||||
"name": "wp_db_size",
|
||||
"method_name": "wp_db_size",
|
||||
"description": "Get database size statistics including total size, table sizes, and row counts.",
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
# DB Tables List
|
||||
{
|
||||
"name": "wp_db_tables",
|
||||
"method_name": "wp_db_tables",
|
||||
"description": "List all database tables with detailed information (size, engine, rows, collation).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prefix_only": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Show only WordPress tables (with wp_ prefix)",
|
||||
}
|
||||
},
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
# DB Search
|
||||
{
|
||||
"name": "wp_db_search",
|
||||
"method_name": "wp_db_search",
|
||||
"description": "Search database for specific strings. Useful for finding content, debugging, or data migration.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"search_string": {"type": "string", "description": "String to search for"},
|
||||
"tables": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Specific tables to search",
|
||||
},
|
||||
"regex": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Use regex pattern matching",
|
||||
},
|
||||
"case_sensitive": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Case-sensitive search",
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"default": 100,
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"description": "Maximum results to return",
|
||||
},
|
||||
},
|
||||
"required": ["search_string"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
# DB Query (read-only SELECT)
|
||||
{
|
||||
"name": "wp_db_query",
|
||||
"method_name": "wp_db_query",
|
||||
"description": "Execute read-only SQL query (SELECT, SHOW, DESCRIBE only). For advanced users and debugging.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "SQL query to execute (SELECT only)",
|
||||
},
|
||||
"max_rows": {
|
||||
"type": "integer",
|
||||
"default": 1000,
|
||||
"minimum": 1,
|
||||
"maximum": 10000,
|
||||
"description": "Maximum rows to return",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# DB Repair
|
||||
{
|
||||
"name": "wp_db_repair",
|
||||
"method_name": "wp_db_repair",
|
||||
"description": "Repair corrupted database tables. Runs REPAIR TABLE on all WordPress tables.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tables": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Specific tables to repair (default: all tables)",
|
||||
}
|
||||
},
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class DatabaseHandler:
|
||||
"""Handles WordPress database operations"""
|
||||
|
||||
def __init__(self, client: WordPressClient, wp_cli: WPCLIManager | None = None):
|
||||
"""
|
||||
Initialize Database Handler
|
||||
|
||||
Args:
|
||||
client: WordPress REST API client
|
||||
wp_cli: WP-CLI manager (optional, for advanced operations)
|
||||
"""
|
||||
self.client = client
|
||||
self.wp_cli = wp_cli
|
||||
self.logger = client.logger
|
||||
|
||||
async def wp_db_export(
|
||||
self,
|
||||
tables: list[str] | None = None,
|
||||
exclude_tables: list[str] | None = None,
|
||||
add_drop_table: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Export WordPress database to SQL file
|
||||
|
||||
Uses WP-CLI: wp db export
|
||||
"""
|
||||
if not self.wp_cli:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "WP-CLI not available. Container name not configured.",
|
||||
}
|
||||
|
||||
try:
|
||||
# Build WP-CLI command
|
||||
cmd = "db export /tmp/backup-$(date +%Y%m%d-%H%M%S).sql"
|
||||
|
||||
if tables:
|
||||
cmd += f" --tables={','.join(tables)}"
|
||||
|
||||
if exclude_tables:
|
||||
cmd += f" --exclude_tables={','.join(exclude_tables)}"
|
||||
|
||||
if not add_drop_table:
|
||||
cmd += " --no-add-drop-table"
|
||||
|
||||
# Execute export
|
||||
result = await self.wp_cli.execute_command(cmd)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Database exported successfully",
|
||||
"file": result.get("output", ""),
|
||||
"command": cmd,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Database export failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def wp_db_import(
|
||||
self, file_path: str | None = None, url: str | None = None, skip_optimization: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Import database from SQL file
|
||||
|
||||
⚠️ DESTRUCTIVE OPERATION - Requires admin scope
|
||||
"""
|
||||
if not self.wp_cli:
|
||||
return {"success": False, "error": "WP-CLI not available"}
|
||||
|
||||
if not file_path and not url:
|
||||
return {"success": False, "error": "Either file_path or url is required"}
|
||||
|
||||
try:
|
||||
# Download file if URL provided
|
||||
if url:
|
||||
# Use wget or curl to download
|
||||
download_cmd = f"wget -O /tmp/import.sql '{url}'"
|
||||
await self.wp_cli.execute_command(f"eval '{download_cmd}'")
|
||||
file_path = "/tmp/import.sql"
|
||||
|
||||
# Import database
|
||||
cmd = f"db import {file_path}"
|
||||
await self.wp_cli.execute_command(cmd)
|
||||
|
||||
# Optimize unless skipped
|
||||
if not skip_optimization:
|
||||
await self.wp_cli.execute_command("db optimize")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Database imported successfully",
|
||||
"file": file_path,
|
||||
"optimized": not skip_optimization,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Database import failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def wp_db_size(self) -> dict[str, Any]:
|
||||
"""Get database size statistics"""
|
||||
if not self.wp_cli:
|
||||
return {"success": False, "error": "WP-CLI not available"}
|
||||
|
||||
try:
|
||||
# Get total database size first
|
||||
total_query = """
|
||||
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS total_mb,
|
||||
SUM(table_rows) AS total_rows,
|
||||
COUNT(*) AS table_count
|
||||
FROM information_schema.TABLES
|
||||
WHERE table_schema = DATABASE()
|
||||
"""
|
||||
|
||||
total_cmd = f'db query "{total_query}" --skip-column-names'
|
||||
total_result = await self.wp_cli.execute_command(total_cmd)
|
||||
|
||||
# Parse tab-separated output
|
||||
total_output = total_result.get("output", "0\t0\t0").strip()
|
||||
total_parts = total_output.split("\t")
|
||||
|
||||
total_size_mb = (
|
||||
float(total_parts[0]) if len(total_parts) > 0 and total_parts[0] else 0.0
|
||||
)
|
||||
total_rows = int(total_parts[1]) if len(total_parts) > 1 and total_parts[1] else 0
|
||||
table_count = int(total_parts[2]) if len(total_parts) > 2 and total_parts[2] else 0
|
||||
|
||||
# Get individual table sizes (top 50 largest tables)
|
||||
tables_query = """
|
||||
SELECT table_name,
|
||||
ROUND((data_length + index_length) / 1024 / 1024, 2) AS size_mb,
|
||||
table_rows
|
||||
FROM information_schema.TABLES
|
||||
WHERE table_schema = DATABASE()
|
||||
ORDER BY (data_length + index_length) DESC
|
||||
LIMIT 50
|
||||
"""
|
||||
|
||||
tables_cmd = f'db query "{tables_query}" --skip-column-names'
|
||||
tables_result = await self.wp_cli.execute_command(tables_cmd)
|
||||
|
||||
# Parse table results (tab-separated)
|
||||
tables_output = tables_result.get("output", "").strip()
|
||||
tables = []
|
||||
|
||||
if tables_output:
|
||||
for line in tables_output.split("\n"):
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 3:
|
||||
tables.append(
|
||||
{
|
||||
"table_name": parts[0],
|
||||
"size_mb": float(parts[1]) if parts[1] else 0.0,
|
||||
"table_rows": int(parts[2]) if parts[2] else 0,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_size_mb": total_size_mb,
|
||||
"total_rows": total_rows,
|
||||
"table_count": table_count,
|
||||
"tables": tables,
|
||||
"note": "Showing top 50 largest tables",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Database size check failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def wp_db_tables(self, prefix_only: bool = True) -> dict[str, Any]:
|
||||
"""List all database tables with details"""
|
||||
if not self.wp_cli:
|
||||
return {"success": False, "error": "WP-CLI not available"}
|
||||
|
||||
try:
|
||||
# Query for table information
|
||||
query = """
|
||||
SELECT
|
||||
table_name,
|
||||
engine,
|
||||
table_rows,
|
||||
ROUND((data_length / 1024 / 1024), 2),
|
||||
ROUND((index_length / 1024 / 1024), 2),
|
||||
ROUND(((data_length + index_length) / 1024 / 1024), 2),
|
||||
table_collation
|
||||
FROM information_schema.TABLES
|
||||
WHERE table_schema = DATABASE()
|
||||
"""
|
||||
|
||||
if prefix_only:
|
||||
# Get WordPress table prefix
|
||||
prefix_result = await self.wp_cli.execute_command("config get table_prefix")
|
||||
prefix = prefix_result.get("output", "wp_").strip()
|
||||
query += f" AND table_name LIKE '{prefix}%'"
|
||||
|
||||
query += " ORDER BY (data_length + index_length) DESC"
|
||||
|
||||
# Use --skip-column-names for MariaDB compatibility (no --format=json)
|
||||
cmd = f'db query "{query}" --skip-column-names'
|
||||
result = await self.wp_cli.execute_command(cmd)
|
||||
|
||||
# Parse tab-separated output
|
||||
tables_output = result.get("output", "").strip()
|
||||
tables = []
|
||||
|
||||
if tables_output:
|
||||
for line in tables_output.split("\n"):
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 7:
|
||||
tables.append(
|
||||
{
|
||||
"name": parts[0],
|
||||
"engine": parts[1],
|
||||
"rows": int(parts[2]) if parts[2] and parts[2] != "NULL" else 0,
|
||||
"data_size_mb": (
|
||||
float(parts[3]) if parts[3] and parts[3] != "NULL" else 0.0
|
||||
),
|
||||
"index_size_mb": (
|
||||
float(parts[4]) if parts[4] and parts[4] != "NULL" else 0.0
|
||||
),
|
||||
"total_size_mb": (
|
||||
float(parts[5]) if parts[5] and parts[5] != "NULL" else 0.0
|
||||
),
|
||||
"collation": parts[6] if parts[6] != "NULL" else None,
|
||||
}
|
||||
)
|
||||
|
||||
return {"success": True, "tables": tables, "total": len(tables)}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Database tables list failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def wp_db_search(
|
||||
self,
|
||||
search_string: str,
|
||||
tables: list[str] | None = None,
|
||||
regex: bool = False,
|
||||
case_sensitive: bool = False,
|
||||
max_results: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
"""Search database for specific strings"""
|
||||
if not self.wp_cli:
|
||||
return {"success": False, "error": "WP-CLI not available"}
|
||||
|
||||
try:
|
||||
# Build search-replace command in dry-run mode
|
||||
cmd = f'search-replace "{search_string}" "{search_string}" --dry-run --format=count'
|
||||
|
||||
if tables:
|
||||
cmd += f" {' '.join(tables)}"
|
||||
|
||||
if regex:
|
||||
cmd += " --regex"
|
||||
|
||||
if not case_sensitive:
|
||||
cmd += " --skip-columns=guid" # Common practice
|
||||
|
||||
result = await self.wp_cli.execute_command(cmd)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"search_string": search_string,
|
||||
"matches_found": result.get("output", "0"),
|
||||
"dry_run": True,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Database search failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def wp_db_query(self, query: str, max_rows: int = 1000) -> dict[str, Any]:
|
||||
"""
|
||||
Execute read-only SQL query
|
||||
|
||||
Security: Only SELECT, SHOW, DESCRIBE queries allowed
|
||||
"""
|
||||
if not self.wp_cli:
|
||||
return {"success": False, "error": "WP-CLI not available"}
|
||||
|
||||
# Validate query (additional server-side validation)
|
||||
query_upper = query.strip().upper()
|
||||
allowed_starts = ("SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN")
|
||||
|
||||
if not any(query_upper.startswith(cmd) for cmd in allowed_starts):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries allowed",
|
||||
}
|
||||
|
||||
forbidden = [
|
||||
"INSERT",
|
||||
"UPDATE",
|
||||
"DELETE",
|
||||
"DROP",
|
||||
"ALTER",
|
||||
"CREATE",
|
||||
"TRUNCATE",
|
||||
"REPLACE",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
]
|
||||
|
||||
for keyword in forbidden:
|
||||
if keyword in query_upper:
|
||||
return {"success": False, "error": f"Forbidden keyword in query: {keyword}"}
|
||||
|
||||
try:
|
||||
# Add LIMIT if not present
|
||||
if "LIMIT" not in query_upper:
|
||||
query = f"{query.rstrip(';')} LIMIT {max_rows}"
|
||||
|
||||
# Execute query with --skip-column-names for MariaDB compatibility
|
||||
# First, get column names separately if it's a SELECT
|
||||
results = []
|
||||
|
||||
if query_upper.startswith("SELECT"):
|
||||
# For SELECT queries, we need to parse the tab-separated output
|
||||
cmd = f'db query "{query}" --skip-column-names'
|
||||
result = await self.wp_cli.execute_command(cmd)
|
||||
|
||||
# Get output
|
||||
output = result.get("output", "").strip()
|
||||
|
||||
if output:
|
||||
# Parse tab-separated values
|
||||
lines = output.split("\n")
|
||||
|
||||
# For simple queries, try to detect column structure
|
||||
# We'll return as a list of dictionaries with column indices
|
||||
for idx, line in enumerate(lines):
|
||||
values = line.split("\t")
|
||||
# Create a row dict with column indices
|
||||
row = {f"col_{i}": val for i, val in enumerate(values)}
|
||||
results.append(row)
|
||||
|
||||
# Limit results
|
||||
if idx >= max_rows - 1:
|
||||
break
|
||||
else:
|
||||
# For SHOW, DESCRIBE, etc., just return raw output
|
||||
cmd = f'db query "{query}"'
|
||||
result = await self.wp_cli.execute_command(cmd)
|
||||
output = result.get("output", "").strip()
|
||||
|
||||
# Return as formatted message
|
||||
return {
|
||||
"success": True,
|
||||
"output": output,
|
||||
"query": query,
|
||||
"note": "Results displayed as plain text",
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"results": results,
|
||||
"row_count": len(results),
|
||||
"query": query,
|
||||
"note": "Columns are numbered as col_0, col_1, etc. due to MariaDB compatibility mode.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Database query failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def wp_db_repair(self, tables: list[str] | None = None) -> dict[str, Any]:
|
||||
"""Repair corrupted database tables"""
|
||||
if not self.wp_cli:
|
||||
return {"success": False, "error": "WP-CLI not available"}
|
||||
|
||||
try:
|
||||
# Get list of tables to repair
|
||||
if not tables:
|
||||
# Get all WordPress tables
|
||||
table_list = await self.wp_db_tables(prefix_only=True)
|
||||
if not table_list.get("success"):
|
||||
return table_list
|
||||
|
||||
tables = [t["name"] for t in table_list.get("tables", [])]
|
||||
|
||||
# Repair each table
|
||||
results = []
|
||||
for table in tables:
|
||||
try:
|
||||
query = f"REPAIR TABLE {table}"
|
||||
cmd = f'db query "{query}" --format=json'
|
||||
result = await self.wp_cli.execute_command(cmd)
|
||||
|
||||
repair_result = json.loads(result.get("output", "[]"))
|
||||
|
||||
results.append(
|
||||
{
|
||||
"table": table,
|
||||
"status": "Repaired" if repair_result else "OK",
|
||||
"message": str(repair_result),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
results.append({"table": table, "status": "Failed", "message": str(e)})
|
||||
|
||||
# Count successes/failures
|
||||
success_count = sum(1 for r in results if r["status"] != "Failed")
|
||||
failed_count = len(results) - success_count
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_tables": len(results),
|
||||
"success_count": success_count,
|
||||
"failed_count": failed_count,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Database repair failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -1,582 +0,0 @@
|
||||
"""
|
||||
System Operations Handler
|
||||
|
||||
Manages WordPress system operations including:
|
||||
- System information (PHP, MySQL, WordPress versions)
|
||||
- Disk usage statistics
|
||||
- Cron job management
|
||||
- Cache operations
|
||||
- Error log retrieval
|
||||
|
||||
Most operations require WP-CLI for advanced functionality.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress.wp_cli import WPCLIManager
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator"""
|
||||
return [
|
||||
# System Info
|
||||
{
|
||||
"name": "system_info",
|
||||
"method_name": "system_info",
|
||||
"description": "Get comprehensive system information including PHP version, MySQL version, WordPress version, server info, memory limits, and more.",
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
# PHP Info
|
||||
{
|
||||
"name": "system_phpinfo",
|
||||
"method_name": "system_phpinfo",
|
||||
"description": "Get detailed PHP configuration including loaded extensions, ini settings, and disabled functions.",
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
# Disk Usage
|
||||
{
|
||||
"name": "system_disk_usage",
|
||||
"method_name": "system_disk_usage",
|
||||
"description": "Get disk usage statistics including uploads size, plugins size, themes size, and database size.",
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
# Clear All Caches
|
||||
{
|
||||
"name": "system_clear_all_caches",
|
||||
"method_name": "system_clear_all_caches",
|
||||
"description": "Clear all caches including object cache, transients, and opcache (if available). Safe to run anytime.",
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "write",
|
||||
},
|
||||
# Cron List
|
||||
{
|
||||
"name": "cron_list",
|
||||
"method_name": "cron_list",
|
||||
"description": "List all scheduled WordPress cron jobs with schedule, next run time, and arguments.",
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
# Cron Run
|
||||
{
|
||||
"name": "cron_run",
|
||||
"method_name": "cron_run",
|
||||
"description": "Manually trigger a specific cron job by hook name. Useful for testing or forcing scheduled tasks.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hook": {"type": "string", "description": "Cron hook name to execute"},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {},
|
||||
"default": [],
|
||||
"description": "Optional arguments to pass to the hook",
|
||||
},
|
||||
},
|
||||
"required": ["hook"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# Error Log
|
||||
{
|
||||
"name": "error_log",
|
||||
"method_name": "error_log",
|
||||
"description": "Get recent PHP error log entries. Useful for debugging issues. Admin scope recommended for security.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lines": {
|
||||
"type": "integer",
|
||||
"default": 100,
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"description": "Number of log lines to retrieve",
|
||||
},
|
||||
"filter": {
|
||||
"type": "string",
|
||||
"description": "Filter logs by keyword (case-insensitive)",
|
||||
},
|
||||
"level": {
|
||||
"type": "string",
|
||||
"enum": ["error", "warning", "notice", "fatal"],
|
||||
"description": "Filter by error level",
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class SystemHandler:
|
||||
"""Handles WordPress system operations"""
|
||||
|
||||
def __init__(self, client: WordPressClient, wp_cli: WPCLIManager | None = None):
|
||||
"""
|
||||
Initialize System Handler
|
||||
|
||||
Args:
|
||||
client: WordPress REST API client
|
||||
wp_cli: WP-CLI manager (optional, for advanced operations)
|
||||
"""
|
||||
self.client = client
|
||||
self.wp_cli = wp_cli
|
||||
self.logger = client.logger
|
||||
|
||||
async def wp_cli_version(self) -> dict[str, Any]:
|
||||
"""Get WP-CLI version information"""
|
||||
if not self.wp_cli:
|
||||
return {"success": False, "version": None, "error": "WP-CLI not available"}
|
||||
|
||||
try:
|
||||
result = await self.wp_cli.execute_command("cli version")
|
||||
version_output = result.get("output", "").strip()
|
||||
|
||||
# Parse version (e.g., "WP-CLI 2.10.0")
|
||||
version = (
|
||||
version_output.replace("WP-CLI ", "").strip()
|
||||
if "WP-CLI" in version_output
|
||||
else version_output
|
||||
)
|
||||
|
||||
return {"success": True, "version": version, "full_output": version_output}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"WP-CLI version check failed: {str(e)}")
|
||||
return {"success": False, "version": None, "error": str(e)}
|
||||
|
||||
async def system_info(self) -> dict[str, Any]:
|
||||
"""Get comprehensive system information"""
|
||||
if not self.wp_cli:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "WP-CLI not available. Container name not configured.",
|
||||
}
|
||||
|
||||
try:
|
||||
# Get various system info using WP-CLI
|
||||
info_commands = {
|
||||
"php_version": "eval 'echo PHP_VERSION;'",
|
||||
"wordpress_version": "core version",
|
||||
"site_url": "option get siteurl",
|
||||
"active_theme": "theme list --status=active --field=name",
|
||||
"plugin_count": "plugin list --status=active --format=count",
|
||||
"wp_debug": "config get WP_DEBUG",
|
||||
"wp_debug_log": "config get WP_DEBUG_LOG",
|
||||
"multisite": "config get MULTISITE",
|
||||
}
|
||||
|
||||
results = {}
|
||||
for key, cmd in info_commands.items():
|
||||
try:
|
||||
result = await self.wp_cli.execute_command(cmd)
|
||||
results[key] = result.get("output", "").strip()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to get {key}: {str(e)}")
|
||||
results[key] = "N/A"
|
||||
|
||||
# Get PHP settings
|
||||
php_settings_cmd = """eval 'echo json_encode([
|
||||
"memory_limit" => ini_get("memory_limit"),
|
||||
"max_execution_time" => ini_get("max_execution_time"),
|
||||
"upload_max_filesize" => ini_get("upload_max_filesize"),
|
||||
"post_max_size" => ini_get("post_max_size"),
|
||||
"max_input_vars" => ini_get("max_input_vars")
|
||||
]);'"""
|
||||
|
||||
php_result = await self.wp_cli.execute_command(php_settings_cmd)
|
||||
php_settings = json.loads(php_result.get("output", "{}"))
|
||||
|
||||
# Get MySQL version
|
||||
mysql_cmd = 'db query "SELECT VERSION()" --skip-column-names'
|
||||
mysql_result = await self.wp_cli.execute_command(mysql_cmd)
|
||||
mysql_version = mysql_result.get("output", "N/A").strip()
|
||||
|
||||
# Get server software from environment
|
||||
server_cmd = 'eval \'echo $_SERVER["SERVER_SOFTWARE"] ?? "Unknown";\''
|
||||
server_result = await self.wp_cli.execute_command(server_cmd)
|
||||
server_software = server_result.get("output", "Unknown").strip()
|
||||
|
||||
# Get loaded PHP extensions
|
||||
ext_cmd = "eval 'echo json_encode(get_loaded_extensions());'"
|
||||
ext_result = await self.wp_cli.execute_command(ext_cmd)
|
||||
php_extensions = json.loads(ext_result.get("output", "[]"))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"php_version": results.get("php_version", "N/A"),
|
||||
"mysql_version": mysql_version,
|
||||
"wordpress_version": results.get("wordpress_version", "N/A"),
|
||||
"server_software": server_software,
|
||||
"memory_limit": php_settings.get("memory_limit", "N/A"),
|
||||
"max_execution_time": int(php_settings.get("max_execution_time", 0)),
|
||||
"upload_max_filesize": php_settings.get("upload_max_filesize", "N/A"),
|
||||
"post_max_size": php_settings.get("post_max_size", "N/A"),
|
||||
"max_input_vars": int(php_settings.get("max_input_vars", 0)),
|
||||
"php_extensions": php_extensions,
|
||||
"wp_debug": results.get("wp_debug", "false") == "true",
|
||||
"wp_debug_log": results.get("wp_debug_log", "false") == "true",
|
||||
"multisite": results.get("multisite", "false") == "true",
|
||||
"active_plugins": int(results.get("plugin_count", 0)),
|
||||
"active_theme": results.get("active_theme", "N/A"),
|
||||
"site_url": results.get("site_url", "N/A"),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"System info failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def system_phpinfo(self) -> dict[str, Any]:
|
||||
"""Get detailed PHP configuration"""
|
||||
if not self.wp_cli:
|
||||
return {"success": False, "error": "WP-CLI not available"}
|
||||
|
||||
try:
|
||||
# Get PHP version and SAPI
|
||||
version_cmd = "eval 'echo PHP_VERSION;'"
|
||||
version_result = await self.wp_cli.execute_command(version_cmd)
|
||||
php_version = version_result.get("output", "").strip()
|
||||
|
||||
sapi_cmd = "eval 'echo PHP_SAPI;'"
|
||||
sapi_result = await self.wp_cli.execute_command(sapi_cmd)
|
||||
php_sapi = sapi_result.get("output", "").strip()
|
||||
|
||||
# Get loaded extensions
|
||||
ext_cmd = "eval 'echo json_encode(get_loaded_extensions());'"
|
||||
ext_result = await self.wp_cli.execute_command(ext_cmd)
|
||||
extensions = json.loads(ext_result.get("output", "[]"))
|
||||
|
||||
# Get important ini settings
|
||||
ini_settings_cmd = """eval 'echo json_encode([
|
||||
"display_errors" => ini_get("display_errors"),
|
||||
"error_reporting" => ini_get("error_reporting"),
|
||||
"log_errors" => ini_get("log_errors"),
|
||||
"error_log" => ini_get("error_log"),
|
||||
"memory_limit" => ini_get("memory_limit"),
|
||||
"max_execution_time" => ini_get("max_execution_time"),
|
||||
"upload_max_filesize" => ini_get("upload_max_filesize"),
|
||||
"post_max_size" => ini_get("post_max_size"),
|
||||
"max_input_vars" => ini_get("max_input_vars"),
|
||||
"max_input_time" => ini_get("max_input_time"),
|
||||
"default_socket_timeout" => ini_get("default_socket_timeout"),
|
||||
"allow_url_fopen" => ini_get("allow_url_fopen"),
|
||||
"session.save_handler" => ini_get("session.save_handler")
|
||||
]);'"""
|
||||
|
||||
ini_result = await self.wp_cli.execute_command(ini_settings_cmd)
|
||||
ini_settings = json.loads(ini_result.get("output", "{}"))
|
||||
|
||||
# Get disabled functions
|
||||
disabled_cmd = "eval 'echo ini_get(\"disable_functions\");'"
|
||||
disabled_result = await self.wp_cli.execute_command(disabled_cmd)
|
||||
disabled_raw = disabled_result.get("output", "").strip()
|
||||
disabled_functions = [f.strip() for f in disabled_raw.split(",") if f.strip()]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"version": php_version,
|
||||
"sapi": php_sapi,
|
||||
"extensions": extensions,
|
||||
"ini_settings": ini_settings,
|
||||
"disabled_functions": disabled_functions,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"PHP info failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def system_disk_usage(self) -> dict[str, Any]:
|
||||
"""Get disk usage statistics"""
|
||||
if not self.wp_cli:
|
||||
return {"success": False, "error": "WP-CLI not available"}
|
||||
|
||||
try:
|
||||
# Get WordPress root path
|
||||
path_cmd = "eval 'echo ABSPATH;'"
|
||||
path_result = await self.wp_cli.execute_command(path_cmd)
|
||||
wp_path = path_result.get("output", "").strip()
|
||||
|
||||
# Get directory sizes using du command
|
||||
sizes = {}
|
||||
|
||||
# Uploads directory
|
||||
uploads_cmd = f"eval 'echo shell_exec(\"du -sm {wp_path}wp-content/uploads 2>/dev/null | cut -f1\");'"
|
||||
try:
|
||||
uploads_result = await self.wp_cli.execute_command(uploads_cmd)
|
||||
upload_size = uploads_result.get("output", "0").strip()
|
||||
sizes["uploads_size_mb"] = (
|
||||
float(upload_size) if upload_size and upload_size != "" else 0.0
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to get uploads size: {e}")
|
||||
sizes["uploads_size_mb"] = 0.0
|
||||
|
||||
# Plugins directory
|
||||
plugins_cmd = f"eval 'echo shell_exec(\"du -sm {wp_path}wp-content/plugins 2>/dev/null | cut -f1\");'"
|
||||
try:
|
||||
plugins_result = await self.wp_cli.execute_command(plugins_cmd)
|
||||
plugins_size = plugins_result.get("output", "0").strip()
|
||||
sizes["plugins_size_mb"] = (
|
||||
float(plugins_size) if plugins_size and plugins_size != "" else 0.0
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to get plugins size: {e}")
|
||||
sizes["plugins_size_mb"] = 0.0
|
||||
|
||||
# Themes directory
|
||||
themes_cmd = f"eval 'echo shell_exec(\"du -sm {wp_path}wp-content/themes 2>/dev/null | cut -f1\");'"
|
||||
try:
|
||||
themes_result = await self.wp_cli.execute_command(themes_cmd)
|
||||
themes_size = themes_result.get("output", "0").strip()
|
||||
sizes["themes_size_mb"] = (
|
||||
float(themes_size) if themes_size and themes_size != "" else 0.0
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to get themes size: {e}")
|
||||
sizes["themes_size_mb"] = 0.0
|
||||
|
||||
# Total WordPress directory
|
||||
total_cmd = f"eval 'echo shell_exec(\"du -sm {wp_path} 2>/dev/null | cut -f1\");'"
|
||||
try:
|
||||
total_result = await self.wp_cli.execute_command(total_cmd)
|
||||
total_size = total_result.get("output", "0").strip()
|
||||
sizes["wordpress_size_mb"] = (
|
||||
float(total_size) if total_size and total_size != "" else 0.0
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to get wordpress total size: {e}")
|
||||
sizes["wordpress_size_mb"] = 0.0
|
||||
|
||||
# Database size (from our database handler logic)
|
||||
db_query = """
|
||||
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb
|
||||
FROM information_schema.TABLES
|
||||
WHERE table_schema = DATABASE()
|
||||
"""
|
||||
db_cmd = f'db query "{db_query}" --skip-column-names'
|
||||
try:
|
||||
db_result = await self.wp_cli.execute_command(db_cmd)
|
||||
sizes["database_size_mb"] = float(db_result.get("output", "0").strip())
|
||||
except (ValueError, KeyError, Exception):
|
||||
sizes["database_size_mb"] = 0.0
|
||||
|
||||
# Calculate total
|
||||
total_size = sum([sizes["wordpress_size_mb"], sizes["database_size_mb"]])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_size_mb": round(total_size, 2),
|
||||
**sizes,
|
||||
"breakdown": {
|
||||
"uploads": sizes["uploads_size_mb"],
|
||||
"plugins": sizes["plugins_size_mb"],
|
||||
"themes": sizes["themes_size_mb"],
|
||||
"database": sizes["database_size_mb"],
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Disk usage check failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def system_clear_all_caches(self) -> dict[str, Any]:
|
||||
"""Clear all caches"""
|
||||
if not self.wp_cli:
|
||||
return {"success": False, "error": "WP-CLI not available"}
|
||||
|
||||
try:
|
||||
cleared = []
|
||||
|
||||
# Clear object cache
|
||||
try:
|
||||
await self.wp_cli.execute_command("cache flush")
|
||||
cleared.append("object_cache")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Object cache flush failed: {str(e)}")
|
||||
|
||||
# Clear transients
|
||||
try:
|
||||
await self.wp_cli.execute_command("transient delete --all")
|
||||
cleared.append("transients")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Transient delete failed: {str(e)}")
|
||||
|
||||
# Clear OPcache if available
|
||||
try:
|
||||
opcache_cmd = 'eval \'if (function_exists("opcache_reset")) { opcache_reset(); echo "cleared"; }\''
|
||||
opcache_result = await self.wp_cli.execute_command(opcache_cmd)
|
||||
if "cleared" in opcache_result.get("output", ""):
|
||||
cleared.append("opcache")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"OPcache clear failed: {str(e)}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Cleared {len(cleared)} cache types",
|
||||
"cleared": cleared,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Clear all caches failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def cron_list(self) -> dict[str, Any]:
|
||||
"""List all WordPress cron jobs"""
|
||||
if not self.wp_cli:
|
||||
return {"success": False, "error": "WP-CLI not available"}
|
||||
|
||||
try:
|
||||
# Get cron events
|
||||
cmd = "cron event list --format=json"
|
||||
result = await self.wp_cli.execute_command(cmd)
|
||||
|
||||
events_data = json.loads(result.get("output", "[]"))
|
||||
|
||||
# Parse events
|
||||
events = []
|
||||
for event in events_data:
|
||||
events.append(
|
||||
{
|
||||
"hook": event.get("hook", ""),
|
||||
"timestamp": int(event.get("time", 0)),
|
||||
"schedule": event.get("recurrence", "single"),
|
||||
"interval": event.get("interval"),
|
||||
"args": event.get("args", []),
|
||||
}
|
||||
)
|
||||
|
||||
# Get available schedules
|
||||
schedules_cmd = "cron schedule list --format=json"
|
||||
try:
|
||||
schedules_result = await self.wp_cli.execute_command(schedules_cmd)
|
||||
schedules_data = json.loads(schedules_result.get("output", "[]"))
|
||||
schedules = {s.get("name"): s for s in schedules_data}
|
||||
except (json.JSONDecodeError, KeyError, Exception):
|
||||
schedules = {}
|
||||
|
||||
return {"success": True, "events": events, "total": len(events), "schedules": schedules}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Cron list failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def cron_run(self, hook: str, args: list[Any] | None = None) -> dict[str, Any]:
|
||||
"""Manually run a cron job"""
|
||||
if not self.wp_cli:
|
||||
return {"success": False, "error": "WP-CLI not available"}
|
||||
|
||||
try:
|
||||
# Build command
|
||||
cmd = f"cron event run {hook}"
|
||||
|
||||
# Execute cron job
|
||||
result = await self.wp_cli.execute_command(cmd)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Cron job '{hook}' executed",
|
||||
"hook": hook,
|
||||
"output": result.get("output", ""),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Cron run failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def error_log(
|
||||
self, lines: int = 100, filter: str | None = None, level: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Get PHP error log entries"""
|
||||
if not self.wp_cli:
|
||||
return {"success": False, "error": "WP-CLI not available"}
|
||||
|
||||
try:
|
||||
# Get error log path
|
||||
log_path_cmd = "eval 'echo ini_get(\"error_log\");'"
|
||||
log_path_result = await self.wp_cli.execute_command(log_path_cmd)
|
||||
log_path = log_path_result.get("output", "").strip()
|
||||
|
||||
if not log_path or log_path == "":
|
||||
# Try WordPress debug log
|
||||
wp_path_cmd = "eval 'echo WP_CONTENT_DIR;'"
|
||||
wp_path_result = await self.wp_cli.execute_command(wp_path_cmd)
|
||||
wp_content = wp_path_result.get("output", "").strip()
|
||||
log_path = f"{wp_content}/debug.log"
|
||||
|
||||
# Get log file size
|
||||
size_cmd = f"eval 'echo filesize(\"{log_path}\") ?? 0;'"
|
||||
try:
|
||||
size_result = await self.wp_cli.execute_command(size_cmd)
|
||||
log_size_bytes = int(size_result.get("output", "0").strip())
|
||||
log_size_mb = round(log_size_bytes / 1024 / 1024, 2)
|
||||
except (ValueError, KeyError, Exception):
|
||||
log_size_mb = 0.0
|
||||
|
||||
# Read log file (last N lines)
|
||||
tail_cmd = f"eval 'exec(\"tail -n {lines} {log_path} 2>&1\");'"
|
||||
log_result = await self.wp_cli.execute_command(tail_cmd)
|
||||
log_lines = log_result.get("output", "").strip().split("\n")
|
||||
|
||||
# Parse log entries
|
||||
entries = []
|
||||
for line in log_lines:
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
# Basic parsing (PHP error log format varies)
|
||||
# Format: [timestamp] PHP Error_Type: message in file on line X
|
||||
match = re.match(r"\[([^\]]+)\]\s+PHP\s+(\w+):\s+(.+)", line)
|
||||
|
||||
if match:
|
||||
timestamp, error_type, message = match.groups()
|
||||
|
||||
# Extract file and line if present
|
||||
file_match = re.search(r"in\s+(.+)\s+on\s+line\s+(\d+)", message)
|
||||
file_path = file_match.group(1) if file_match else None
|
||||
line_num = int(file_match.group(2)) if file_match else None
|
||||
|
||||
entry = {
|
||||
"timestamp": timestamp,
|
||||
"level": error_type.lower(),
|
||||
"message": message,
|
||||
"file": file_path,
|
||||
"line": line_num,
|
||||
}
|
||||
|
||||
# Apply filters
|
||||
if level and entry["level"] != level.lower():
|
||||
continue
|
||||
|
||||
if filter and filter.lower() not in message.lower():
|
||||
continue
|
||||
|
||||
entries.append(entry)
|
||||
else:
|
||||
# Unparsed line - include as-is
|
||||
entries.append(
|
||||
{
|
||||
"timestamp": "N/A",
|
||||
"level": "unknown",
|
||||
"message": line,
|
||||
"file": None,
|
||||
"line": None,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"entries": entries,
|
||||
"total_lines": len(log_lines),
|
||||
"filtered_lines": len(entries),
|
||||
"log_size_mb": log_size_mb,
|
||||
"log_path": log_path,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error log retrieval failed: {str(e)}")
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -1,291 +0,0 @@
|
||||
"""
|
||||
WordPress Advanced Plugin - Clean Architecture
|
||||
|
||||
Advanced WordPress management features requiring elevated permissions.
|
||||
Provides database operations, bulk operations, and system management.
|
||||
|
||||
This plugin is separated from core WordPress plugin for:
|
||||
- Better security (separate API keys for advanced features)
|
||||
- Better tool visibility (users see only features they need)
|
||||
- Granular access control
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.base import BasePlugin
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress_advanced import handlers
|
||||
|
||||
|
||||
class WordPressAdvancedPlugin(BasePlugin):
|
||||
"""
|
||||
WordPress Advanced plugin - separated for security and visibility.
|
||||
|
||||
Provides advanced WordPress management capabilities:
|
||||
- Database operations (export, import, search, query, repair)
|
||||
- Bulk operations (batch updates/deletes for posts, products, media)
|
||||
- System operations (system info, cache, cron, error logs)
|
||||
|
||||
Requires:
|
||||
- WordPress site with REST API
|
||||
- WP-CLI access (for database and system operations)
|
||||
- Docker container name (for WP-CLI execution)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_plugin_name() -> str:
|
||||
"""Return plugin type identifier"""
|
||||
return "wordpress_advanced"
|
||||
|
||||
@staticmethod
|
||||
def get_required_config_keys() -> list[str]:
|
||||
"""Return required configuration keys"""
|
||||
return ["url", "username", "app_password", "container"]
|
||||
|
||||
def __init__(self, config: dict[str, Any], project_id: str | None = None):
|
||||
"""
|
||||
Initialize WordPress Advanced plugin with handlers.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary containing:
|
||||
- url: WordPress site URL
|
||||
- username: WordPress username
|
||||
- app_password: WordPress application password
|
||||
- container: Docker container name for WP-CLI (REQUIRED)
|
||||
project_id: Optional project ID (auto-generated if not provided)
|
||||
"""
|
||||
super().__init__(config, project_id=project_id)
|
||||
|
||||
# Create WordPress API client
|
||||
self.client = WordPressClient(
|
||||
site_url=config["url"], username=config["username"], app_password=config["app_password"]
|
||||
)
|
||||
|
||||
# WP-CLI is REQUIRED for wordpress_advanced
|
||||
container_name = config.get("container")
|
||||
if not container_name:
|
||||
raise ValueError(
|
||||
"WordPress Advanced plugin requires 'container' configuration. "
|
||||
"Please set the 'container' field when adding the site in the dashboard."
|
||||
)
|
||||
|
||||
# Import WP-CLI manager
|
||||
from plugins.wordpress.wp_cli import WPCLIManager
|
||||
|
||||
wp_cli_manager = WPCLIManager(container_name)
|
||||
|
||||
# Initialize handlers (all require WP-CLI or advanced REST API)
|
||||
self.database = handlers.DatabaseHandler(self.client, wp_cli_manager)
|
||||
self.bulk = handlers.BulkHandler(self.client)
|
||||
self.system = handlers.SystemHandler(self.client, wp_cli_manager)
|
||||
|
||||
@staticmethod
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""
|
||||
Return all tool specifications for ToolGenerator.
|
||||
|
||||
This method is called by ToolGenerator to create unified tools
|
||||
with site parameter routing.
|
||||
|
||||
Returns:
|
||||
List of tool specification dictionaries (22 tools total)
|
||||
"""
|
||||
specs = []
|
||||
|
||||
# Database operations (7 tools)
|
||||
specs.extend(handlers.get_database_specs())
|
||||
|
||||
# Bulk operations (8 tools)
|
||||
specs.extend(handlers.get_bulk_specs())
|
||||
|
||||
# System operations (7 tools)
|
||||
specs.extend(handlers.get_system_specs())
|
||||
|
||||
return specs
|
||||
|
||||
async def health_check(self) -> dict[str, Any]:
|
||||
"""
|
||||
Check WordPress Advanced features availability.
|
||||
|
||||
Returns:
|
||||
Dict with health status and WP-CLI availability
|
||||
"""
|
||||
try:
|
||||
# Test REST API access by hitting /wp-json/ directly
|
||||
# NOTE: self.client.get("/") hits /wp-json/wp/v2/ which doesn't
|
||||
# return a "name" field. We need /wp-json/ for the site index.
|
||||
rest_api_available = False
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
site_url = self.client.site_url
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"{site_url}/wp-json/",
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
ssl=False,
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
rest_api_available = bool(data.get("name"))
|
||||
except Exception as e:
|
||||
self.logger.warning(f"REST API check failed: {e}")
|
||||
|
||||
# Test authentication with an authenticated request
|
||||
auth_valid = False
|
||||
if rest_api_available:
|
||||
try:
|
||||
await self.client.get("users/me")
|
||||
auth_valid = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Test WP-CLI access (optional — only needed for database/system tools)
|
||||
wp_cli_available = False
|
||||
try:
|
||||
wp_cli_version = await self.system.wp_cli_version()
|
||||
wp_cli_available = bool(wp_cli_version.get("version"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = {
|
||||
"healthy": rest_api_available,
|
||||
"wp_cli_available": wp_cli_available,
|
||||
"rest_api_available": rest_api_available,
|
||||
"auth_valid": auth_valid,
|
||||
"features": {
|
||||
"database_operations": wp_cli_available,
|
||||
"bulk_operations": rest_api_available,
|
||||
"system_operations": wp_cli_available,
|
||||
},
|
||||
}
|
||||
if not auth_valid and rest_api_available:
|
||||
result["auth_warning"] = "Site accessible but credentials may be invalid"
|
||||
return result
|
||||
except Exception as e:
|
||||
return {
|
||||
"healthy": False,
|
||||
"error": str(e),
|
||||
"wp_cli_available": False,
|
||||
"rest_api_available": False,
|
||||
}
|
||||
|
||||
# ========================================
|
||||
# Method Delegation to Handlers
|
||||
# ========================================
|
||||
# All methods delegate to appropriate handlers
|
||||
# This maintains backward compatibility with existing code
|
||||
|
||||
# === Database Operations (7 tools) ===
|
||||
async def wp_db_export(self, **kwargs):
|
||||
"""Export WordPress database"""
|
||||
result = await self.database.wp_db_export(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_db_import(self, **kwargs):
|
||||
"""Import WordPress database"""
|
||||
result = await self.database.wp_db_import(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_db_size(self, **kwargs):
|
||||
"""Get database size"""
|
||||
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):
|
||||
"""List database tables"""
|
||||
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):
|
||||
"""Search database"""
|
||||
result = await self.database.wp_db_search(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_db_query(self, **kwargs):
|
||||
"""Execute read-only database query"""
|
||||
result = await self.database.wp_db_query(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_db_repair(self, **kwargs):
|
||||
"""Repair and optimize database"""
|
||||
result = await self.database.wp_db_repair(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# === Bulk Operations (8 tools) ===
|
||||
async def bulk_update_posts(self, **kwargs):
|
||||
"""Bulk update posts"""
|
||||
result = await self.bulk.bulk_update_posts(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def bulk_delete_posts(self, **kwargs):
|
||||
"""Bulk delete posts"""
|
||||
result = await self.bulk.bulk_delete_posts(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def bulk_update_products(self, **kwargs):
|
||||
"""Bulk update products"""
|
||||
result = await self.bulk.bulk_update_products(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def bulk_delete_products(self, **kwargs):
|
||||
"""Bulk delete products"""
|
||||
result = await self.bulk.bulk_delete_products(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def bulk_delete_media(self, **kwargs):
|
||||
"""Bulk delete media"""
|
||||
result = await self.bulk.bulk_delete_media(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def bulk_assign_categories(self, **kwargs):
|
||||
"""Bulk assign categories to posts"""
|
||||
result = await self.bulk.bulk_assign_categories(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def bulk_assign_tags(self, **kwargs):
|
||||
"""Bulk assign tags to posts"""
|
||||
result = await self.bulk.bulk_assign_tags(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def bulk_trash_posts(self, **kwargs):
|
||||
"""Bulk move posts to trash"""
|
||||
result = await self.bulk.bulk_trash_posts(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# === System Operations (7 tools) ===
|
||||
async def system_info(self, **kwargs):
|
||||
"""Get system information"""
|
||||
result = await self.system.system_info(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def system_phpinfo(self, **kwargs):
|
||||
"""Get PHP information"""
|
||||
result = await self.system.system_phpinfo(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def system_disk_usage(self, **kwargs):
|
||||
"""Get disk usage"""
|
||||
result = await self.system.system_disk_usage(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def system_clear_all_caches(self, **kwargs):
|
||||
"""Clear all caches"""
|
||||
result = await self.system.system_clear_all_caches(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def cron_list(self, **kwargs):
|
||||
"""List cron jobs"""
|
||||
result = await self.system.cron_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def cron_run(self, **kwargs):
|
||||
"""Run cron job"""
|
||||
result = await self.system.cron_run(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def error_log(self, **kwargs):
|
||||
"""Get error log"""
|
||||
result = await self.system.error_log(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
@@ -1,34 +0,0 @@
|
||||
"""
|
||||
WordPress Advanced Pydantic Schemas
|
||||
"""
|
||||
|
||||
from .bulk import *
|
||||
from .database import *
|
||||
from .system import *
|
||||
|
||||
__all__ = [
|
||||
# Database schemas
|
||||
"DatabaseExportRequest",
|
||||
"DatabaseImportRequest",
|
||||
"DatabaseSizeResponse",
|
||||
"DatabaseTablesResponse",
|
||||
"DatabaseSearchRequest",
|
||||
"DatabaseQueryRequest",
|
||||
"DatabaseRepairResponse",
|
||||
# Bulk schemas
|
||||
"BulkUpdatePostsRequest",
|
||||
"BulkDeletePostsRequest",
|
||||
"BulkUpdateProductsRequest",
|
||||
"BulkDeleteProductsRequest",
|
||||
"BulkDeleteMediaRequest",
|
||||
"BulkAssignCategoriesRequest",
|
||||
"BulkAssignTagsRequest",
|
||||
"BulkOperationResponse",
|
||||
# System schemas
|
||||
"SystemInfoResponse",
|
||||
"SystemPHPInfoResponse",
|
||||
"SystemDiskUsageResponse",
|
||||
"CronListResponse",
|
||||
"CronRunRequest",
|
||||
"ErrorLogResponse",
|
||||
]
|
||||
@@ -1,259 +0,0 @@
|
||||
"""
|
||||
Bulk Operations Schemas
|
||||
|
||||
Pydantic models for WordPress bulk operations including:
|
||||
- Bulk updates for posts/products
|
||||
- Bulk deletions
|
||||
- Bulk category/tag assignments
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class BulkUpdatePostsParams(BaseModel):
|
||||
"""Parameters for bulk updating posts"""
|
||||
|
||||
post_ids: list[int] = Field(
|
||||
..., min_length=1, max_length=100, description="List of post IDs to update (max 100)"
|
||||
)
|
||||
updates: dict[str, Any] = Field(
|
||||
..., description="Fields to update (status, author_id, categories, tags, etc.)"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@field_validator("post_ids")
|
||||
def validate_post_ids(cls, v):
|
||||
"""Ensure all IDs are positive"""
|
||||
if any(id <= 0 for id in v):
|
||||
raise ValueError("All post IDs must be positive integers")
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
@field_validator("updates")
|
||||
def validate_updates(cls, v):
|
||||
"""Validate update fields are allowed"""
|
||||
allowed_fields = {
|
||||
"status",
|
||||
"title",
|
||||
"content",
|
||||
"excerpt",
|
||||
"author",
|
||||
"categories",
|
||||
"tags",
|
||||
"featured_media",
|
||||
"comment_status",
|
||||
"ping_status",
|
||||
"sticky",
|
||||
"format",
|
||||
"meta",
|
||||
}
|
||||
|
||||
invalid_fields = set(v.keys()) - allowed_fields
|
||||
if invalid_fields:
|
||||
raise ValueError(f"Invalid update fields: {', '.join(invalid_fields)}")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class BulkDeletePostsParams(BaseModel):
|
||||
"""Parameters for bulk deleting posts"""
|
||||
|
||||
post_ids: list[int] = Field(
|
||||
..., min_length=1, max_length=100, description="List of post IDs to delete (max 100)"
|
||||
)
|
||||
force: bool = Field(default=False, description="Force permanent deletion (bypass trash)")
|
||||
|
||||
@classmethod
|
||||
@field_validator("post_ids")
|
||||
def validate_post_ids(cls, v):
|
||||
"""Ensure all IDs are positive"""
|
||||
if any(id <= 0 for id in v):
|
||||
raise ValueError("All post IDs must be positive integers")
|
||||
return v
|
||||
|
||||
|
||||
class BulkUpdateProductsParams(BaseModel):
|
||||
"""Parameters for bulk updating WooCommerce products"""
|
||||
|
||||
product_ids: list[int] = Field(
|
||||
..., min_length=1, max_length=100, description="List of product IDs to update (max 100)"
|
||||
)
|
||||
updates: dict[str, Any] = Field(
|
||||
..., description="Fields to update (price, stock_quantity, status, etc.)"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@field_validator("product_ids")
|
||||
def validate_product_ids(cls, v):
|
||||
"""Ensure all IDs are positive"""
|
||||
if any(id <= 0 for id in v):
|
||||
raise ValueError("All product IDs must be positive integers")
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
@field_validator("updates")
|
||||
def validate_updates(cls, v):
|
||||
"""Validate update fields are allowed"""
|
||||
allowed_fields = {
|
||||
"name",
|
||||
"status",
|
||||
"featured",
|
||||
"catalog_visibility",
|
||||
"description",
|
||||
"short_description",
|
||||
"sku",
|
||||
"price",
|
||||
"regular_price",
|
||||
"sale_price",
|
||||
"stock_quantity",
|
||||
"stock_status",
|
||||
"manage_stock",
|
||||
"categories",
|
||||
"tags",
|
||||
"images",
|
||||
"attributes",
|
||||
"meta_data",
|
||||
}
|
||||
|
||||
invalid_fields = set(v.keys()) - allowed_fields
|
||||
if invalid_fields:
|
||||
raise ValueError(f"Invalid update fields: {', '.join(invalid_fields)}")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class BulkDeleteProductsParams(BaseModel):
|
||||
"""Parameters for bulk deleting products"""
|
||||
|
||||
product_ids: list[int] = Field(
|
||||
..., min_length=1, max_length=100, description="List of product IDs to delete (max 100)"
|
||||
)
|
||||
force: bool = Field(default=False, description="Force permanent deletion")
|
||||
|
||||
@classmethod
|
||||
@field_validator("product_ids")
|
||||
def validate_product_ids(cls, v):
|
||||
"""Ensure all IDs are positive"""
|
||||
if any(id <= 0 for id in v):
|
||||
raise ValueError("All product IDs must be positive integers")
|
||||
return v
|
||||
|
||||
|
||||
class BulkAssignCategoriesParams(BaseModel):
|
||||
"""Parameters for bulk assigning categories"""
|
||||
|
||||
item_ids: list[int] = Field(
|
||||
..., min_length=1, max_length=100, description="List of post/product IDs (max 100)"
|
||||
)
|
||||
category_ids: list[int] = Field(..., min_length=1, description="List of category IDs to assign")
|
||||
replace: bool = Field(
|
||||
default=False, description="Replace existing categories (true) or append (false)"
|
||||
)
|
||||
item_type: str = Field(default="post", description="Type of items: 'post' or 'product'")
|
||||
|
||||
@classmethod
|
||||
@field_validator("item_ids", "category_ids")
|
||||
def validate_ids(cls, v):
|
||||
"""Ensure all IDs are positive"""
|
||||
if any(id <= 0 for id in v):
|
||||
raise ValueError("All IDs must be positive integers")
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
@field_validator("item_type")
|
||||
def validate_item_type(cls, v):
|
||||
"""Validate item type"""
|
||||
if v not in ["post", "product"]:
|
||||
raise ValueError("item_type must be 'post' or 'product'")
|
||||
return v
|
||||
|
||||
|
||||
class BulkAssignTagsParams(BaseModel):
|
||||
"""Parameters for bulk assigning tags"""
|
||||
|
||||
item_ids: list[int] = Field(
|
||||
..., min_length=1, max_length=100, description="List of post/product IDs (max 100)"
|
||||
)
|
||||
tag_ids: list[int] = Field(..., min_length=1, description="List of tag IDs to assign")
|
||||
replace: bool = Field(
|
||||
default=False, description="Replace existing tags (true) or append (false)"
|
||||
)
|
||||
item_type: str = Field(default="post", description="Type of items: 'post' or 'product'")
|
||||
|
||||
@classmethod
|
||||
@field_validator("item_ids", "tag_ids")
|
||||
def validate_ids(cls, v):
|
||||
"""Ensure all IDs are positive"""
|
||||
if any(id <= 0 for id in v):
|
||||
raise ValueError("All IDs must be positive integers")
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
@field_validator("item_type")
|
||||
def validate_item_type(cls, v):
|
||||
"""Validate item type"""
|
||||
if v not in ["post", "product"]:
|
||||
raise ValueError("item_type must be 'post' or 'product'")
|
||||
return v
|
||||
|
||||
|
||||
class BulkUpdateMediaParams(BaseModel):
|
||||
"""Parameters for bulk updating media items"""
|
||||
|
||||
media_ids: list[int] = Field(
|
||||
..., min_length=1, max_length=100, description="List of media IDs to update (max 100)"
|
||||
)
|
||||
updates: dict[str, Any] = Field(
|
||||
..., description="Fields to update (alt_text, title, caption, description)"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@field_validator("media_ids")
|
||||
def validate_media_ids(cls, v):
|
||||
"""Ensure all IDs are positive"""
|
||||
if any(id <= 0 for id in v):
|
||||
raise ValueError("All media IDs must be positive integers")
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
@field_validator("updates")
|
||||
def validate_updates(cls, v):
|
||||
"""Validate update fields are allowed"""
|
||||
allowed_fields = {"title", "alt_text", "caption", "description", "meta"}
|
||||
|
||||
invalid_fields = set(v.keys()) - allowed_fields
|
||||
if invalid_fields:
|
||||
raise ValueError(f"Invalid update fields: {', '.join(invalid_fields)}")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class BulkDeleteMediaParams(BaseModel):
|
||||
"""Parameters for bulk deleting media items"""
|
||||
|
||||
media_ids: list[int] = Field(
|
||||
..., min_length=1, max_length=100, description="List of media IDs to delete (max 100)"
|
||||
)
|
||||
force: bool = Field(
|
||||
default=True, description="Force permanent deletion (media can't be trashed)"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@field_validator("media_ids")
|
||||
def validate_media_ids(cls, v):
|
||||
"""Ensure all IDs are positive"""
|
||||
if any(id <= 0 for id in v):
|
||||
raise ValueError("All media IDs must be positive integers")
|
||||
return v
|
||||
|
||||
|
||||
class BulkOperationResult(BaseModel):
|
||||
"""Result of a bulk operation"""
|
||||
|
||||
success_count: int = Field(description="Number of successful operations")
|
||||
failed_count: int = Field(description="Number of failed operations")
|
||||
total: int = Field(description="Total items processed")
|
||||
failed_ids: list[int] = Field(default=[], description="IDs that failed to process")
|
||||
errors: list[dict[str, Any]] = Field(default=[], description="Detailed error information")
|
||||
@@ -1,183 +0,0 @@
|
||||
"""
|
||||
Database Operations Schemas
|
||||
|
||||
Pydantic models for WordPress database operations including:
|
||||
- Database export/import
|
||||
- Backup/restore
|
||||
- Optimization and repair
|
||||
- Search and query operations
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class DatabaseExportParams(BaseModel):
|
||||
"""Parameters for database export"""
|
||||
|
||||
tables: list[str] | None = Field(
|
||||
default=None, description="Specific tables to export (default: all tables)"
|
||||
)
|
||||
exclude_tables: list[str] | None = Field(
|
||||
default=None, description="Tables to exclude from export"
|
||||
)
|
||||
add_drop_table: bool = Field(default=True, description="Include DROP TABLE statements")
|
||||
|
||||
@classmethod
|
||||
@field_validator("tables", "exclude_tables")
|
||||
def validate_table_names(cls, v):
|
||||
"""Validate table names - no special characters"""
|
||||
if v:
|
||||
for table in v:
|
||||
if not table.replace("_", "").isalnum():
|
||||
raise ValueError(f"Invalid table name: {table}")
|
||||
return v
|
||||
|
||||
|
||||
class DatabaseImportParams(BaseModel):
|
||||
"""Parameters for database import"""
|
||||
|
||||
file_path: str | None = Field(default=None, description="Path to SQL file on server")
|
||||
url: str | None = Field(default=None, description="URL to download SQL file from")
|
||||
skip_optimization: bool = Field(
|
||||
default=False, description="Skip database optimization after import"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@field_validator("url")
|
||||
def validate_url(cls, v):
|
||||
"""Validate URL format"""
|
||||
if v and not v.startswith(("http://", "https://")):
|
||||
raise ValueError("URL must start with http:// or https://")
|
||||
return v
|
||||
|
||||
|
||||
class DatabaseBackupParams(BaseModel):
|
||||
"""Parameters for creating database backup"""
|
||||
|
||||
description: str | None = Field(
|
||||
default=None, max_length=255, description="Optional description for this backup"
|
||||
)
|
||||
compress: bool = Field(default=True, description="Compress backup with gzip")
|
||||
include_uploads: bool = Field(
|
||||
default=False, description="Also backup wp-content/uploads directory"
|
||||
)
|
||||
|
||||
|
||||
class DatabaseRestoreParams(BaseModel):
|
||||
"""Parameters for restoring database"""
|
||||
|
||||
backup_id: str | None = Field(default=None, description="Backup ID to restore")
|
||||
timestamp: str | None = Field(
|
||||
default=None, description="Backup timestamp (alternative to backup_id)"
|
||||
)
|
||||
confirm: bool = Field(default=False, description="Confirmation required (safety check)")
|
||||
|
||||
@classmethod
|
||||
@field_validator("confirm")
|
||||
def validate_confirmation(cls, v):
|
||||
"""Require explicit confirmation for restore"""
|
||||
if not v:
|
||||
raise ValueError("Confirmation required: set confirm=true")
|
||||
return v
|
||||
|
||||
|
||||
class DatabaseSearchParams(BaseModel):
|
||||
"""Parameters for searching database"""
|
||||
|
||||
search_string: str = Field(
|
||||
..., min_length=1, max_length=255, description="String to search for in database"
|
||||
)
|
||||
tables: list[str] | None = Field(
|
||||
default=None, description="Specific tables to search (default: all tables)"
|
||||
)
|
||||
regex: bool = Field(default=False, description="Use regex pattern matching")
|
||||
case_sensitive: bool = Field(default=False, description="Case-sensitive search")
|
||||
max_results: int = Field(default=100, ge=1, le=1000, description="Maximum results to return")
|
||||
|
||||
|
||||
class DatabaseQueryParams(BaseModel):
|
||||
"""Parameters for executing SQL query"""
|
||||
|
||||
query: str = Field(
|
||||
..., min_length=1, max_length=10000, description="SQL query to execute (SELECT only)"
|
||||
)
|
||||
max_rows: int = Field(default=1000, ge=1, le=10000, description="Maximum rows to return")
|
||||
|
||||
@classmethod
|
||||
@field_validator("query")
|
||||
def validate_query(cls, v):
|
||||
"""
|
||||
Validate query is safe (read-only SELECT)
|
||||
|
||||
Security: Only allow SELECT, SHOW, DESCRIBE statements
|
||||
Prevent: INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, etc.
|
||||
"""
|
||||
query_upper = v.strip().upper()
|
||||
|
||||
# Allowed statement types
|
||||
allowed_starts = ("SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN")
|
||||
|
||||
if not any(query_upper.startswith(cmd) for cmd in allowed_starts):
|
||||
raise ValueError("Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries allowed")
|
||||
|
||||
# Forbidden keywords (prevent nested destructive queries)
|
||||
forbidden = [
|
||||
"INSERT",
|
||||
"UPDATE",
|
||||
"DELETE",
|
||||
"DROP",
|
||||
"ALTER",
|
||||
"CREATE",
|
||||
"TRUNCATE",
|
||||
"REPLACE",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
]
|
||||
|
||||
for keyword in forbidden:
|
||||
if keyword in query_upper:
|
||||
raise ValueError(f"Forbidden keyword in query: {keyword}")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class DatabaseSizeResponse(BaseModel):
|
||||
"""Response model for database size information"""
|
||||
|
||||
total_size_mb: float = Field(description="Total database size in MB")
|
||||
tables: list[dict[str, Any]] = Field(description="List of tables with size information")
|
||||
row_count: int = Field(description="Total row count across all tables")
|
||||
|
||||
|
||||
class DatabaseTableInfo(BaseModel):
|
||||
"""Information about a database table"""
|
||||
|
||||
name: str
|
||||
engine: str
|
||||
rows: int
|
||||
data_size_mb: float
|
||||
index_size_mb: float
|
||||
total_size_mb: float
|
||||
collation: str
|
||||
|
||||
|
||||
class DatabaseRepairResult(BaseModel):
|
||||
"""Result of database repair operation"""
|
||||
|
||||
table: str
|
||||
status: str # 'OK', 'Repaired', 'Failed'
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class DatabaseBackupInfo(BaseModel):
|
||||
"""Information about a database backup"""
|
||||
|
||||
backup_id: str
|
||||
timestamp: str
|
||||
size_mb: float
|
||||
compressed: bool
|
||||
description: str | None = None
|
||||
location: str
|
||||
tables_count: int
|
||||
@@ -1,150 +0,0 @@
|
||||
"""
|
||||
System Operations Schemas
|
||||
|
||||
Pydantic models for WordPress system operations including:
|
||||
- System information
|
||||
- Disk usage
|
||||
- Cron management
|
||||
- Cache operations
|
||||
- Error logs
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class SystemInfoResponse(BaseModel):
|
||||
"""Comprehensive system information"""
|
||||
|
||||
php_version: str = Field(description="PHP version")
|
||||
mysql_version: str = Field(description="MySQL/MariaDB version")
|
||||
wordpress_version: str = Field(description="WordPress core version")
|
||||
server_software: str = Field(description="Web server software")
|
||||
memory_limit: str = Field(description="PHP memory limit")
|
||||
max_execution_time: int = Field(description="Max execution time in seconds")
|
||||
upload_max_filesize: str = Field(description="Maximum upload file size")
|
||||
post_max_size: str = Field(description="Maximum POST size")
|
||||
max_input_vars: int = Field(description="Maximum input variables")
|
||||
php_extensions: list[str] = Field(default=[], description="Loaded PHP extensions")
|
||||
wp_debug: bool = Field(description="WP_DEBUG constant status")
|
||||
wp_debug_log: bool = Field(description="WP_DEBUG_LOG constant status")
|
||||
multisite: bool = Field(description="WordPress Multisite enabled")
|
||||
active_plugins: int = Field(description="Number of active plugins")
|
||||
active_theme: str = Field(description="Active theme name")
|
||||
|
||||
|
||||
class PHPInfoResponse(BaseModel):
|
||||
"""PHP configuration details"""
|
||||
|
||||
version: str
|
||||
sapi: str = Field(description="Server API (e.g., fpm-fcgi, apache2handler)")
|
||||
extensions: list[str] = Field(description="Loaded PHP extensions")
|
||||
ini_settings: dict[str, str] = Field(description="Important php.ini settings")
|
||||
disabled_functions: list[str] = Field(default=[], description="Disabled PHP functions")
|
||||
|
||||
|
||||
class DiskUsageResponse(BaseModel):
|
||||
"""Disk usage statistics"""
|
||||
|
||||
total_size_mb: float = Field(description="Total disk usage in MB")
|
||||
wordpress_size_mb: float = Field(description="WordPress installation size")
|
||||
uploads_size_mb: float = Field(description="wp-content/uploads size")
|
||||
plugins_size_mb: float = Field(description="wp-content/plugins size")
|
||||
themes_size_mb: float = Field(description="wp-content/themes size")
|
||||
database_size_mb: float = Field(description="Database size")
|
||||
available_space_mb: float | None = Field(
|
||||
default=None, description="Available disk space (if accessible)"
|
||||
)
|
||||
breakdown: dict[str, float] = Field(default={}, description="Detailed breakdown by directory")
|
||||
|
||||
|
||||
class CronEvent(BaseModel):
|
||||
"""WordPress cron event information"""
|
||||
|
||||
hook: str = Field(description="Cron hook name")
|
||||
timestamp: int = Field(description="Unix timestamp of next run")
|
||||
schedule: str = Field(description="Schedule type (hourly, daily, etc.)")
|
||||
interval: int | None = Field(
|
||||
default=None, description="Interval in seconds for recurring events"
|
||||
)
|
||||
args: list[Any] = Field(default=[], description="Arguments passed to the hook")
|
||||
|
||||
|
||||
class CronListResponse(BaseModel):
|
||||
"""List of all cron events"""
|
||||
|
||||
events: list[CronEvent] = Field(description="List of cron events")
|
||||
total: int = Field(description="Total number of events")
|
||||
schedules: dict[str, dict[str, Any]] = Field(default={}, description="Available cron schedules")
|
||||
|
||||
|
||||
class CronRunParams(BaseModel):
|
||||
"""Parameters for manually running a cron job"""
|
||||
|
||||
hook: str = Field(..., min_length=1, max_length=255, description="Cron hook name to execute")
|
||||
args: list[Any] = Field(default=[], description="Optional arguments to pass to the hook")
|
||||
|
||||
@classmethod
|
||||
@field_validator("hook")
|
||||
def validate_hook(cls, v):
|
||||
"""Validate hook name format"""
|
||||
# Hook names should be alphanumeric with underscores, hyphens
|
||||
if not v.replace("_", "").replace("-", "").replace(".", "").isalnum():
|
||||
raise ValueError(
|
||||
"Hook name can only contain letters, numbers, underscores, " "hyphens, and dots"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class ErrorLogParams(BaseModel):
|
||||
"""Parameters for retrieving error log"""
|
||||
|
||||
lines: int = Field(
|
||||
default=100, ge=1, le=1000, description="Number of log lines to retrieve (max 1000)"
|
||||
)
|
||||
filter: str | None = Field(
|
||||
default=None, description="Filter logs by keyword (case-insensitive)"
|
||||
)
|
||||
level: str | None = Field(
|
||||
default=None, description="Filter by error level (error, warning, notice)"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@field_validator("level")
|
||||
def validate_level(cls, v):
|
||||
"""Validate error level"""
|
||||
if v and v.lower() not in ["error", "warning", "notice", "fatal"]:
|
||||
raise ValueError("level must be: error, warning, notice, or fatal")
|
||||
return v.lower() if v else v
|
||||
|
||||
|
||||
class ErrorLogEntry(BaseModel):
|
||||
"""Single error log entry"""
|
||||
|
||||
timestamp: str = Field(description="Error timestamp")
|
||||
level: str = Field(description="Error level (error, warning, etc.)")
|
||||
message: str = Field(description="Error message")
|
||||
file: str | None = Field(default=None, description="File where error occurred")
|
||||
line: int | None = Field(default=None, description="Line number")
|
||||
|
||||
|
||||
class ErrorLogResponse(BaseModel):
|
||||
"""Error log retrieval response"""
|
||||
|
||||
entries: list[ErrorLogEntry] = Field(description="Log entries")
|
||||
total_lines: int = Field(description="Total lines in log file")
|
||||
filtered_lines: int = Field(description="Number of entries returned")
|
||||
log_size_mb: float = Field(description="Log file size in MB")
|
||||
|
||||
|
||||
class CacheStats(BaseModel):
|
||||
"""Cache statistics"""
|
||||
|
||||
cache_type: str = Field(description="Type of object cache (Redis, Memcached, etc.)")
|
||||
cache_enabled: bool = Field(description="Is persistent object cache enabled")
|
||||
transients_count: int = Field(description="Number of transients in database")
|
||||
opcache_enabled: bool = Field(description="Is OPcache enabled")
|
||||
opcache_memory_usage: dict[str, Any] | None = Field(
|
||||
default=None, description="OPcache memory usage statistics"
|
||||
)
|
||||
17
plugins/wordpress_specialist/__init__.py
Normal file
17
plugins/wordpress_specialist/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""WordPress Specialist Plugin (F.19).
|
||||
|
||||
Companion-backed advanced WordPress management for professionals —
|
||||
plugins, themes, users, options, cron, maintenance, page editing, site
|
||||
config + layout, db inspection, bulk fan-out. Replaced the deprecated
|
||||
``wordpress_advanced`` plugin (sunset 2026-05-04 in F.19.3.2-.3); this
|
||||
plugin requires only Airano MCP Bridge v2.18.0+ and an Application
|
||||
Password for a user with ``manage_options``. No Docker socket needed.
|
||||
"""
|
||||
|
||||
from plugins.wordpress_specialist import handlers
|
||||
from plugins.wordpress_specialist.plugin import WordPressSpecialistPlugin
|
||||
|
||||
__all__ = [
|
||||
"WordPressSpecialistPlugin",
|
||||
"handlers",
|
||||
]
|
||||
53
plugins/wordpress_specialist/handlers/__init__.py
Normal file
53
plugins/wordpress_specialist/handlers/__init__.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Handlers for the WordPress Specialist plugin."""
|
||||
|
||||
from plugins.wordpress_specialist.handlers.bulk import BulkHandler
|
||||
from plugins.wordpress_specialist.handlers.bulk import (
|
||||
get_tool_specifications as get_bulk_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.database import DatabaseHandler
|
||||
from plugins.wordpress_specialist.handlers.database import (
|
||||
get_tool_specifications as get_database_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.management import ManagementHandler
|
||||
from plugins.wordpress_specialist.handlers.management import (
|
||||
get_tool_specifications as get_management_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.pages import PagesHandler
|
||||
from plugins.wordpress_specialist.handlers.pages import (
|
||||
get_tool_specifications as get_pages_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.plugins import PluginsHandler
|
||||
from plugins.wordpress_specialist.handlers.plugins import (
|
||||
get_tool_specifications as get_plugins_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.site_config import SiteConfigHandler
|
||||
from plugins.wordpress_specialist.handlers.site_config import (
|
||||
get_tool_specifications as get_site_config_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.site_layout import SiteLayoutHandler
|
||||
from plugins.wordpress_specialist.handlers.site_layout import (
|
||||
get_tool_specifications as get_site_layout_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.themes import ThemesHandler
|
||||
from plugins.wordpress_specialist.handlers.themes import (
|
||||
get_tool_specifications as get_themes_specs,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BulkHandler",
|
||||
"get_bulk_specs",
|
||||
"DatabaseHandler",
|
||||
"get_database_specs",
|
||||
"ManagementHandler",
|
||||
"get_management_specs",
|
||||
"PagesHandler",
|
||||
"get_pages_specs",
|
||||
"PluginsHandler",
|
||||
"get_plugins_specs",
|
||||
"SiteConfigHandler",
|
||||
"get_site_config_specs",
|
||||
"SiteLayoutHandler",
|
||||
"get_site_layout_specs",
|
||||
"ThemesHandler",
|
||||
"get_themes_specs",
|
||||
]
|
||||
293
plugins/wordpress_specialist/handlers/bulk.py
Normal file
293
plugins/wordpress_specialist/handlers/bulk.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""F.19.3.2-.3 — Bulk fan-out surface (post + term updates).
|
||||
|
||||
Stock-REST-backed: each item dispatches to ``wp/v2/posts/{id}`` or
|
||||
``wp/v2/{taxonomy}/{id}`` — no companion routes are added for these
|
||||
because the stock REST endpoints already exist and handle their own
|
||||
permission checks. Per-item permission gating happens at the WP layer
|
||||
(``edit_post`` / ``manage_terms`` per id), so partial successes are
|
||||
the expected shape.
|
||||
|
||||
Surface map:
|
||||
|
||||
* **wp_bulk_post_update** — ``POST wp/v2/posts/{id}`` per item, fanned
|
||||
out with concurrency=10 (mirror of the legacy ``wordpress_advanced``
|
||||
bulk pattern). 50-item cap.
|
||||
* **wp_bulk_term_update** — ``POST wp/v2/{taxonomy}/{id}`` per item,
|
||||
same shape. 50-item cap. ``taxonomy`` is the REST base
|
||||
(``categories`` / ``tags`` / custom rest_base).
|
||||
|
||||
Both tools sit on the ``editor`` tier — same risk class as bulk page
|
||||
edits. Caller needs ``edit_posts`` / ``manage_terms`` (or the
|
||||
taxonomy-specific cap) on every item; per-item failures are surfaced
|
||||
in the response, the rest succeed.
|
||||
|
||||
Security rules (extending S-1…S-25):
|
||||
|
||||
* **S-26** — Bulk operations cap at 50 items per call (mirror of the
|
||||
S-14 / S-18 family). Bigger payloads return ``bulk_too_large`` 400
|
||||
client-side without any HTTP traffic. Per-item permission checks
|
||||
happen one-by-one inside WP REST (``current_user_can('edit_post', $id)``);
|
||||
the client doesn't aggregate caps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# S-26 cap. Server doesn't add its own check — the cap is purely
|
||||
# client-side because each request is a separate stock-REST call.
|
||||
_BULK_MAX_ITEMS = 50
|
||||
|
||||
# Stock REST uses POST for create/update on the post / taxonomy
|
||||
# endpoints. PUT also works in modern WP, but POST is the documented
|
||||
# pattern and matches what WP-CLI emits.
|
||||
_UPDATE_METHOD = "POST"
|
||||
|
||||
# Acceptable taxonomy slug shape — must look like ``sanitize_key``
|
||||
# would have produced it server-side. Lowercased, digits, ``_``, ``-``.
|
||||
_TAXONOMY_RE = re.compile(r"^[a-z0-9][a-z0-9_\-]{0,63}$")
|
||||
|
||||
# Concurrency bound for the fan-out. Matches the legacy
|
||||
# ``wordpress_advanced`` pattern; keeps the WP server from getting
|
||||
# swamped on shared hosting.
|
||||
_FANOUT_CONCURRENCY = 10
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.3.2-.3 bulk surface."""
|
||||
return [
|
||||
{
|
||||
"name": "wp_bulk_post_update",
|
||||
"method_name": "wp_bulk_post_update",
|
||||
"description": (
|
||||
"Fan-out update across multiple posts via stock REST "
|
||||
"``wp/v2/posts/{id}``. Pass ``updates=[{id, ...fields}]`` "
|
||||
"where each item carries the id plus whichever fields "
|
||||
"to write (``status``, ``title``, ``content``, ``excerpt``, "
|
||||
"``categories``, ``tags``, ``author``, ``featured_media``, "
|
||||
"``meta``, etc — anything stock REST accepts on the "
|
||||
"``post`` endpoint). Returns "
|
||||
"``[{id, status:'ok'|'error', error?}]`` — partial "
|
||||
"successes are the expected shape since per-item "
|
||||
"``edit_post`` cap checks happen at the WP layer. "
|
||||
"S-26: 50-item cap per call; bigger payloads return "
|
||||
"``bulk_too_large`` 400 without any HTTP. Concurrency "
|
||||
"is bounded to 10 in flight to avoid swamping shared "
|
||||
"hosts."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"updates": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": _BULK_MAX_ITEMS,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Post id to update.",
|
||||
},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
"description": (
|
||||
f"Updates array (1–{_BULK_MAX_ITEMS} items). "
|
||||
"Each item must carry ``id`` plus any "
|
||||
"post fields stock REST accepts."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["updates"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_bulk_term_update",
|
||||
"method_name": "wp_bulk_term_update",
|
||||
"description": (
|
||||
"Fan-out update across multiple terms in a single "
|
||||
"taxonomy via stock REST ``wp/v2/{taxonomy}/{id}``. "
|
||||
"``taxonomy`` is the REST base — ``categories``, "
|
||||
"``tags``, or a custom taxonomy's ``rest_base``. "
|
||||
"Pass ``updates=[{id, ...fields}]`` where each item "
|
||||
"carries the id plus whichever term fields to write "
|
||||
"(``name``, ``slug``, ``description``, ``parent``, "
|
||||
"``meta``). Returns "
|
||||
"``[{id, status:'ok'|'error', error?}]``. Per-item "
|
||||
"``manage_terms`` (or the taxonomy-specific edit cap) "
|
||||
"is enforced server-side. S-26: 50-item cap per call. "
|
||||
"Concurrency is bounded to 10 in flight."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"taxonomy": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"description": (
|
||||
"REST base of the taxonomy "
|
||||
"(``categories``, ``tags``, or a custom "
|
||||
"taxonomy's rest_base)."
|
||||
),
|
||||
},
|
||||
"updates": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": _BULK_MAX_ITEMS,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Term id to update.",
|
||||
},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
"description": (f"Updates array (1–{_BULK_MAX_ITEMS} items)."),
|
||||
},
|
||||
},
|
||||
"required": ["taxonomy", "updates"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_updates(value: Any) -> list[dict[str, Any]]:
|
||||
"""Validate the bulk updates array (S-26 client side).
|
||||
|
||||
Each item must be a dict with a positive integer ``id``. Other
|
||||
fields are forwarded untouched — stock REST does its own field
|
||||
validation. Empty arrays are rejected (no-op), > 50 returns
|
||||
``bulk_too_large``.
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f"updates must be a list (got {type(value).__name__})")
|
||||
if not value:
|
||||
raise ValueError("updates must contain at least one item")
|
||||
if len(value) > _BULK_MAX_ITEMS:
|
||||
raise ValueError(
|
||||
f"bulk_too_large: updates contains {len(value)} items "
|
||||
f"(max {_BULK_MAX_ITEMS} per call)"
|
||||
)
|
||||
for idx, item in enumerate(value):
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(f"updates[{idx}] must be an object")
|
||||
item_id = item.get("id")
|
||||
if not isinstance(item_id, int) or isinstance(item_id, bool) or item_id < 1:
|
||||
raise ValueError(f"updates[{idx}].id must be a positive integer (got {item_id!r})")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_taxonomy(value: Any) -> str:
|
||||
"""Validate the taxonomy slug shape (cheap pre-check).
|
||||
|
||||
The binding gate is server-side — WP returns 404 for unregistered
|
||||
taxonomies. This catches obviously bad input (slashes, spaces,
|
||||
etc.) without a round-trip.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"taxonomy must be a string (got {type(value).__name__})")
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("taxonomy must be a non-empty string")
|
||||
if not _TAXONOMY_RE.match(stripped):
|
||||
raise ValueError(f"taxonomy must match [a-z0-9][a-z0-9_-]{{0,63}} (got {stripped!r})")
|
||||
return stripped
|
||||
|
||||
|
||||
class BulkHandler:
|
||||
"""Bulk fan-out surface (F.19.3.2-.3) — post + term updates.
|
||||
|
||||
Each method runs N stock-REST requests in parallel (bounded at
|
||||
concurrency=10) and returns the per-item status array. Per-item
|
||||
failures don't fail the whole call — the caller gets
|
||||
``{id, status:'error', error}`` for each one and ``status:'ok'``
|
||||
for the successes.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def _fanout(
|
||||
self,
|
||||
endpoint_template: str,
|
||||
updates: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Run the per-item fan-out with bounded concurrency.
|
||||
|
||||
``endpoint_template`` is a format string that takes the item id —
|
||||
e.g. ``"posts/{id}"`` or ``"categories/{id}"``. Each item's
|
||||
non-``id`` fields are forwarded as the JSON body.
|
||||
"""
|
||||
sem = asyncio.Semaphore(_FANOUT_CONCURRENCY)
|
||||
results: list[dict[str, Any]] = [None] * len(updates) # type: ignore[list-item]
|
||||
|
||||
async def one(idx: int, item: dict[str, Any]) -> None:
|
||||
item_id = int(item["id"])
|
||||
body = {k: v for k, v in item.items() if k != "id"}
|
||||
async with sem:
|
||||
try:
|
||||
await self.client.request(
|
||||
_UPDATE_METHOD,
|
||||
endpoint_template.format(id=item_id),
|
||||
json_data=body if body else None,
|
||||
)
|
||||
results[idx] = {"id": item_id, "status": "ok"}
|
||||
except Exception as exc: # noqa: BLE001 — relay any per-item failure
|
||||
results[idx] = {
|
||||
"id": item_id,
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
await asyncio.gather(*(one(i, u) for i, u in enumerate(updates)))
|
||||
return results
|
||||
|
||||
async def wp_bulk_post_update(
|
||||
self,
|
||||
updates: list[dict[str, Any]],
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
clean = _validate_updates(updates)
|
||||
results = await self._fanout("posts/{id}", clean)
|
||||
ok = sum(1 for r in results if r["status"] == "ok")
|
||||
return {
|
||||
"total": len(results),
|
||||
"ok": ok,
|
||||
"errors": len(results) - ok,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
async def wp_bulk_term_update(
|
||||
self,
|
||||
taxonomy: str,
|
||||
updates: list[dict[str, Any]],
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
tax = _validate_taxonomy(taxonomy)
|
||||
clean = _validate_updates(updates)
|
||||
results = await self._fanout(tax + "/{id}", clean)
|
||||
ok = sum(1 for r in results if r["status"] == "ok")
|
||||
return {
|
||||
"taxonomy": tax,
|
||||
"total": len(results),
|
||||
"ok": ok,
|
||||
"errors": len(results) - ok,
|
||||
"results": results,
|
||||
}
|
||||
249
plugins/wordpress_specialist/handlers/database.py
Normal file
249
plugins/wordpress_specialist/handlers/database.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""F.19.3.2-.3 — Database inspection surface (db/size + db/tables + db/search).
|
||||
|
||||
Closes the database-introspection gap on ``wordpress_specialist`` so the
|
||||
deprecated ``wordpress_advanced`` (Docker-socket / WP-CLI) plugin can
|
||||
sunset cleanly. All three tools are read-only (scope=``read``) and
|
||||
companion-backed via Airano MCP Bridge v2.18.0+.
|
||||
|
||||
Surface map:
|
||||
|
||||
* **wp_db_size** (``GET /admin/db/size``) — single
|
||||
``information_schema.TABLES`` aggregation. Returns
|
||||
``{database_bytes, table_count, row_count_estimate, database_name,
|
||||
table_prefix}``. No SQL exposure: caller doesn't pick the query.
|
||||
* **wp_db_tables** (``GET /admin/db/tables``) — per-table breakdown
|
||||
from the same source. One row per WP table with name / engine /
|
||||
rows / data_bytes / index_bytes / total_bytes / collation.
|
||||
* **wp_db_search** (``POST /admin/db/search``) — search wrapper
|
||||
around ``WP_Query`` with ``s=$query``. NEVER raw SQL.
|
||||
|
||||
Security rules (extending S-1…S-24):
|
||||
|
||||
* **S-25** — ``wp_db_search`` uses ``WP_Query`` with ``s=``, not raw
|
||||
SQL. ``query`` is sanitised via ``sanitize_text_field`` server-side
|
||||
and length-capped at 200 chars client-side. Refusal to return non-
|
||||
readable posts (private/draft from other authors) is enforced by
|
||||
``WP_Query``'s own ``posts_clauses`` filter — the same gate the WP
|
||||
search page uses.
|
||||
|
||||
All routes gated on ``manage_options`` at the companion level. Pulling
|
||||
``information_schema.TABLES`` requires the DB user to have access to it
|
||||
— the standard WordPress install has this for the same MySQL user that
|
||||
runs WP itself, so this is safe in practice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by every F.19.* surface.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# S-25 client-side cap. Server enforces the same limit; this is purely
|
||||
# a fast-fail before the round-trip.
|
||||
_QUERY_MAX_LEN = 200
|
||||
|
||||
# Limit cap on db/search. Mirrors the server-side ceiling.
|
||||
_SEARCH_LIMIT_MAX = 100
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.3.2-.3 database surface."""
|
||||
return [
|
||||
{
|
||||
"name": "wp_db_size",
|
||||
"method_name": "wp_db_size",
|
||||
"description": (
|
||||
"Read aggregate database size for the WordPress install. "
|
||||
"Returns ``{database_bytes, table_count, "
|
||||
"row_count_estimate, database_name, table_prefix}``. "
|
||||
"Source is a single ``information_schema.TABLES`` "
|
||||
"aggregation scoped to the WP table prefix — no SQL is "
|
||||
"exposed to the caller (S-25). InnoDB row counts are "
|
||||
"estimates, not exact, mirroring MySQL's own caveat. "
|
||||
"Use to answer 'how big is this site?' before deciding "
|
||||
"whether to migrate / archive. Requires Airano MCP "
|
||||
"Bridge v2.18.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_db_tables",
|
||||
"method_name": "wp_db_tables",
|
||||
"description": (
|
||||
"Read per-table size + row breakdown. Returns "
|
||||
"``{database_name, table_prefix, tables: [{name, engine, "
|
||||
"rows, data_bytes, index_bytes, total_bytes, collation}]}`` "
|
||||
"ordered by total_bytes descending. Same source as "
|
||||
"``wp_db_size`` — one row per WP table. Useful for "
|
||||
"'which table is the bloat?' debugging (commonly "
|
||||
"options, postmeta, comments, or a plugin's log table). "
|
||||
"Requires Airano MCP Bridge v2.18.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_db_search",
|
||||
"method_name": "wp_db_search",
|
||||
"description": (
|
||||
"Full-text search across post / product content using "
|
||||
"``WP_Query`` with ``s=$query``. NEVER raw SQL (S-25). "
|
||||
"Returns ``{query, limit, count, hits: [{id, post_type, "
|
||||
"status, title, snippet, url, modified}]}``. Sanitises "
|
||||
"``query`` via ``sanitize_text_field`` server-side; "
|
||||
"client caps it at 200 chars. ``limit`` defaults to 20, "
|
||||
"max 100. Optional ``post_type`` (string or array) and "
|
||||
"``status`` (string or array) filters. Non-readable "
|
||||
"posts (private / draft from other authors) are filtered "
|
||||
"out by ``WP_Query``'s own ``posts_clauses`` — same gate "
|
||||
"the WP search page uses. Requires Airano MCP Bridge "
|
||||
"v2.18.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": _QUERY_MAX_LEN,
|
||||
"description": (
|
||||
"Search term. Sanitised server-side via "
|
||||
"``sanitize_text_field``; client caps at "
|
||||
f"{_QUERY_MAX_LEN} chars."
|
||||
),
|
||||
},
|
||||
"post_type": {
|
||||
"description": (
|
||||
"Optional post type filter. String "
|
||||
"(``post``, ``page``, ``product`` ...) or "
|
||||
"array of strings. Defaults to ``any``."
|
||||
),
|
||||
},
|
||||
"status": {
|
||||
"description": (
|
||||
"Optional post status filter. String "
|
||||
"(``publish``, ``draft`` ...) or array of "
|
||||
"strings. Defaults to ``any``."
|
||||
),
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": _SEARCH_LIMIT_MAX,
|
||||
"default": 20,
|
||||
"description": (
|
||||
f"Max hits to return (1–{_SEARCH_LIMIT_MAX}). " "Defaults to 20."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _normalise_query(value: Any) -> str:
|
||||
"""Validate + length-cap the search query (S-25 client side).
|
||||
|
||||
The server re-runs ``sanitize_text_field`` and applies the same
|
||||
length cap; this is a fast-fail before the round-trip. Empty
|
||||
queries are rejected — ``WP_Query`` with ``s=''`` matches every
|
||||
post and would return a meaningless dump.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"query must be a string (got {type(value).__name__})")
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("query must be a non-empty string")
|
||||
if len(stripped) > _QUERY_MAX_LEN:
|
||||
stripped = stripped[:_QUERY_MAX_LEN]
|
||||
return stripped
|
||||
|
||||
|
||||
def _normalise_limit(value: Any) -> int:
|
||||
"""Cap the search limit at 100 (mirror of the server-side ceiling)."""
|
||||
if value is None:
|
||||
return 20
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"limit must be an integer (got {type(value).__name__})")
|
||||
if value < 1:
|
||||
raise ValueError(f"limit must be >= 1 (got {value})")
|
||||
if value > _SEARCH_LIMIT_MAX:
|
||||
return _SEARCH_LIMIT_MAX
|
||||
return value
|
||||
|
||||
|
||||
def _normalise_filter(value: Any, field: str) -> str | list[str] | None:
|
||||
"""Accept a string or list of strings; reject anything else.
|
||||
|
||||
The server sanitises with ``sanitize_key`` per item; we only check
|
||||
structural shape here.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value if value else None
|
||||
if isinstance(value, list):
|
||||
if not all(isinstance(v, str) for v in value):
|
||||
raise ValueError(f"{field} array must contain only strings")
|
||||
return value
|
||||
raise ValueError(f"{field} must be a string or array of strings")
|
||||
|
||||
|
||||
class DatabaseHandler:
|
||||
"""Database inspection surface (F.19.3.2-.3) — db/size + db/tables + db/search.
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion.
|
||||
The plugin.py wrapper serialises for MCP transport. Server errors
|
||||
(500 db_size_query_failed, 400 invalid_query, etc.) are relayed
|
||||
untouched — the companion is the binding gate.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def wp_db_size(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/db/size",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_db_tables(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/db/tables",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_db_search(
|
||||
self,
|
||||
query: str,
|
||||
post_type: str | list[str] | None = None,
|
||||
status: str | list[str] | None = None,
|
||||
limit: int | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"query": _normalise_query(query),
|
||||
"limit": _normalise_limit(limit),
|
||||
}
|
||||
pt = _normalise_filter(post_type, "post_type")
|
||||
if pt is not None:
|
||||
body["post_type"] = pt
|
||||
st = _normalise_filter(status, "status")
|
||||
if st is not None:
|
||||
body["status"] = st
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/db/search",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
236
plugins/wordpress_specialist/handlers/management.py
Normal file
236
plugins/wordpress_specialist/handlers/management.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""F.19.1 — WordPress Specialist read-only management handler.
|
||||
|
||||
Surfaces the ``airano-mcp/v1/admin/*`` companion routes (plugins, themes,
|
||||
users, options, cron, maintenance) as MCP tools. Read-only in this
|
||||
iteration; write operations land in F.19.2 once user-supplied security
|
||||
rules are folded in.
|
||||
|
||||
All tools require companion plugin v2.11.0+ and the saved Application
|
||||
Password to belong to a WordPress user with ``manage_options``. Behaviour
|
||||
when the companion is missing or auth is insufficient is delegated to
|
||||
the companion's own error responses, which arrive as ``rest_no_route``
|
||||
or ``rest_forbidden`` from WordPress core.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Single-source admin namespace prefix. The companion plugin registers
|
||||
# routes under this prefix in airano-mcp-bridge.php register_rest_routes().
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.1 read-only admin surface."""
|
||||
return [
|
||||
{
|
||||
"name": "wp_plugin_list",
|
||||
"method_name": "wp_plugin_list",
|
||||
"description": (
|
||||
"List every plugin known to WordPress with active/network-active "
|
||||
"status, version, author, and update availability. Read-only "
|
||||
"(no install/activate). Requires Airano MCP Bridge v2.11.0+ "
|
||||
"and a WordPress user with manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_list",
|
||||
"method_name": "wp_theme_list",
|
||||
"description": (
|
||||
"List every installed theme: stylesheet/template names, "
|
||||
"version, parent, block-theme flag, active flag, update "
|
||||
"availability. Requires Airano MCP Bridge v2.11.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_user_list",
|
||||
"method_name": "wp_user_list",
|
||||
"description": (
|
||||
"List WordPress users with id, username, email, display name, "
|
||||
"roles, and registration timestamp. Supports optional role "
|
||||
"and search filters; paginated up to 200 per call. Requires "
|
||||
"Airano MCP Bridge v2.11.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"description": "Filter by role slug (e.g. 'administrator', 'editor').",
|
||||
},
|
||||
"search": {
|
||||
"type": "string",
|
||||
"description": "Search across login, email, and display name.",
|
||||
},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 200,
|
||||
"default": 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_option_get",
|
||||
"method_name": "wp_option_get",
|
||||
"description": (
|
||||
"Read a single WordPress option by name. Refuses keys that "
|
||||
"look like credentials (suffix matches secret/password/"
|
||||
"api_key/token/auth_key/auth_salt etc.) — operators can "
|
||||
"still inspect those via wp-admin if needed. Requires "
|
||||
"Airano MCP Bridge v2.11.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Option key (alphanumerics, dashes, underscores).",
|
||||
}
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_cron_list",
|
||||
"method_name": "wp_cron_list",
|
||||
"description": (
|
||||
"Dump the WordPress cron table: hook name, next run time "
|
||||
"(epoch + ISO 8601 UTC), schedule slug, interval, and "
|
||||
"stored args. Requires Airano MCP Bridge v2.11.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_maintenance_status",
|
||||
"method_name": "wp_maintenance_status",
|
||||
"description": (
|
||||
"Report whether WordPress is currently in maintenance mode "
|
||||
"by inspecting the .maintenance sentinel file. Returns "
|
||||
"``enabled``, ``started_at`` (epoch), and ``stale`` (true "
|
||||
"when older than 10 minutes — WP's own threshold). "
|
||||
"Requires Airano MCP Bridge v2.11.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
# F.19.3.1 — system info ports (companion v2.12.0+). Originally
|
||||
# ported from the legacy wordpress_advanced WP-CLI surface
|
||||
# (sunset 2026-05-04); kept here as the companion-backed
|
||||
# equivalents.
|
||||
{
|
||||
"name": "wp_system_info",
|
||||
"method_name": "wp_system_info",
|
||||
"description": (
|
||||
"PHP / MySQL / WordPress versions, server software, "
|
||||
"memory limits, multisite flag, debug state, and "
|
||||
"canonical paths (ABSPATH, plugins, uploads). Companion-"
|
||||
"backed; no Docker socket required. Requires Airano MCP "
|
||||
"Bridge v2.12.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_php_info",
|
||||
"method_name": "wp_php_info",
|
||||
"description": (
|
||||
"Curated PHP configuration snapshot — sorted extension "
|
||||
"list, common ini settings (memory/upload/session/error), "
|
||||
"disabled functions, opcache state. Returns structured "
|
||||
"JSON, not the full ``phpinfo()`` HTML dump (which would "
|
||||
"leak server internals). Requires Airano MCP Bridge "
|
||||
"v2.12.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_disk_usage",
|
||||
"method_name": "wp_disk_usage",
|
||||
"description": (
|
||||
"Bytes used by uploads, plugins, and themes plus "
|
||||
"filesystem-wide ``disk_total/free/used`` for ABSPATH. "
|
||||
"Each tree walk caps at 200k files / 5s wall clock; "
|
||||
"truncated walks set ``truncated: true`` so the caller "
|
||||
"can treat the value as a lower bound. Requires Airano "
|
||||
"MCP Bridge v2.12.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ManagementHandler:
|
||||
"""Thin wrapper around the companion's ``admin`` namespace.
|
||||
|
||||
Every method awaits a companion REST GET and returns the parsed JSON
|
||||
body unchanged so the SPA / MCP client sees the same shape WordPress
|
||||
produced. Errors propagate as exceptions raised by ``WordPressClient``;
|
||||
the plugin.py wrapper layer is responsible for serialisation.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def wp_plugin_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/plugins", use_custom_namespace=True)
|
||||
|
||||
async def wp_theme_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/themes", use_custom_namespace=True)
|
||||
|
||||
async def wp_user_list(
|
||||
self,
|
||||
role: str | None = None,
|
||||
search: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {"page": int(page), "per_page": int(per_page)}
|
||||
if role:
|
||||
params["role"] = role
|
||||
if search:
|
||||
params["search"] = search
|
||||
return await self.client.get(f"{_ADMIN_NS}/users", params=params, use_custom_namespace=True)
|
||||
|
||||
async def wp_option_get(self, name: str, **_: Any) -> dict[str, Any]:
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("wp_option_get requires a non-empty 'name' string")
|
||||
# WordPress option keys are typically [a-zA-Z0-9_-]; the companion
|
||||
# route also enforces this via sanitize_key, but reject obvious
|
||||
# injection attempts (path traversal, slashes) on the client side
|
||||
# so they never reach the wire.
|
||||
if "/" in name or ".." in name or "\x00" in name:
|
||||
raise ValueError(f"wp_option_get rejected suspicious option name: {name!r}")
|
||||
return await self.client.get(f"{_ADMIN_NS}/options/{name}", use_custom_namespace=True)
|
||||
|
||||
async def wp_cron_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/cron", use_custom_namespace=True)
|
||||
|
||||
async def wp_maintenance_status(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/maintenance", use_custom_namespace=True)
|
||||
|
||||
# F.19.3.1 — system info ports (companion v2.12.0+)
|
||||
|
||||
async def wp_system_info(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/system-info", use_custom_namespace=True)
|
||||
|
||||
async def wp_php_info(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/phpinfo", use_custom_namespace=True)
|
||||
|
||||
async def wp_disk_usage(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/disk-usage", use_custom_namespace=True)
|
||||
686
plugins/wordpress_specialist/handlers/pages.py
Normal file
686
plugins/wordpress_specialist/handlers/pages.py
Normal file
@@ -0,0 +1,686 @@
|
||||
"""F.19.5 — Page editing surface (Gutenberg + Elementor + Classic).
|
||||
|
||||
Eleven tools split across three surfaces. The Gutenberg + Elementor +
|
||||
Classic surfaces share one handler because every tool reaches the same
|
||||
WordPress site through the same companion plugin; splitting along the
|
||||
"`pages.py` for content writes / `management.py` for inventory" axis
|
||||
keeps each handler small and focused.
|
||||
|
||||
Surface map:
|
||||
|
||||
* **Gutenberg** (4 tools, companion v2.13.0 routes ``/admin/blocks/*``):
|
||||
``wp_blocks_get`` reads via stock REST + ``parse_blocks()`` server-side
|
||||
in MCPHub; the writes (``wp_blocks_replace`` / ``wp_blocks_insert_at``
|
||||
/ ``wp_blocks_remove_at``) hit the companion so ``serialize_blocks()``
|
||||
stays server-side and avoids client-side corruption of HTML comment
|
||||
delimiters.
|
||||
* **Elementor** (6 tools, ``/admin/elementor/*``):
|
||||
``wp_elementor_detect`` + ``wp_elementor_get`` + ``wp_elementor_template_list``
|
||||
read; ``wp_elementor_set`` + ``wp_elementor_render_css`` +
|
||||
``wp_elementor_template_apply`` write. The companion handles the
|
||||
slash-strip / JSON-validate dance and fires
|
||||
``elementor/document/after_save`` after writes so caches and CSS
|
||||
regenerate cleanly.
|
||||
* **Classic** (1 tool): ``wp_classic_html_replace`` is a thin
|
||||
``post_content`` swap — the only F.19.5 tool that exists for sites
|
||||
that haven't migrated to the block editor.
|
||||
|
||||
Security rules layered on top of F.19.2 S-1…S-11 (companion enforces
|
||||
these regardless of MCPHub-side guards):
|
||||
|
||||
* **S-12** — every block / Elementor / Classic write requires
|
||||
``edit_post`` on the target post id (per-item, not just the global
|
||||
manage_options gate). Companion checks via ``current_user_can``.
|
||||
* **S-13** — block + classic content sanitised via ``wp_kses_post`` by
|
||||
default; ``raw_html=True`` only goes through when the calling WP user
|
||||
has ``unfiltered_html``.
|
||||
* **S-14** — Elementor JSON node count capped at 5,000 per call; the
|
||||
companion returns ``elementor_too_large`` when oversized — callers
|
||||
should switch to ``wp_elementor_template_apply``.
|
||||
|
||||
All tools require Airano MCP Bridge v2.13.0+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by the management surface.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# Stock REST namespace — used by the two tools that don't need companion
|
||||
# routes (``wp_blocks_get`` and ``wp_classic_html_replace`` read paths).
|
||||
_WP_NS = "wp/v2"
|
||||
|
||||
# Mirrored from the companion's BLOCKS_MAX_PER_CALL / ELEMENTOR_MAX_NODES
|
||||
# constants so MCPHub can reject obviously-oversized payloads before
|
||||
# they reach the wire. The companion enforces the real limit.
|
||||
_BLOCKS_MAX_PER_CALL = 200
|
||||
_ELEMENTOR_MAX_NODES = 5000
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.5 page editing surface."""
|
||||
return [
|
||||
# ───── Gutenberg blocks ──────────────────────────────────────
|
||||
{
|
||||
"name": "wp_blocks_get",
|
||||
"method_name": "wp_blocks_get",
|
||||
"description": (
|
||||
"Read a post or page as a block tree. Fetches post_content "
|
||||
"via stock REST then parses it server-side with WP's block "
|
||||
"grammar so the caller gets a structured array of "
|
||||
"{blockName, attrs, innerBlocks, innerHTML} entries. Works "
|
||||
"on any WordPress 5.0+ install — no companion route "
|
||||
"needed for reads."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {
|
||||
"type": "integer",
|
||||
"description": "Target post or page id.",
|
||||
"minimum": 1,
|
||||
},
|
||||
"post_type": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Stock REST collection — ``posts`` (default) or "
|
||||
"``pages``. Companion isn't consulted for reads."
|
||||
),
|
||||
"default": "posts",
|
||||
},
|
||||
},
|
||||
"required": ["post_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_blocks_replace",
|
||||
"method_name": "wp_blocks_replace",
|
||||
"description": (
|
||||
"Replace a post's full block tree. The companion serializes "
|
||||
"the array via WP's serialize_blocks() so HTML comment "
|
||||
"delimiters round-trip cleanly. Block content is sanitised "
|
||||
"with wp_kses_post unless raw_html=true (S-13: requires "
|
||||
"the WP user to also hold unfiltered_html). Capped at 200 "
|
||||
"blocks per call. Requires Airano MCP Bridge v2.13.0+ and "
|
||||
"edit_post on the target."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {"type": "integer", "minimum": 1},
|
||||
"blocks": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Array of block dicts (same shape parse_blocks "
|
||||
"returns). innerBlocks may be nested."
|
||||
),
|
||||
"maxItems": _BLOCKS_MAX_PER_CALL,
|
||||
},
|
||||
"raw_html": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Skip wp_kses_post sanitisation. Companion "
|
||||
"still enforces unfiltered_html — false stays "
|
||||
"the default in every case."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["post_id", "blocks"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_blocks_insert_at",
|
||||
"method_name": "wp_blocks_insert_at",
|
||||
"description": (
|
||||
"Insert a single block at a given index, pushing the rest "
|
||||
"down. Same sanitisation + cap rules as wp_blocks_replace. "
|
||||
"Requires Airano MCP Bridge v2.13.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {"type": "integer", "minimum": 1},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"0-based insertion point. Pass the current "
|
||||
"block count to append. Defaults to append."
|
||||
),
|
||||
},
|
||||
"block": {
|
||||
"type": "object",
|
||||
"description": "Single block dict to insert.",
|
||||
},
|
||||
"raw_html": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["post_id", "block"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_blocks_remove_at",
|
||||
"method_name": "wp_blocks_remove_at",
|
||||
"description": (
|
||||
"Remove the block at the given index. The response "
|
||||
"includes the removed block so the caller can rollback by "
|
||||
"feeding it back to wp_blocks_insert_at. Requires Airano "
|
||||
"MCP Bridge v2.13.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {"type": "integer", "minimum": 1},
|
||||
"index": {"type": "integer", "minimum": 0},
|
||||
},
|
||||
"required": ["post_id", "index"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
# ───── Elementor ─────────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_elementor_detect",
|
||||
"method_name": "wp_elementor_detect",
|
||||
"description": (
|
||||
"Report Elementor presence on the site: installed flag, "
|
||||
"version, Pro flag, and the post types Elementor edits. "
|
||||
"Returns ``installed: false`` cleanly when Elementor is "
|
||||
"absent — non-Elementor sites do not 404. Requires Airano "
|
||||
"MCP Bridge v2.13.0+."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_elementor_get",
|
||||
"method_name": "wp_elementor_get",
|
||||
"description": (
|
||||
"Fetch the parsed _elementor_data tree for a post. The "
|
||||
"companion strips WP's slashes and JSON-decodes server-"
|
||||
"side; the caller always sees a plain array. Returns "
|
||||
"``edited_with_elementor: false`` if the post hasn't been "
|
||||
"opened in Elementor. Requires Airano MCP Bridge v2.13.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"post_id": {"type": "integer", "minimum": 1}},
|
||||
"required": ["post_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_elementor_set",
|
||||
"method_name": "wp_elementor_set",
|
||||
"description": (
|
||||
"Replace a post's _elementor_data tree. Companion validates "
|
||||
"every node has id/elType/settings, enforces the 5,000-node "
|
||||
"cap (S-14), writes via update_post_meta, and fires "
|
||||
"elementor/document/after_save so caches and CSS clear. "
|
||||
"Oversized payloads return ``elementor_too_large``; switch "
|
||||
"to wp_elementor_template_apply. Requires Airano MCP "
|
||||
"Bridge v2.13.0+ and edit_post on the target."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {"type": "integer", "minimum": 1},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Top-level Elementor sections array. Every "
|
||||
"node (recursively, via ``elements``) must "
|
||||
"carry id, elType, settings."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["post_id", "data"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_elementor_render_css",
|
||||
"method_name": "wp_elementor_render_css",
|
||||
"description": (
|
||||
"Trigger Elementor's per-post CSS regeneration so the "
|
||||
"front-end picks up changes from a recent wp_elementor_set "
|
||||
"or theme switch. Equivalent to clicking 'Regenerate CSS' "
|
||||
"scoped to a single post. Requires Airano MCP Bridge "
|
||||
"v2.13.0+ and Elementor active."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"post_id": {"type": "integer", "minimum": 1}},
|
||||
"required": ["post_id"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_elementor_template_list",
|
||||
"method_name": "wp_elementor_template_list",
|
||||
"description": (
|
||||
"List saved Elementor templates (the elementor_library "
|
||||
"CPT). Returns id, title, type (page/section/header/…), "
|
||||
"and modified_gmt. Returns ``installed: false`` cleanly "
|
||||
"if Elementor is not active. Requires Airano MCP Bridge "
|
||||
"v2.13.0+."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_elementor_template_apply",
|
||||
"method_name": "wp_elementor_template_apply",
|
||||
"description": (
|
||||
"Copy a saved Elementor template's data into a target "
|
||||
"post. Subject to the same S-14 5,000-node cap as "
|
||||
"wp_elementor_set. Useful when a payload exceeds the "
|
||||
"cap — clone a known-good template instead of streaming "
|
||||
"raw JSON. Requires Airano MCP Bridge v2.13.0+ and "
|
||||
"edit_post on the target."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Source elementor_library post id.",
|
||||
},
|
||||
"post_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Target post id (where the layout lands).",
|
||||
},
|
||||
},
|
||||
"required": ["template_id", "post_id"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
# ───── Classic editor ────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_classic_html_replace",
|
||||
"method_name": "wp_classic_html_replace",
|
||||
"description": (
|
||||
"Pure post_content swap for sites still on the Classic "
|
||||
"editor. Companion sanitises with wp_kses_post unless "
|
||||
"raw_html=true (S-13). Requires Airano MCP Bridge "
|
||||
"v2.13.0+ and edit_post on the target."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {"type": "integer", "minimum": 1},
|
||||
"html": {
|
||||
"type": "string",
|
||||
"description": "Replacement post_content body.",
|
||||
},
|
||||
"raw_html": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["post_id", "html"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _validate_post_id(post_id: Any) -> int:
|
||||
"""Reject obviously-bad ids before the wire."""
|
||||
if not isinstance(post_id, int) or isinstance(post_id, bool) or post_id <= 0:
|
||||
raise ValueError(f"post_id must be a positive integer, got {post_id!r}")
|
||||
return post_id
|
||||
|
||||
|
||||
def _count_elementor_nodes(tree: list[Any]) -> int:
|
||||
"""Recursively count Elementor nodes (mirrors the companion's walker)."""
|
||||
total = 0
|
||||
for node in tree:
|
||||
if isinstance(node, dict):
|
||||
total += 1
|
||||
children = node.get("elements")
|
||||
if isinstance(children, list):
|
||||
total += _count_elementor_nodes(children)
|
||||
return total
|
||||
|
||||
|
||||
class PagesHandler:
|
||||
"""Block + Elementor + Classic page-editing surface (F.19.5).
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion (or
|
||||
from stock REST in the two read-only cases). The plugin.py wrapper
|
||||
layer is responsible for serialising the dict for MCP transport.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ── Gutenberg ────────────────────────────────────────────────────
|
||||
|
||||
async def wp_blocks_get(
|
||||
self,
|
||||
post_id: int,
|
||||
post_type: str = "posts",
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Read post_content via stock REST, parse blocks server-side."""
|
||||
post_id = _validate_post_id(post_id)
|
||||
if post_type not in {"posts", "pages"}:
|
||||
raise ValueError(f"post_type must be 'posts' or 'pages', got {post_type!r}")
|
||||
# Stock REST returns post_content under "content.raw" when
|
||||
# context=edit and the user has edit_posts. The wordpress
|
||||
# client always authenticates with an Application Password so
|
||||
# we ask for the raw form.
|
||||
post = await self.client.get(
|
||||
f"{post_type}/{post_id}",
|
||||
params={"context": "edit"},
|
||||
)
|
||||
raw = ""
|
||||
if isinstance(post, dict):
|
||||
content = post.get("content")
|
||||
if isinstance(content, dict):
|
||||
raw = content.get("raw") or content.get("rendered") or ""
|
||||
# Lazy import — `parse_blocks` lives in MCPHub-side helpers so
|
||||
# we don't reach into a separate WordPress installation.
|
||||
blocks = _parse_blocks_python(raw)
|
||||
return {
|
||||
"post_id": post_id,
|
||||
"post_type": post_type,
|
||||
"count": len(blocks),
|
||||
"blocks": blocks,
|
||||
}
|
||||
|
||||
async def wp_blocks_replace(
|
||||
self,
|
||||
post_id: int,
|
||||
blocks: list[dict[str, Any]],
|
||||
raw_html: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
if not isinstance(blocks, list):
|
||||
raise ValueError("blocks must be a list of block dicts")
|
||||
if len(blocks) > _BLOCKS_MAX_PER_CALL:
|
||||
raise ValueError(f"blocks exceeds {_BLOCKS_MAX_PER_CALL} per call (got {len(blocks)})")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/blocks/replace",
|
||||
json_data={"post_id": post_id, "blocks": blocks, "raw_html": bool(raw_html)},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_blocks_insert_at(
|
||||
self,
|
||||
post_id: int,
|
||||
block: dict[str, Any],
|
||||
index: int | None = None,
|
||||
raw_html: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
if not isinstance(block, dict):
|
||||
raise ValueError("block must be a dict")
|
||||
body: dict[str, Any] = {
|
||||
"post_id": post_id,
|
||||
"block": block,
|
||||
"raw_html": bool(raw_html),
|
||||
}
|
||||
if index is not None:
|
||||
if not isinstance(index, int) or isinstance(index, bool) or index < 0:
|
||||
raise ValueError("index must be a non-negative integer")
|
||||
body["index"] = index
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/blocks/insert",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_blocks_remove_at(
|
||||
self,
|
||||
post_id: int,
|
||||
index: int,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
if not isinstance(index, int) or isinstance(index, bool) or index < 0:
|
||||
raise ValueError("index must be a non-negative integer")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/blocks/remove",
|
||||
json_data={"post_id": post_id, "index": index},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Elementor ────────────────────────────────────────────────────
|
||||
|
||||
async def wp_elementor_detect(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/elementor/status",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_elementor_get(self, post_id: int, **_: Any) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/elementor/{post_id}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_elementor_set(
|
||||
self,
|
||||
post_id: int,
|
||||
data: list[Any],
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("data must be a top-level Elementor sections array")
|
||||
node_count = _count_elementor_nodes(data)
|
||||
if node_count > _ELEMENTOR_MAX_NODES:
|
||||
raise ValueError(
|
||||
f"Elementor payload has {node_count} nodes — exceeds "
|
||||
f"{_ELEMENTOR_MAX_NODES} per call. Use "
|
||||
f"wp_elementor_template_apply with a saved template instead."
|
||||
)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/elementor/{post_id}",
|
||||
json_data={"data": data},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_elementor_render_css(self, post_id: int, **_: Any) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/elementor/{post_id}/regen-css",
|
||||
json_data={},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_elementor_template_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/elementor/templates",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_elementor_template_apply(
|
||||
self,
|
||||
template_id: int,
|
||||
post_id: int,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
if not isinstance(template_id, int) or isinstance(template_id, bool) or template_id <= 0:
|
||||
raise ValueError("template_id must be a positive integer")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/elementor/templates/apply",
|
||||
json_data={"template_id": template_id, "post_id": post_id},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Classic editor ───────────────────────────────────────────────
|
||||
|
||||
async def wp_classic_html_replace(
|
||||
self,
|
||||
post_id: int,
|
||||
html: str,
|
||||
raw_html: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
if not isinstance(html, str):
|
||||
raise ValueError("html must be a string")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/classic/{post_id}/replace",
|
||||
json_data={"html": html, "raw_html": bool(raw_html)},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Block grammar parser (Python port)
|
||||
#
|
||||
# WP's grammar is documented at
|
||||
# https://developer.wordpress.org/block-editor/reference-guides/data/data-core-blocks/
|
||||
# but every block read operation is just round-tripping HTML comments
|
||||
# of the form:
|
||||
# <!-- wp:blockname {"attr":"value"} -->
|
||||
# <p>inner html</p>
|
||||
# <!-- wp:innerName -->...<!-- /wp:innerName -->
|
||||
# <!-- /wp:blockname -->
|
||||
#
|
||||
# Matching the official PHP grammar exactly would require a state
|
||||
# machine; the cases F.19.5 cares about are simpler — we need to
|
||||
# extract the block tree shape (name + attrs + innerHTML + innerBlocks)
|
||||
# so a downstream caller can reason about it. The parser below is
|
||||
# intentionally narrow: it covers `parse_blocks()` output for content
|
||||
# produced by the block editor itself (the only realistic input for
|
||||
# read-back). For freeform / classic-editor content it falls back to a
|
||||
# single ``core/freeform`` block with the original HTML.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_blocks_python(html: str) -> list[dict[str, Any]]:
|
||||
import json
|
||||
import re
|
||||
|
||||
if not html or "<!-- wp:" not in html:
|
||||
if not html:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"blockName": None,
|
||||
"attrs": {},
|
||||
"innerBlocks": [],
|
||||
"innerHTML": html,
|
||||
"innerContent": [html],
|
||||
}
|
||||
]
|
||||
|
||||
open_re = re.compile(
|
||||
r"<!--\s*wp:([a-z0-9][a-z0-9_/-]*)\s*(\{.*?\})?\s*(/)?-->",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
close_re = re.compile(r"<!--\s*/wp:([a-z0-9][a-z0-9_/-]*)\s*-->", re.IGNORECASE)
|
||||
|
||||
pos = 0
|
||||
length = len(html)
|
||||
blocks: list[dict[str, Any]] = []
|
||||
stack: list[dict[str, Any]] = []
|
||||
|
||||
def _attach(block: dict[str, Any]) -> None:
|
||||
if stack:
|
||||
stack[-1]["innerBlocks"].append(block)
|
||||
else:
|
||||
blocks.append(block)
|
||||
|
||||
while pos < length:
|
||||
m_open = open_re.search(html, pos)
|
||||
m_close = close_re.search(html, pos)
|
||||
|
||||
# Pick the earliest match.
|
||||
next_open = m_open.start() if m_open else length
|
||||
next_close = m_close.start() if m_close else length
|
||||
|
||||
if next_open == length and next_close == length:
|
||||
# No more delimiters — flush the rest as freeform on the
|
||||
# outer level (or innerHTML of the open block).
|
||||
tail = html[pos:length]
|
||||
if tail.strip():
|
||||
if stack:
|
||||
stack[-1]["innerHTML"] += tail
|
||||
stack[-1]["innerContent"].append(tail)
|
||||
else:
|
||||
blocks.append(
|
||||
{
|
||||
"blockName": None,
|
||||
"attrs": {},
|
||||
"innerBlocks": [],
|
||||
"innerHTML": tail,
|
||||
"innerContent": [tail],
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
if next_open <= next_close and m_open is not None:
|
||||
# Free text before this open tag → inherit by current parent.
|
||||
head = html[pos:next_open]
|
||||
if head:
|
||||
if stack:
|
||||
stack[-1]["innerHTML"] += head
|
||||
stack[-1]["innerContent"].append(head)
|
||||
else:
|
||||
if head.strip():
|
||||
blocks.append(
|
||||
{
|
||||
"blockName": None,
|
||||
"attrs": {},
|
||||
"innerBlocks": [],
|
||||
"innerHTML": head,
|
||||
"innerContent": [head],
|
||||
}
|
||||
)
|
||||
|
||||
name = m_open.group(1)
|
||||
attrs_raw = m_open.group(2)
|
||||
self_closing = m_open.group(3) is not None
|
||||
attrs: dict[str, Any] = {}
|
||||
if attrs_raw:
|
||||
try:
|
||||
attrs = json.loads(attrs_raw)
|
||||
except json.JSONDecodeError:
|
||||
attrs = {"_invalid_json": attrs_raw}
|
||||
block_name = name if "/" in name else f"core/{name}"
|
||||
block = {
|
||||
"blockName": block_name,
|
||||
"attrs": attrs,
|
||||
"innerBlocks": [],
|
||||
"innerHTML": "",
|
||||
"innerContent": [],
|
||||
}
|
||||
pos = m_open.end()
|
||||
if self_closing:
|
||||
_attach(block)
|
||||
else:
|
||||
stack.append(block)
|
||||
elif m_close is not None:
|
||||
head = html[pos:next_close]
|
||||
if head and stack:
|
||||
stack[-1]["innerHTML"] += head
|
||||
stack[-1]["innerContent"].append(head)
|
||||
if stack:
|
||||
closing = stack.pop()
|
||||
_attach(closing)
|
||||
pos = m_close.end()
|
||||
else: # pragma: no cover — guarded by the length check above
|
||||
break
|
||||
|
||||
# Anything left on the stack is a mismatched open — surface it
|
||||
# rather than silently dropping content.
|
||||
while stack:
|
||||
unclosed = stack.pop()
|
||||
_attach(unclosed)
|
||||
|
||||
return blocks
|
||||
407
plugins/wordpress_specialist/handlers/plugins.py
Normal file
407
plugins/wordpress_specialist/handlers/plugins.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""F.19.2.1 — Plugin write management (install + activate + delete).
|
||||
|
||||
Six tools split across two tiers — the first tools on `wordpress_specialist`
|
||||
that exercise the `install` and `admin` tiers introduced by F.19.2.0:
|
||||
|
||||
* **install tier** (3 tools) — wp.org slug install, activate / deactivate,
|
||||
update. These hit `Plugin_Upgrader` / `activate_plugin` /
|
||||
`deactivate_plugins` against an already-vetted package source (wp.org
|
||||
curated).
|
||||
* **admin tier** (3 tools) — URL/zip install, plugin delete. These see
|
||||
more attack surface (arbitrary zip contents) or have no undo (delete
|
||||
drops plugin data).
|
||||
|
||||
Surface map:
|
||||
|
||||
* `wp_plugin_install_from_slug(slug, activate?)` — install tier
|
||||
* `wp_plugin_install_from_zip(zip_url | zip_base64, activate?, overwrite?)` — admin tier
|
||||
* `wp_plugin_activate(slug, network_wide?)` — install tier
|
||||
* `wp_plugin_deactivate(slug, network_wide?)` — install tier
|
||||
* `wp_plugin_update(slug)` — install tier
|
||||
* `wp_plugin_delete(slug)` — admin tier
|
||||
|
||||
Security rules layered on top of F.19.2 S-1…S-11 + F.19.5 S-12…S-14
|
||||
+ F.19.7 S-15…S-19 (companion enforces these regardless of MCPHub-side
|
||||
guards):
|
||||
|
||||
* **S-15 (reused)** — `slug` must match a key in `get_plugins()` on the
|
||||
WP side for activate / deactivate / update / delete. install routes
|
||||
validate the slug shape on the wire and let `Plugin_Upgrader` handle
|
||||
fetch / extraction / verification.
|
||||
* **S-18 (reused)** — 50 MB cap on install zip payloads.
|
||||
* **S-20** *(new)* — refuses to delete the Airano MCP Bridge companion
|
||||
itself (`airano-mcp-bridge`). Removing the companion via its own
|
||||
route would brick the MCP connection; operators must use the WP-Admin
|
||||
Plugins page instead.
|
||||
* **S-21** *(new)* — refuses to deactivate / delete an active plugin
|
||||
marked as ``Required: yes`` in its header (rare; some hosts ship
|
||||
must-use plugins this way).
|
||||
|
||||
All tools require Airano MCP Bridge v2.15.0+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by F.19.1 / F.19.5 / F.19.7.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# Mirror of the companion's PLUGIN_ZIP_MAX_BYTES (S-18). The companion
|
||||
# enforces the binding limit; this is a cheap pre-check to avoid uploading
|
||||
# a 200 MB payload that will be rejected at the wire anyway.
|
||||
_PLUGIN_ZIP_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
# Plugin slugs from wp.org are conventionally lowercase letters / digits /
|
||||
# dashes. Some legacy plugins use underscores; the regex permits both.
|
||||
# Length cap mirrors WP's own slug sanitiser (sanitize_key + 64 chars).
|
||||
_PLUGIN_SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.2.1 plugin write surface."""
|
||||
return [
|
||||
# ───── Install tier (3) ──────────────────────────────────────
|
||||
{
|
||||
"name": "wp_plugin_install_from_slug",
|
||||
"method_name": "wp_plugin_install_from_slug",
|
||||
"description": (
|
||||
"Install a plugin from a wp.org slug (e.g. 'akismet'). "
|
||||
"Companion calls plugins_api() to resolve the package URL, "
|
||||
"downloads via download_url() (so the WP filesystem "
|
||||
"abstraction stays engaged), then runs WP core's "
|
||||
"Plugin_Upgrader. Set activate=true to activate "
|
||||
"immediately on success — activation requires "
|
||||
"activate_plugins; companion rejects with rest_forbidden "
|
||||
"if missing. Requires Airano MCP Bridge v2.15.0+ and a "
|
||||
"WordPress user with install_plugins."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"wp.org plugin slug (the 'foo' in "
|
||||
"https://wordpress.org/plugins/foo/). "
|
||||
"Alphanumerics, dashes, underscores only. "
|
||||
"Accepts the ``folder/file.php`` form returned "
|
||||
"by the capabilities probe — everything after "
|
||||
"the first slash is stripped."
|
||||
),
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": ("Activate the plugin immediately after install."),
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "install",
|
||||
},
|
||||
{
|
||||
"name": "wp_plugin_activate",
|
||||
"method_name": "wp_plugin_activate",
|
||||
"description": (
|
||||
"Activate an installed plugin by slug. Companion resolves "
|
||||
"the slug to the plugin file via get_plugins() (S-15) and "
|
||||
"calls activate_plugin(). When network_wide=true on a "
|
||||
"multisite install the plugin is activated network-wide "
|
||||
"(requires manage_network_plugins). Requires Airano MCP "
|
||||
"Bridge v2.15.0+ and activate_plugins."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Plugin slug (from wp_plugin_list). Accepts "
|
||||
"either the folder-only form (`woocommerce`) "
|
||||
"or the `folder/file.php` form returned by the "
|
||||
"capabilities probe — both are normalised "
|
||||
"client-side."
|
||||
),
|
||||
},
|
||||
"network_wide": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Activate network-wide on multisite. Ignored "
|
||||
"on single-site installs."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "install",
|
||||
},
|
||||
{
|
||||
"name": "wp_plugin_deactivate",
|
||||
"method_name": "wp_plugin_deactivate",
|
||||
"description": (
|
||||
"Deactivate an installed plugin by slug. Refuses to "
|
||||
"deactivate the Airano MCP Bridge companion itself — "
|
||||
"doing so would brick the MCP connection (S-20). "
|
||||
"Requires Airano MCP Bridge v2.15.0+ and activate_plugins."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Plugin slug. Accepts folder-only "
|
||||
"(`woocommerce`) or `folder/file.php` form."
|
||||
),
|
||||
},
|
||||
"network_wide": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": ("Deactivate network-wide on multisite."),
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "install",
|
||||
},
|
||||
{
|
||||
"name": "wp_plugin_update",
|
||||
"method_name": "wp_plugin_update",
|
||||
"description": (
|
||||
"Update an installed plugin to the latest wp.org version. "
|
||||
"Companion checks the cached update_plugins transient + "
|
||||
"runs Plugin_Upgrader::upgrade(). Returns no-op (with "
|
||||
"``up_to_date: true``) when no update is available rather "
|
||||
"than erroring. Requires Airano MCP Bridge v2.15.0+ and "
|
||||
"update_plugins."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Plugin slug. Accepts folder-only "
|
||||
"(`woocommerce`) or `folder/file.php` form."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "install",
|
||||
},
|
||||
# ───── Admin tier (3) ────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_plugin_install_from_zip",
|
||||
"method_name": "wp_plugin_install_from_zip",
|
||||
"description": (
|
||||
"Install a plugin from a remote URL or inline base64 zip. "
|
||||
"Sees more attack surface than slug install (arbitrary "
|
||||
"zip contents) so this lands on the admin tier. Companion "
|
||||
"still runs Plugin_Upgrader so signature checks stay "
|
||||
"engaged. Pass exactly one of zip_url (companion fetches "
|
||||
"via wp_safe_remote_get) or zip_base64 (decoded "
|
||||
"server-side). Capped at 50 MB per zip (S-18). Requires "
|
||||
"Airano MCP Bridge v2.15.0+ and install_plugins."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"zip_url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"https URL the companion will download. "
|
||||
"Mutually exclusive with zip_base64."
|
||||
),
|
||||
},
|
||||
"zip_base64": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Base64-encoded plugin zip body. Capped at " "~50 MB after decode."
|
||||
),
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Activate after install.",
|
||||
},
|
||||
"overwrite": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Permit overwriting an existing plugin with " "the same slug."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "wp_plugin_delete",
|
||||
"method_name": "wp_plugin_delete",
|
||||
"description": (
|
||||
"Delete an installed plugin by slug. Refuses to delete "
|
||||
"the Airano MCP Bridge companion itself (S-20) and any "
|
||||
"currently-active plugin (caller must deactivate first). "
|
||||
"Lands on the admin tier because plugin delete drops "
|
||||
"the plugin's database tables / options on uninstall — "
|
||||
"no undo. Requires Airano MCP Bridge v2.15.0+ and "
|
||||
"delete_plugins."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Plugin slug. Accepts folder-only "
|
||||
"(`woocommerce`) or `folder/file.php` form."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_plugin_slug(slug: Any) -> str:
|
||||
"""Reject obviously-malformed plugin slugs before the wire.
|
||||
|
||||
Accepts both the folder-only form (``woocommerce``) and the
|
||||
``folder/file.php`` form returned by the WP capabilities probe —
|
||||
everything from the first slash onward is stripped, mirroring the
|
||||
companion's own normalisation. The companion still does the binding
|
||||
``get_plugins()`` membership check (S-15) for activate / deactivate /
|
||||
update / delete — this is just structural defence-in-depth.
|
||||
"""
|
||||
if not isinstance(slug, str):
|
||||
raise ValueError("slug must be a string")
|
||||
stripped = slug.strip()
|
||||
if not stripped:
|
||||
raise ValueError("slug must be a non-empty string")
|
||||
folder = stripped.split("/", 1)[0]
|
||||
if not _PLUGIN_SLUG_RE.match(folder):
|
||||
raise ValueError(
|
||||
f"slug must be alphanumerics + dashes + underscores "
|
||||
f"(<=64 chars, no leading dash); got {slug!r}"
|
||||
)
|
||||
return folder
|
||||
|
||||
|
||||
class PluginsHandler:
|
||||
"""Plugin install / activate / deactivate / update / delete surface
|
||||
(F.19.2.1).
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion. The
|
||||
plugin.py wrapper layer is responsible for serialising the dict for
|
||||
MCP transport.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ── Install tier ────────────────────────────────────────────────
|
||||
|
||||
async def wp_plugin_install_from_slug(
|
||||
self,
|
||||
slug: str,
|
||||
activate: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
slug = _validate_plugin_slug(slug)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/plugins/install",
|
||||
json_data={"slug": slug, "activate": bool(activate)},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_plugin_activate(
|
||||
self,
|
||||
slug: str,
|
||||
network_wide: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
slug = _validate_plugin_slug(slug)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/plugins/{slug}/activate",
|
||||
json_data={"network_wide": bool(network_wide)},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_plugin_deactivate(
|
||||
self,
|
||||
slug: str,
|
||||
network_wide: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
slug = _validate_plugin_slug(slug)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/plugins/{slug}/deactivate",
|
||||
json_data={"network_wide": bool(network_wide)},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_plugin_update(self, slug: str, **_: Any) -> dict[str, Any]:
|
||||
slug = _validate_plugin_slug(slug)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/plugins/{slug}/update",
|
||||
json_data={},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Admin tier ──────────────────────────────────────────────────
|
||||
|
||||
async def wp_plugin_install_from_zip(
|
||||
self,
|
||||
zip_url: str | None = None,
|
||||
zip_base64: str | None = None,
|
||||
activate: bool = False,
|
||||
overwrite: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not zip_url and not zip_base64:
|
||||
raise ValueError("wp_plugin_install_from_zip requires zip_url or zip_base64")
|
||||
if zip_url and zip_base64:
|
||||
raise ValueError("wp_plugin_install_from_zip accepts zip_url OR zip_base64, not both")
|
||||
body: dict[str, Any] = {
|
||||
"activate": bool(activate),
|
||||
"overwrite": bool(overwrite),
|
||||
}
|
||||
if zip_url:
|
||||
if not isinstance(zip_url, str):
|
||||
raise ValueError("zip_url must be a string")
|
||||
body["zip_url"] = zip_url
|
||||
else:
|
||||
if not isinstance(zip_base64, str):
|
||||
raise ValueError("zip_base64 must be a string")
|
||||
decoded_size_upper_bound = len(zip_base64) * 3 // 4
|
||||
if decoded_size_upper_bound > _PLUGIN_ZIP_MAX_BYTES:
|
||||
raise ValueError(
|
||||
f"zip_base64 decodes to roughly {decoded_size_upper_bound} bytes "
|
||||
f"— exceeds {_PLUGIN_ZIP_MAX_BYTES} byte cap (S-18)"
|
||||
)
|
||||
body["zip_base64"] = zip_base64
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/plugins/install",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_plugin_delete(self, slug: str, **_: Any) -> dict[str, Any]:
|
||||
slug = _validate_plugin_slug(slug)
|
||||
return await self.client.delete(
|
||||
f"{_ADMIN_NS}/plugins/{slug}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
423
plugins/wordpress_specialist/handlers/site_config.py
Normal file
423
plugins/wordpress_specialist/handlers/site_config.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""F.19.6.A — Site config surface (identity + reading + permalinks).
|
||||
|
||||
First consumer of the ``settings`` tier introduced by F.19.2.0.
|
||||
Six tools split across three small surfaces — every one of them is
|
||||
reachable from the WP-Admin Settings menu, no companion magic, just
|
||||
a typed REST face for what an editor would otherwise click through.
|
||||
|
||||
Surface map (all on the ``settings`` tier):
|
||||
|
||||
* **Site identity** (2 tools, ``/admin/site/identity``):
|
||||
``wp_site_identity_get`` reads title / tagline / site_icon /
|
||||
custom_logo / blog_charset / WP version. ``wp_site_identity_set``
|
||||
writes title / tagline / site_icon_id / custom_logo_id.
|
||||
* **Reading** (2 tools, ``/admin/site/reading``):
|
||||
``wp_reading_settings_get`` / ``wp_reading_settings_set`` cover
|
||||
show_on_front (posts vs page), page_on_front, page_for_posts,
|
||||
posts_per_page, blog_public (search-engine visibility). The
|
||||
``blog_public=false`` write surfaces a hint reminding the caller
|
||||
the change asks search engines not to index but is non-binding.
|
||||
* **Permalinks** (2 tools, ``/admin/permalinks``):
|
||||
``wp_permalinks_get`` / ``wp_permalinks_set``. After a write the
|
||||
companion calls ``flush_rewrite_rules()`` so the new structure
|
||||
takes effect immediately — same as the manual save on
|
||||
Settings → Permalinks.
|
||||
|
||||
Security rules: route-level ``manage_options`` only. No new S-rules
|
||||
this round — every operation maps to a stock WP option write that
|
||||
WP-Admin would do in a single click. WP's own ``sanitize_option_*``
|
||||
hooks fire on each ``update_option`` and provide the safe-input gate.
|
||||
|
||||
All tools require Airano MCP Bridge v2.16.0+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by F.19.1 / F.19.5 /
|
||||
# F.19.7 / F.19.2.1.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# Permalink structure tokens WP recognises. Anything else is rejected
|
||||
# client-side as a cheap pre-check (the companion is the binding gate
|
||||
# — it round-trips through WP's own ``permalink_structure`` sanitiser).
|
||||
_PERMALINK_TOKEN_RE = re.compile(r"^(/|%[a-z_]+%|[A-Za-z0-9_\-])+$")
|
||||
_PERMALINK_MAX_LEN = 256
|
||||
|
||||
# Reading-settings ``show_on_front`` accepts only these two strings —
|
||||
# matches WP's own constant enum.
|
||||
_SHOW_ON_FRONT_VALUES = {"posts", "page"}
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.6.A site config surface."""
|
||||
return [
|
||||
# ───── Site identity (2) ─────────────────────────────────────
|
||||
{
|
||||
"name": "wp_site_identity_get",
|
||||
"method_name": "wp_site_identity_get",
|
||||
"description": (
|
||||
"Read site identity: title (blogname), tagline "
|
||||
"(blogdescription), site_icon (favicon attachment id), "
|
||||
"custom_logo (theme logo attachment id), blog_charset, "
|
||||
"WP version, and admin email. Companion-backed read so "
|
||||
"responses stay consistent with the writer route. "
|
||||
"Requires Airano MCP Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_site_identity_set",
|
||||
"method_name": "wp_site_identity_set",
|
||||
"description": (
|
||||
"Update site identity. Pass any subset of title / "
|
||||
"tagline / site_icon_id / custom_logo_id (omitted keys "
|
||||
"are left untouched). Attachment ids are validated by "
|
||||
"the companion against WP's media library — invalid "
|
||||
"ids return ``invalid_attachment``. Requires Airano "
|
||||
"MCP Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Site title (option ``blogname``).",
|
||||
"maxLength": 255,
|
||||
},
|
||||
"tagline": {
|
||||
"type": "string",
|
||||
"description": "Site tagline (option ``blogdescription``).",
|
||||
"maxLength": 1024,
|
||||
},
|
||||
"site_icon_id": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Attachment id for the site icon (favicon). "
|
||||
"Pass 0 to clear; pass an integer >= 1 to set."
|
||||
),
|
||||
},
|
||||
"custom_logo_id": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Attachment id for the theme custom logo. "
|
||||
"Pass 0 to clear; the active theme must "
|
||||
"declare ``custom-logo`` support."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
# ───── Reading settings (2) ──────────────────────────────────
|
||||
{
|
||||
"name": "wp_reading_settings_get",
|
||||
"method_name": "wp_reading_settings_get",
|
||||
"description": (
|
||||
"Read the Settings → Reading panel: show_on_front "
|
||||
"(``posts`` or ``page``), page_on_front, "
|
||||
"page_for_posts, posts_per_page, posts_per_rss, and "
|
||||
"blog_public (search-engine visibility flag — 0 means "
|
||||
"WP asks crawlers not to index). Requires Airano MCP "
|
||||
"Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_reading_settings_set",
|
||||
"method_name": "wp_reading_settings_set",
|
||||
"description": (
|
||||
"Update Settings → Reading values. Pass any subset of "
|
||||
"show_on_front / page_on_front / page_for_posts / "
|
||||
"posts_per_page / posts_per_rss / blog_public. When "
|
||||
"show_on_front=='page', page_on_front must be set to a "
|
||||
"published Page id (companion validates). Setting "
|
||||
"blog_public=false tells crawlers not to index — the "
|
||||
"directive is non-binding, real privacy still belongs "
|
||||
"to httpd auth or membership plugins. Requires Airano "
|
||||
"MCP Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"show_on_front": {
|
||||
"type": "string",
|
||||
"enum": ["posts", "page"],
|
||||
"description": (
|
||||
"``posts`` shows the latest blog posts on "
|
||||
"the home URL; ``page`` uses two static "
|
||||
"pages (front + posts archive)."
|
||||
),
|
||||
},
|
||||
"page_on_front": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Page id used as the static front page "
|
||||
"(only consulted when show_on_front=page)."
|
||||
),
|
||||
},
|
||||
"page_for_posts": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Page id used as the blog posts archive "
|
||||
"(only consulted when show_on_front=page)."
|
||||
),
|
||||
},
|
||||
"posts_per_page": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": "Posts shown per page on archives + the home feed.",
|
||||
},
|
||||
"posts_per_rss": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": "Items emitted in RSS / Atom feeds.",
|
||||
},
|
||||
"blog_public": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"true = invite search engines to index; "
|
||||
"false = ask crawlers not to. Non-binding."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
# ───── Permalinks (2) ────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_permalinks_get",
|
||||
"method_name": "wp_permalinks_get",
|
||||
"description": (
|
||||
"Read the current permalink structure (option "
|
||||
"``permalink_structure``) plus the category_base + "
|
||||
"tag_base prefixes. Empty string in ``structure`` "
|
||||
'means "plain" permalinks (?p=N). Requires Airano '
|
||||
"MCP Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_permalinks_set",
|
||||
"method_name": "wp_permalinks_set",
|
||||
"description": (
|
||||
"Update the permalink structure. Common safe values: "
|
||||
"``/%postname%/``, ``/%year%/%monthnum%/%postname%/``, "
|
||||
"``/%category%/%postname%/``. Pass an empty string for "
|
||||
"plain permalinks. The companion writes the option "
|
||||
"then calls ``flush_rewrite_rules()`` so the new "
|
||||
"structure takes effect immediately. Optional "
|
||||
"category_base / tag_base override the default "
|
||||
"``category`` / ``tag`` prefixes. Requires Airano MCP "
|
||||
"Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"structure": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Permalink template using WP tokens "
|
||||
"(``%postname%``, ``%post_id%``, ``%year%``, "
|
||||
"``%monthnum%``, ``%day%``, ``%hour%``, "
|
||||
"``%minute%``, ``%second%``, ``%category%``, "
|
||||
"``%author%``). Empty string = plain."
|
||||
),
|
||||
"maxLength": _PERMALINK_MAX_LEN,
|
||||
},
|
||||
"category_base": {
|
||||
"type": "string",
|
||||
"description": "Category archive prefix (default ``category``).",
|
||||
"maxLength": 64,
|
||||
},
|
||||
"tag_base": {
|
||||
"type": "string",
|
||||
"description": "Tag archive prefix (default ``tag``).",
|
||||
"maxLength": 64,
|
||||
},
|
||||
},
|
||||
"required": ["structure"],
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_permalink_structure(structure: Any) -> str:
|
||||
"""Cheap structural pre-check for permalink_structure.
|
||||
|
||||
Empty string ("plain" permalinks) is valid. Otherwise we accept
|
||||
only `/`, `%token%`, alphanumerics, `_`, and `-`. The companion's
|
||||
``sanitize_option_permalink_structure`` is the binding sanitiser.
|
||||
"""
|
||||
if not isinstance(structure, str):
|
||||
raise ValueError("structure must be a string")
|
||||
if structure == "":
|
||||
return ""
|
||||
if len(structure) > _PERMALINK_MAX_LEN:
|
||||
raise ValueError(f"structure exceeds {_PERMALINK_MAX_LEN} char cap")
|
||||
if "\x00" in structure:
|
||||
raise ValueError("structure must not contain null bytes")
|
||||
if not _PERMALINK_TOKEN_RE.match(structure):
|
||||
raise ValueError(
|
||||
"structure may only contain `/`, `%token%`, alnum, `_`, `-` "
|
||||
"(got unexpected character)"
|
||||
)
|
||||
return structure
|
||||
|
||||
|
||||
def _validate_attachment_id(value: Any, field: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ValueError(f"{field} must be a non-negative integer (got {value!r})")
|
||||
return value
|
||||
|
||||
|
||||
class SiteConfigHandler:
|
||||
"""Site identity + reading + permalinks surface (F.19.6.A).
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion.
|
||||
The plugin.py wrapper is responsible for serialising for MCP
|
||||
transport.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ── Identity ────────────────────────────────────────────────────
|
||||
|
||||
async def wp_site_identity_get(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/site/identity",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_site_identity_set(
|
||||
self,
|
||||
title: str | None = None,
|
||||
tagline: str | None = None,
|
||||
site_icon_id: int | None = None,
|
||||
custom_logo_id: int | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
if not isinstance(title, str):
|
||||
raise ValueError("title must be a string")
|
||||
body["title"] = title
|
||||
if tagline is not None:
|
||||
if not isinstance(tagline, str):
|
||||
raise ValueError("tagline must be a string")
|
||||
body["tagline"] = tagline
|
||||
if site_icon_id is not None:
|
||||
body["site_icon_id"] = _validate_attachment_id(site_icon_id, "site_icon_id")
|
||||
if custom_logo_id is not None:
|
||||
body["custom_logo_id"] = _validate_attachment_id(custom_logo_id, "custom_logo_id")
|
||||
if not body:
|
||||
raise ValueError("wp_site_identity_set requires at least one field to update")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/site/identity",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Reading ─────────────────────────────────────────────────────
|
||||
|
||||
async def wp_reading_settings_get(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/site/reading",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_reading_settings_set(
|
||||
self,
|
||||
show_on_front: str | None = None,
|
||||
page_on_front: int | None = None,
|
||||
page_for_posts: int | None = None,
|
||||
posts_per_page: int | None = None,
|
||||
posts_per_rss: int | None = None,
|
||||
blog_public: bool | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {}
|
||||
if show_on_front is not None:
|
||||
if show_on_front not in _SHOW_ON_FRONT_VALUES:
|
||||
raise ValueError(
|
||||
f"show_on_front must be one of {sorted(_SHOW_ON_FRONT_VALUES)} "
|
||||
f"(got {show_on_front!r})"
|
||||
)
|
||||
body["show_on_front"] = show_on_front
|
||||
if page_on_front is not None:
|
||||
body["page_on_front"] = _validate_attachment_id(page_on_front, "page_on_front")
|
||||
if page_for_posts is not None:
|
||||
body["page_for_posts"] = _validate_attachment_id(page_for_posts, "page_for_posts")
|
||||
if posts_per_page is not None:
|
||||
if not isinstance(posts_per_page, int) or isinstance(posts_per_page, bool):
|
||||
raise ValueError("posts_per_page must be an integer")
|
||||
if posts_per_page < 1 or posts_per_page > 100:
|
||||
raise ValueError("posts_per_page must be between 1 and 100")
|
||||
body["posts_per_page"] = posts_per_page
|
||||
if posts_per_rss is not None:
|
||||
if not isinstance(posts_per_rss, int) or isinstance(posts_per_rss, bool):
|
||||
raise ValueError("posts_per_rss must be an integer")
|
||||
if posts_per_rss < 1 or posts_per_rss > 100:
|
||||
raise ValueError("posts_per_rss must be between 1 and 100")
|
||||
body["posts_per_rss"] = posts_per_rss
|
||||
if blog_public is not None:
|
||||
if not isinstance(blog_public, bool):
|
||||
raise ValueError("blog_public must be a boolean")
|
||||
body["blog_public"] = blog_public
|
||||
if not body:
|
||||
raise ValueError("wp_reading_settings_set requires at least one field to update")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/site/reading",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Permalinks ──────────────────────────────────────────────────
|
||||
|
||||
async def wp_permalinks_get(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/permalinks",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_permalinks_set(
|
||||
self,
|
||||
structure: str,
|
||||
category_base: str | None = None,
|
||||
tag_base: str | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {"structure": _validate_permalink_structure(structure)}
|
||||
if category_base is not None:
|
||||
if not isinstance(category_base, str) or len(category_base) > 64:
|
||||
raise ValueError("category_base must be a string up to 64 chars")
|
||||
body["category_base"] = category_base
|
||||
if tag_base is not None:
|
||||
if not isinstance(tag_base, str) or len(tag_base) > 64:
|
||||
raise ValueError("tag_base must be a string up to 64 chars")
|
||||
body["tag_base"] = tag_base
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/permalinks",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
480
plugins/wordpress_specialist/handlers/site_layout.py
Normal file
480
plugins/wordpress_specialist/handlers/site_layout.py
Normal file
@@ -0,0 +1,480 @@
|
||||
"""F.19.6.B — Site layout surface (menus + widgets + customizer).
|
||||
|
||||
Closes the Settings → Menus, Appearance → Widgets, and Customizer gaps
|
||||
on ``wordpress_specialist``. Sits on the same ``settings`` tier as the
|
||||
F.19.6.A site config surface — same risk class, same dashboard preset,
|
||||
no new tier this round.
|
||||
|
||||
Surface map:
|
||||
|
||||
* **Menus** (3 tools, ``/admin/menus``):
|
||||
``wp_menu_list`` enumerates every nav menu with its theme-location
|
||||
bindings + item count. ``wp_menu_get`` reads one menu's items.
|
||||
``wp_menu_set`` does a full-replace write — items not in the array
|
||||
are deleted, new items are created, existing items (matched by id)
|
||||
are updated. Slug stays frozen so ``theme_location`` mapping survives.
|
||||
|
||||
* **Widgets** (3 tools, ``/admin/widgets/*``):
|
||||
``wp_widget_areas_list`` enumerates registered sidebar areas with
|
||||
their kind (``block`` or ``legacy``). ``wp_widget_get`` reads one
|
||||
area; block-kind areas return parsed block trees + raw HTML for
|
||||
roundtrip, legacy-kind areas return option-keyed settings.
|
||||
``wp_widget_set`` does a full-replace write — block areas accept any
|
||||
block raw HTML; legacy areas accept ``text`` widget settings only
|
||||
this round (other legacy types are read-only).
|
||||
|
||||
* **Customizer** (1 tool, ``/admin/customizer/changeset``):
|
||||
``wp_customizer_changeset`` wraps the customizer changeset queue with
|
||||
a single action enum (``get`` / ``apply`` / ``discard``). Lower
|
||||
priority — most modern themes use the FSE site editor instead of the
|
||||
customizer.
|
||||
|
||||
Security rules (extending S-1…S-21):
|
||||
|
||||
* **S-22** — Nav-menu items reference posts/terms via ``object_id``.
|
||||
Companion dispatches by item ``type``: ``post_type`` checks
|
||||
``read_post`` meta cap; ``taxonomy`` allows public taxonomies and
|
||||
otherwise requires the taxonomy's ``assign_terms`` cap (deliberately
|
||||
NOT ``manage_categories`` — that's a write cap and would refuse
|
||||
routine "add public Category X to footer" flows for editors who
|
||||
don't manage taxonomies); ``custom`` URL items skip the object check.
|
||||
Refusals surface as ``forbidden_object_id`` 403/404.
|
||||
* **S-23** — Widget HTML (block ``content`` and legacy ``text.text``)
|
||||
is sanitised via ``wp_kses_post`` unless the caller has
|
||||
``unfiltered_html``. Mirrors S-13 from F.19.5.
|
||||
* **S-24** — Customizer ``apply`` requires the caller to also hold
|
||||
``customize`` (the cap WP uses for ``/wp-admin/customize.php``).
|
||||
``manage_options`` alone is not enough — same as the WP UI. ``get``
|
||||
and ``discard`` only need ``manage_options``.
|
||||
|
||||
All tools require Airano MCP Bridge v2.17.0+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by every F.19.* surface.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# Nav-menu item types WP recognises. Anything else is rejected client-side
|
||||
# as a cheap pre-check — the companion's S-22 dispatcher is the binding gate.
|
||||
_MENU_ITEM_TYPES = {"post_type", "taxonomy", "custom"}
|
||||
|
||||
# Customizer changeset actions.
|
||||
_CUSTOMIZER_ACTIONS = {"get", "apply", "discard"}
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.6.B site layout surface."""
|
||||
return [
|
||||
# ───── Menus (3) ─────────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_menu_list",
|
||||
"method_name": "wp_menu_list",
|
||||
"description": (
|
||||
"List every WordPress nav-menu with id / name / slug, "
|
||||
"the theme locations bound to it, and the item count. "
|
||||
"Use to discover menu_id before calling wp_menu_get / "
|
||||
"wp_menu_set. Requires Airano MCP Bridge v2.17.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_menu_get",
|
||||
"method_name": "wp_menu_get",
|
||||
"description": (
|
||||
"Read a single nav-menu's items. Returns "
|
||||
"``{id, name, slug, items: [{id, title, type, object, "
|
||||
"object_id, parent, order, url, target, classes, xfn}]}``. "
|
||||
"``type`` is one of ``post_type`` / ``taxonomy`` / "
|
||||
"``custom``. Requires Airano MCP Bridge v2.17.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"menu_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Menu term id (from wp_menu_list).",
|
||||
},
|
||||
},
|
||||
"required": ["menu_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_menu_set",
|
||||
"method_name": "wp_menu_set",
|
||||
"description": (
|
||||
"Full-replace a nav-menu's items. Pass the complete "
|
||||
"items array — items not in the array are deleted, new "
|
||||
"items are created (omit ``id`` or set it to 0), "
|
||||
"existing items (matched by ``id``) are updated. Slug "
|
||||
"stays frozen so the theme_location mapping survives. "
|
||||
"Optional ``name`` renames the menu. S-22: each item's "
|
||||
"``object_id`` is validated against the caller's read "
|
||||
"permissions — ``post_type`` items require ``read_post`` "
|
||||
"on the target; non-public ``taxonomy`` items require "
|
||||
"the taxonomy's ``assign_terms`` cap; ``custom`` URL "
|
||||
"items skip the object check. Validation runs across "
|
||||
"every item before any mutation, so a refusal mid-array "
|
||||
"leaves the menu untouched. Requires Airano MCP Bridge "
|
||||
"v2.17.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"menu_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Menu term id to overwrite.",
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Full menu items array.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"Existing item id to update; "
|
||||
"omit or 0 to create a new item."
|
||||
),
|
||||
},
|
||||
"title": {"type": "string"},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["post_type", "taxonomy", "custom"],
|
||||
},
|
||||
"object": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"post_type slug for ``post_type`` "
|
||||
"items, taxonomy slug for "
|
||||
"``taxonomy`` items; ignored for "
|
||||
"``custom``."
|
||||
),
|
||||
},
|
||||
"object_id": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Referenced post / term id; " "omit for ``custom`` items."
|
||||
),
|
||||
},
|
||||
"parent": {"type": "integer", "minimum": 0},
|
||||
"order": {"type": "integer", "minimum": 0},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"URL for ``custom`` items; "
|
||||
"ignored for post_type / taxonomy."
|
||||
),
|
||||
},
|
||||
"target": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional rename. Slug stays frozen.",
|
||||
},
|
||||
},
|
||||
"required": ["menu_id", "items"],
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
# ───── Widgets (3) ───────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_widget_areas_list",
|
||||
"method_name": "wp_widget_areas_list",
|
||||
"description": (
|
||||
"List every registered sidebar / widget area with id, "
|
||||
"name, theme_location, widget_count, and kind "
|
||||
"(``block`` or ``legacy``). Block areas store widgets "
|
||||
"as block instances; legacy areas use per-widget option "
|
||||
"keys. Requires Airano MCP Bridge v2.17.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_widget_get",
|
||||
"method_name": "wp_widget_get",
|
||||
"description": (
|
||||
"Read one widget area's contents. Returns "
|
||||
"``{area_id, kind, widgets: [...]}``. For ``block`` "
|
||||
"kind, each widget is "
|
||||
"``{id, type:'block', blocks:[...parsed], raw:'<!-- wp:... -->'}`` "
|
||||
"— ``raw`` is the round-trippable HTML; ``blocks`` is "
|
||||
"the parsed tree for inspection. For ``legacy`` kind, "
|
||||
"each widget is "
|
||||
"``{id, type, settings:{...}}`` keyed by the widget's "
|
||||
"option store. Requires Airano MCP Bridge v2.17.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"area_id": {
|
||||
"type": "string",
|
||||
"description": "Sidebar id (from wp_widget_areas_list).",
|
||||
},
|
||||
},
|
||||
"required": ["area_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_widget_set",
|
||||
"method_name": "wp_widget_set",
|
||||
"description": (
|
||||
"Full-replace a widget area's contents. Block-kind "
|
||||
"areas accept any widget with ``raw`` (block HTML) or "
|
||||
"``blocks`` (block tree, server serialises). Legacy "
|
||||
"areas accept ``text`` widget settings only this round "
|
||||
"— other legacy widget types remain read-only. Caller-"
|
||||
"side ``kind`` is ignored: area kind is determined by "
|
||||
"the area itself; block↔legacy conversion is a theme-"
|
||||
"level decision, not an MCP one. S-23: HTML payloads "
|
||||
"are sanitised via ``wp_kses_post`` unless the caller "
|
||||
"has ``unfiltered_html``. Requires Airano MCP Bridge "
|
||||
"v2.17.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"area_id": {"type": "string"},
|
||||
"widgets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"``block`` for block-kind " "areas; ``text`` for legacy."
|
||||
),
|
||||
},
|
||||
"raw": {
|
||||
"type": "string",
|
||||
"description": ("Block HTML (block kind only)."),
|
||||
},
|
||||
"blocks": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Parsed block tree (block kind "
|
||||
"only); used when ``raw`` is "
|
||||
"absent."
|
||||
),
|
||||
},
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Widget settings (legacy kind). "
|
||||
"For ``text`` widgets: "
|
||||
"{title, text, filter?, visual?}."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["area_id", "widgets"],
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
# ───── Customizer (1) ────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_customizer_changeset",
|
||||
"method_name": "wp_customizer_changeset",
|
||||
"description": (
|
||||
"Inspect or commit the pending customizer changeset. "
|
||||
"``action='get'`` returns the pending changeset payload "
|
||||
"(or ``{status:'empty'}`` when nothing is queued). "
|
||||
"``action='apply'`` publishes it; ``action='discard'`` "
|
||||
"trashes it. S-24: ``apply`` requires the caller to "
|
||||
"also hold the ``customize`` cap (same bar as "
|
||||
"``/wp-admin/customize.php``); ``manage_options`` alone "
|
||||
"is not enough. Apply is racy with concurrent edits "
|
||||
"via the customizer UI — this is intentional and "
|
||||
"mirrors WP's own behaviour. Requires Airano MCP "
|
||||
"Bridge v2.17.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["get", "apply", "discard"],
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_post_id(value: Any, field: str) -> int:
|
||||
"""Validate a post / term id reference.
|
||||
|
||||
Sibling to ``site_config._validate_attachment_id`` — separate so the
|
||||
misnaming in F.19.6.A doesn't propagate. Both are non-negative-int
|
||||
pre-checks; the binding existence + capability gate lives server-side
|
||||
in the companion (S-22 dispatcher for menus, attachment lookup for
|
||||
site identity).
|
||||
"""
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ValueError(f"{field} must be a non-negative integer (got {value!r})")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_menu_item(item: Any, idx: int) -> dict[str, Any]:
|
||||
"""Cheap structural pre-check for a nav-menu item.
|
||||
|
||||
The S-22 capability dispatch is server-side; this only catches
|
||||
obvious shape errors so the caller gets fast feedback without a
|
||||
round-trip. ``custom`` items skip the ``object_id`` check (URL is
|
||||
sanitised by the companion via ``esc_url_raw``).
|
||||
"""
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(f"items[{idx}] must be an object")
|
||||
item_type = item.get("type", "custom")
|
||||
if item_type not in _MENU_ITEM_TYPES:
|
||||
raise ValueError(
|
||||
f"items[{idx}].type must be one of {sorted(_MENU_ITEM_TYPES)} " f"(got {item_type!r})"
|
||||
)
|
||||
if item_type != "custom":
|
||||
# post_type + taxonomy require an object_id.
|
||||
object_id = item.get("object_id", 0)
|
||||
_validate_post_id(object_id, f"items[{idx}].object_id")
|
||||
if object_id == 0:
|
||||
raise ValueError(f"items[{idx}].object_id is required for {item_type} items")
|
||||
return item
|
||||
|
||||
|
||||
class SiteLayoutHandler:
|
||||
"""Site layout surface (F.19.6.B) — menus + widgets + customizer.
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion.
|
||||
The plugin.py wrapper serialises for MCP transport. Server errors
|
||||
(403 forbidden_object_id, 400 invalid_action, etc.) are relayed
|
||||
untouched — the companion is the binding gate.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ── Menus ───────────────────────────────────────────────────────
|
||||
|
||||
async def wp_menu_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/menus",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_menu_get(self, menu_id: int, **_: Any) -> dict[str, Any]:
|
||||
_validate_post_id(menu_id, "menu_id")
|
||||
if menu_id == 0:
|
||||
raise ValueError("menu_id is required (got 0)")
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/menus/{menu_id}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_menu_set(
|
||||
self,
|
||||
menu_id: int,
|
||||
items: list[dict[str, Any]],
|
||||
name: str | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
_validate_post_id(menu_id, "menu_id")
|
||||
if menu_id == 0:
|
||||
raise ValueError("menu_id is required (got 0)")
|
||||
if not isinstance(items, list):
|
||||
raise ValueError("items must be a list")
|
||||
for idx, item in enumerate(items):
|
||||
_validate_menu_item(item, idx)
|
||||
body: dict[str, Any] = {"items": items}
|
||||
if name is not None:
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("name must be a non-empty string")
|
||||
body["name"] = name
|
||||
return await self.client.put(
|
||||
f"{_ADMIN_NS}/menus/{menu_id}",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Widgets ─────────────────────────────────────────────────────
|
||||
|
||||
async def wp_widget_areas_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/widgets/areas",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_widget_get(self, area_id: str, **_: Any) -> dict[str, Any]:
|
||||
if not isinstance(area_id, str) or not area_id:
|
||||
raise ValueError("area_id must be a non-empty string")
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/widgets/{area_id}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_widget_set(
|
||||
self,
|
||||
area_id: str,
|
||||
widgets: list[dict[str, Any]],
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(area_id, str) or not area_id:
|
||||
raise ValueError("area_id must be a non-empty string")
|
||||
if not isinstance(widgets, list):
|
||||
raise ValueError("widgets must be a list")
|
||||
# Strip caller-side ``kind`` if present — area kind is determined
|
||||
# by the area itself, not the request. Block↔legacy conversion
|
||||
# is a theme-level decision.
|
||||
cleaned: list[dict[str, Any]] = []
|
||||
for idx, w in enumerate(widgets):
|
||||
if not isinstance(w, dict):
|
||||
raise ValueError(f"widgets[{idx}] must be an object")
|
||||
cleaned.append({k: v for k, v in w.items() if k != "kind"})
|
||||
body = {"widgets": cleaned}
|
||||
return await self.client.put(
|
||||
f"{_ADMIN_NS}/widgets/{area_id}",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Customizer ──────────────────────────────────────────────────
|
||||
|
||||
async def wp_customizer_changeset(
|
||||
self,
|
||||
action: str,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
if action not in _CUSTOMIZER_ACTIONS:
|
||||
raise ValueError(
|
||||
f"action must be one of {sorted(_CUSTOMIZER_ACTIONS)} " f"(got {action!r})"
|
||||
)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/customizer/changeset",
|
||||
json_data={"action": action},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
538
plugins/wordpress_specialist/handlers/themes.py
Normal file
538
plugins/wordpress_specialist/handlers/themes.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""F.19.7 — Theme dev surface (install + file CRUD).
|
||||
|
||||
Seven tools split across two surfaces. Both ride the same companion
|
||||
plugin (Airano MCP Bridge v2.14.0+) and stay on the existing ``editor``
|
||||
tier introduced by F.19.5 — theme work is the same risk class as page
|
||||
editing, no new tier needed.
|
||||
|
||||
Surface map:
|
||||
|
||||
* **Theme management** (3 tools, companion v2.14.0 routes
|
||||
``/admin/themes/*``): ``wp_theme_install_from_zip`` (POST install),
|
||||
``wp_theme_activate``, ``wp_theme_delete``. Install accepts either
|
||||
a remote ``zip_url`` (companion downloads via ``wp_safe_remote_get``)
|
||||
or an inline ``zip_base64`` (cap 50 MB, decoded server-side). All three
|
||||
ride WP core's ``Theme_Upgrader`` so signature checks and the existing
|
||||
filesystem abstraction stay engaged.
|
||||
* **Theme file CRUD** (4 tools, ``/admin/themes/files/*``):
|
||||
``wp_theme_file_list`` (glob walk), ``wp_theme_file_read``,
|
||||
``wp_theme_file_write``, ``wp_theme_file_delete``. Reads/writes go
|
||||
through ``WP_Filesystem_Direct`` server-side; payloads round-trip as
|
||||
base64 so the JSON envelope stays binary-safe (favicons, fonts, MO
|
||||
files, etc.).
|
||||
|
||||
Security rules layered on top of F.19.2 S-1…S-11 + F.19.5 S-12…S-14
|
||||
(companion enforces these regardless of MCPHub-side guards):
|
||||
|
||||
* **S-15** — ``theme_slug`` must match a key in ``wp_get_themes()``.
|
||||
Companion rejects anything else with ``theme_not_found`` (404).
|
||||
MCPHub-side: a structural slug guard (alphanumerics, dashes,
|
||||
underscores; no slashes / dots / null bytes / leading dash) so
|
||||
malformed slugs don't reach the wire.
|
||||
* **S-16** — Path canonicalisation. Every file route resolves
|
||||
``wp-content/themes/{slug}/{path}`` via ``realpath()`` and rejects
|
||||
results that escape the slug directory. Blocks ``..``, symlinks
|
||||
pointing outside, absolute paths, null bytes. MCPHub-side does a
|
||||
best-effort structural pre-check (the companion's realpath is the
|
||||
binding gate).
|
||||
* **S-17** — Writing PHP files requires ``current_user_can('edit_themes')``
|
||||
AND ``!defined('DISALLOW_FILE_EDIT') || !DISALLOW_FILE_EDIT``. Non-PHP
|
||||
files (CSS, JSON, MO/PO, JS, images, fonts) skip the
|
||||
``DISALLOW_FILE_EDIT`` check but still require ``edit_themes``.
|
||||
* **S-18** — Per-call caps: 5 MB per file, 1000 files per list, 50 MB
|
||||
per theme install zip. Companion enforces in PHP; MCPHub rejects
|
||||
obviously-oversized payloads before the wire.
|
||||
* **S-19** — Optimistic concurrency. When ``expected_sha256`` is
|
||||
provided on write, the companion compares against the current file
|
||||
sha256 and returns ``sha_mismatch`` (409) if it doesn't match. Lets
|
||||
agents reason about conflicting edits without locking.
|
||||
|
||||
All tools require Airano MCP Bridge v2.14.0+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by F.19.1 / F.19.5.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# Mirrored from the companion's THEME_FILE_MAX_BYTES /
|
||||
# THEME_LIST_MAX_FILES / THEME_ZIP_MAX_BYTES so MCPHub can reject
|
||||
# obviously-oversized payloads before they reach the wire (S-18). The
|
||||
# companion enforces the real limit.
|
||||
_THEME_FILE_MAX_BYTES = 5 * 1024 * 1024 # 5 MB per file
|
||||
_THEME_LIST_MAX_FILES = 1000 # files per list call
|
||||
_THEME_ZIP_MAX_BYTES = 50 * 1024 * 1024 # 50 MB per install zip
|
||||
|
||||
# A theme slug from ``wp_get_themes()`` is the directory name under
|
||||
# ``wp-content/themes`` — WP itself permits letters, digits, hyphens,
|
||||
# underscores. We add a hard structural guard here as defence-in-depth
|
||||
# alongside S-15 (the companion's ``wp_get_themes()`` whitelist is the
|
||||
# binding check).
|
||||
_THEME_SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.7 theme dev surface."""
|
||||
return [
|
||||
# ───── Theme management ──────────────────────────────────────
|
||||
{
|
||||
"name": "wp_theme_install_from_zip",
|
||||
"method_name": "wp_theme_install_from_zip",
|
||||
"description": (
|
||||
"Install a theme from a remote URL or inline base64 zip. "
|
||||
"Companion runs WP core's Theme_Upgrader so signature checks "
|
||||
"and the WP filesystem abstraction stay engaged. Pass exactly "
|
||||
"one of zip_url (companion fetches via wp_safe_remote_get) or "
|
||||
"zip_base64 (decoded server-side). Capped at 50 MB per zip "
|
||||
"(S-18). Set activate=true to make the new theme active "
|
||||
"after install; overwrite=true permits re-installing a slug "
|
||||
"that already exists. Requires Airano MCP Bridge v2.14.0+ "
|
||||
"and a WordPress user with install_themes."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"zip_url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"https URL the companion will download via "
|
||||
"wp_safe_remote_get. Mutually exclusive with "
|
||||
"zip_base64."
|
||||
),
|
||||
},
|
||||
"zip_base64": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Base64-encoded theme zip body. Capped at "
|
||||
"~50 MB after decode (S-18). Mutually exclusive "
|
||||
"with zip_url."
|
||||
),
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Activate the installed theme on success. "
|
||||
"Activation requires switch_themes — companion "
|
||||
"rejects with rest_forbidden if missing."
|
||||
),
|
||||
},
|
||||
"overwrite": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Permit overwriting an existing theme with the "
|
||||
"same slug. Required if a previous install is "
|
||||
"already on disk."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_activate",
|
||||
"method_name": "wp_theme_activate",
|
||||
"description": (
|
||||
"Switch the active theme to ``slug``. Companion verifies the "
|
||||
"slug exists in wp_get_themes() (S-15) and the caller holds "
|
||||
"switch_themes. Returns the active stylesheet + template "
|
||||
"after the switch — useful when activating a child theme "
|
||||
"(stylesheet differs from template). Requires Airano MCP "
|
||||
"Bridge v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Theme directory name (key in wp_get_themes()). "
|
||||
"Alphanumerics, dashes, underscores only."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_delete",
|
||||
"method_name": "wp_theme_delete",
|
||||
"description": (
|
||||
"Delete an installed theme by slug. Companion refuses to "
|
||||
"delete the active theme (returns ``theme_active``) and the "
|
||||
"current default theme. Caller must hold delete_themes. "
|
||||
"Requires Airano MCP Bridge v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": "Theme directory name to delete.",
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
# ───── Theme file CRUD ───────────────────────────────────────
|
||||
{
|
||||
"name": "wp_theme_file_list",
|
||||
"method_name": "wp_theme_file_list",
|
||||
"description": (
|
||||
"List files inside a theme directory. Walks "
|
||||
"``wp-content/themes/{theme_slug}`` and returns each file's "
|
||||
"relative path, size, mime, sha256, and modified_at (epoch). "
|
||||
"Optional ``glob`` filters by fnmatch pattern (default "
|
||||
"``**/*``). Capped at 1000 files per call (S-18); when the "
|
||||
"walk truncates, the response carries ``truncated: true``. "
|
||||
"Requires Airano MCP Bridge v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_slug": {
|
||||
"type": "string",
|
||||
"description": "Theme directory name (S-15).",
|
||||
},
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"fnmatch glob (e.g. ``**/*.php``). Defaults to "
|
||||
"``**/*`` — every file."
|
||||
),
|
||||
"default": "**/*",
|
||||
},
|
||||
"max_files": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": _THEME_LIST_MAX_FILES,
|
||||
"default": _THEME_LIST_MAX_FILES,
|
||||
"description": (
|
||||
"Hard cap on entries returned. Companion stops "
|
||||
"the walk and sets truncated=true on overflow."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["theme_slug"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_file_read",
|
||||
"method_name": "wp_theme_file_read",
|
||||
"description": (
|
||||
"Read a file inside a theme as base64. Returns "
|
||||
"``{content_base64, mime, size, sha256, modified_at}``. Path "
|
||||
"must resolve under ``wp-content/themes/{theme_slug}`` "
|
||||
"(S-16); ``..``, absolute paths, null bytes, and symlinks "
|
||||
"that escape are rejected by the companion's realpath gate. "
|
||||
"Files larger than 5 MB return ``file_too_large`` (S-18). "
|
||||
"Requires Airano MCP Bridge v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_slug": {"type": "string"},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Theme-relative path. ``style.css``, "
|
||||
"``parts/header.html``, ``functions.php``, etc."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["theme_slug", "path"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_file_write",
|
||||
"method_name": "wp_theme_file_write",
|
||||
"description": (
|
||||
"Write a file inside a theme. ``content_base64`` is the "
|
||||
"decoded body (capped at 5 MB, S-18). PHP file writes "
|
||||
"additionally require ``edit_themes`` AND "
|
||||
"``!DISALLOW_FILE_EDIT`` (S-17); non-PHP writes only need "
|
||||
"``edit_themes``. Pass ``expected_sha256`` for optimistic "
|
||||
"concurrency: the companion compares against the current "
|
||||
"file's sha256 and returns ``sha_mismatch`` (409) on drift "
|
||||
"(S-19). When ``create_dirs`` is true (default) any missing "
|
||||
"parent directories are created. Requires Airano MCP Bridge "
|
||||
"v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_slug": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"content_base64": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Base64-encoded file body. Decoded server-side; "
|
||||
"5 MB hard cap (S-18)."
|
||||
),
|
||||
},
|
||||
"expected_sha256": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional sha256 of the on-disk file the caller "
|
||||
"based their edit on. When supplied, companion "
|
||||
"rejects with sha_mismatch on drift (S-19). "
|
||||
"Omit to perform an unconditional write."
|
||||
),
|
||||
},
|
||||
"create_dirs": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": (
|
||||
"Create any missing parent directories. Set to "
|
||||
"false to require the directory already exists."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["theme_slug", "path", "content_base64"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_file_delete",
|
||||
"method_name": "wp_theme_file_delete",
|
||||
"description": (
|
||||
"Delete a file inside a theme. Path resolution is identical "
|
||||
"to wp_theme_file_read (S-16). Refuses to delete "
|
||||
"``style.css`` of the active theme — that would break the "
|
||||
"front-end. Requires Airano MCP Bridge v2.14.0+ and "
|
||||
"``edit_themes``."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_slug": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
},
|
||||
"required": ["theme_slug", "path"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
#
|
||||
# These are structural guards so the obvious bad calls don't reach the
|
||||
# wire. The binding security check for both rules is server-side: the
|
||||
# companion canonicalises paths via ``realpath()`` (S-16) and intersects
|
||||
# the slug list with ``wp_get_themes()`` (S-15).
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_theme_slug(slug: Any) -> str:
|
||||
"""S-15 client-side guard.
|
||||
|
||||
Reject obviously-malformed slugs before the wire. The companion
|
||||
still does the real ``wp_get_themes()`` membership check.
|
||||
"""
|
||||
if not isinstance(slug, str) or not slug:
|
||||
raise ValueError("theme_slug must be a non-empty string")
|
||||
if not _THEME_SLUG_RE.match(slug):
|
||||
raise ValueError(
|
||||
f"theme_slug must be alphanumerics + dashes + underscores "
|
||||
f"(<=64 chars, no leading dash); got {slug!r}"
|
||||
)
|
||||
return slug
|
||||
|
||||
|
||||
def _validate_theme_file_path(path: Any) -> str:
|
||||
"""S-16 client-side guard.
|
||||
|
||||
Reject the obvious traversal shapes — ``..`` segments, leading
|
||||
slashes, null bytes, backslashes (Windows-style escapes). The
|
||||
companion's ``realpath()`` is the binding gate.
|
||||
"""
|
||||
if not isinstance(path, str) or not path:
|
||||
raise ValueError("path must be a non-empty string")
|
||||
if "\x00" in path:
|
||||
raise ValueError("path must not contain null bytes")
|
||||
if "\\" in path:
|
||||
raise ValueError("path must use forward slashes only")
|
||||
if path.startswith("/"):
|
||||
raise ValueError("path must be theme-relative (no leading slash)")
|
||||
# Reject any segment equal to ``..`` — accepts ``..foo`` (a real
|
||||
# filename) but blocks the traversal idiom.
|
||||
parts = [p for p in path.split("/") if p]
|
||||
if any(p == ".." for p in parts):
|
||||
raise ValueError("path must not contain `..` segments")
|
||||
if not parts:
|
||||
raise ValueError("path must reference a file, not the theme root")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _quote_path(path: str) -> str:
|
||||
"""Percent-encode a theme-relative path while keeping ``/`` literal."""
|
||||
return quote(path, safe="/")
|
||||
|
||||
|
||||
class ThemesHandler:
|
||||
"""Theme management + theme file CRUD surface (F.19.7).
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion. The
|
||||
plugin.py wrapper layer is responsible for serialising the dict for
|
||||
MCP transport.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ── Theme management ────────────────────────────────────────────
|
||||
|
||||
async def wp_theme_install_from_zip(
|
||||
self,
|
||||
zip_url: str | None = None,
|
||||
zip_base64: str | None = None,
|
||||
activate: bool = False,
|
||||
overwrite: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
# Exactly one source must be supplied — both the prompt and the
|
||||
# companion route reject the empty + double-supply cases.
|
||||
if not zip_url and not zip_base64:
|
||||
raise ValueError("wp_theme_install_from_zip requires zip_url or zip_base64")
|
||||
if zip_url and zip_base64:
|
||||
raise ValueError("wp_theme_install_from_zip accepts zip_url OR zip_base64, not both")
|
||||
body: dict[str, Any] = {
|
||||
"activate": bool(activate),
|
||||
"overwrite": bool(overwrite),
|
||||
}
|
||||
if zip_url:
|
||||
if not isinstance(zip_url, str):
|
||||
raise ValueError("zip_url must be a string")
|
||||
body["zip_url"] = zip_url
|
||||
else:
|
||||
if not isinstance(zip_base64, str):
|
||||
raise ValueError("zip_base64 must be a string")
|
||||
# Cheap pre-cap: base64 expands by ~4/3, so ``len * 3 // 4``
|
||||
# is an upper bound on the decoded byte count. This catches
|
||||
# the obviously-too-big payloads without doing a full
|
||||
# decode (which would double the memory usage).
|
||||
decoded_size_upper_bound = len(zip_base64) * 3 // 4
|
||||
if decoded_size_upper_bound > _THEME_ZIP_MAX_BYTES:
|
||||
raise ValueError(
|
||||
f"zip_base64 decodes to roughly {decoded_size_upper_bound} bytes "
|
||||
f"— exceeds {_THEME_ZIP_MAX_BYTES} byte cap (S-18)"
|
||||
)
|
||||
body["zip_base64"] = zip_base64
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/themes/install",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_activate(self, slug: str, **_: Any) -> dict[str, Any]:
|
||||
slug = _validate_theme_slug(slug)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/themes/{slug}/activate",
|
||||
json_data={},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_delete(self, slug: str, **_: Any) -> dict[str, Any]:
|
||||
slug = _validate_theme_slug(slug)
|
||||
return await self.client.delete(
|
||||
f"{_ADMIN_NS}/themes/{slug}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Theme file CRUD ─────────────────────────────────────────────
|
||||
|
||||
async def wp_theme_file_list(
|
||||
self,
|
||||
theme_slug: str,
|
||||
glob: str = "**/*",
|
||||
max_files: int = _THEME_LIST_MAX_FILES,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
theme_slug = _validate_theme_slug(theme_slug)
|
||||
if not isinstance(glob, str) or not glob:
|
||||
raise ValueError("glob must be a non-empty string")
|
||||
if not isinstance(max_files, int) or isinstance(max_files, bool) or max_files <= 0:
|
||||
raise ValueError("max_files must be a positive integer")
|
||||
if max_files > _THEME_LIST_MAX_FILES:
|
||||
raise ValueError(
|
||||
f"max_files {max_files} exceeds the {_THEME_LIST_MAX_FILES} per-call cap (S-18)"
|
||||
)
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/themes/files/{theme_slug}",
|
||||
params={"glob": glob, "max_files": max_files},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_file_read(
|
||||
self,
|
||||
theme_slug: str,
|
||||
path: str,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
theme_slug = _validate_theme_slug(theme_slug)
|
||||
path = _validate_theme_file_path(path)
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/themes/files/{theme_slug}/{_quote_path(path)}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_file_write(
|
||||
self,
|
||||
theme_slug: str,
|
||||
path: str,
|
||||
content_base64: str,
|
||||
expected_sha256: str | None = None,
|
||||
create_dirs: bool = True,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
theme_slug = _validate_theme_slug(theme_slug)
|
||||
path = _validate_theme_file_path(path)
|
||||
if not isinstance(content_base64, str):
|
||||
raise ValueError("content_base64 must be a string")
|
||||
decoded_size_upper_bound = len(content_base64) * 3 // 4
|
||||
if decoded_size_upper_bound > _THEME_FILE_MAX_BYTES:
|
||||
raise ValueError(
|
||||
f"content_base64 decodes to roughly {decoded_size_upper_bound} bytes "
|
||||
f"— exceeds {_THEME_FILE_MAX_BYTES} byte cap (S-18)"
|
||||
)
|
||||
body: dict[str, Any] = {
|
||||
"content_base64": content_base64,
|
||||
"create_dirs": bool(create_dirs),
|
||||
}
|
||||
if expected_sha256 is not None:
|
||||
if not isinstance(expected_sha256, str) or not re.fullmatch(
|
||||
r"[0-9a-fA-F]{64}", expected_sha256
|
||||
):
|
||||
raise ValueError("expected_sha256 must be a 64-char hex string")
|
||||
body["expected_sha256"] = expected_sha256.lower()
|
||||
return await self.client.put(
|
||||
f"{_ADMIN_NS}/themes/files/{theme_slug}/{_quote_path(path)}",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_file_delete(
|
||||
self,
|
||||
theme_slug: str,
|
||||
path: str,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
theme_slug = _validate_theme_slug(theme_slug)
|
||||
path = _validate_theme_file_path(path)
|
||||
return await self.client.delete(
|
||||
f"{_ADMIN_NS}/themes/files/{theme_slug}/{_quote_path(path)}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
358
plugins/wordpress_specialist/plugin.py
Normal file
358
plugins/wordpress_specialist/plugin.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user