Metadata-Version: 2.4
Name: yootheme-mcp
Version: 0.11.3
Summary: MCP server and CLI for YOOtheme Pro 5 on WordPress and Joomla. Read-only tools are free.
Author-email: GMC <gmcfuerte@gmail.com>
License-Expression: LicenseRef-GMC-Commercial
Project-URL: Homepage, https://fuerteventuratv.net/en/cms/yootheme-mcp
Keywords: mcp,yootheme,wordpress,joomla,page-builder,llm
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mcp[cli]<2,>=1.2.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: pydantic>=2.7.0
Requires-Dist: platformdirs>=4.2.0
Requires-Dist: click>=8.1.0
Requires-Dist: paramiko>=3.0.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: ruff>=0.5; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Dynamic: license-file

# yootheme-mcp

**Three products** from one repo — see [PRODUCTS.md](PRODUCTS.md):

| # | Product | Entry |
|---|---------|-------|
| 1 | **Skill** `yootheme-remote` | `skill/SKILL.md` |
| 2 | **MCP** | `yootheme-mcp` / `python -m yootheme_mcp` |
| 3 | **CLI** | `yootheme …` |

**CLI** (and optional MCP server) to manage **YOOtheme Pro 5.x** layouts,
design settings, custom code, the new cookie consent manager and saved element
presets on **WordPress** and **Joomla 4/5** sites.

Talks to your sites via their **standard REST API** — no plugins, no MU-plugins,
no SSH, no file-system access required on the remote side. Just an
Application Password (WordPress) or an API Token (Joomla).

> Status: beta. Package: **yootheme-mcp 0.11.3** (MCP + CLI + Skill).
> **Read-only tools and dry-run previews are free; writing to a site needs a
> licence key** — see
> [Licensing](#licensing). What leaves your machine: see [Privacy](#privacy).
> Current prices and licence terms: https://fuerteventuratv.net/en/cms/yootheme-mcp

<!-- mcp-name: io.github.gmcfuerte/yootheme-mcp -->
<!-- The line above is the MCP Registry's proof that this PyPI package belongs to that server name. Keep it. -->


## Two entry points

The package ships two console scripts after `pip install -e .`:

| Command          | When to use it                                              |
|------------------|-------------------------------------------------------------|
| `yootheme ...`   | Daily ops from a terminal (you, scripts, CI)                |
| `yootheme-mcp`   | Run as an MCP server so Claude / agents can call the tools  |

The CLI is the recommended entry point. The MCP server is the same logic
exposed to language models — useful if you want Claude Desktop to manage your
sites in chat.

## CLI quick reference

```bash
# discover what's configured
yootheme sites

# verify auth + reachability
yootheme ping tiserve

# list pages/posts/articles/modules — mark which have a YT layout
yootheme list tiserve --limit 50 --only-with-layout

# read a layout and save it to disk
yootheme get tiserve 32 --type page -o page32.json

# write a layout back
yootheme set tiserve 32 page32.json --type page

# copy a layout to another site (cross-CMS works!)
yootheme duplicate tiserve 32 staging-joomla 17 \
  --source-type page --target-type article

# bulk find/replace across every layout — dry-run by default
yootheme bulk-replace tiserve "https://old.example.com" "https://new.example.com"
yootheme bulk-replace tiserve "https://old.example.com" "https://new.example.com" --apply

# style customizer
yootheme customizer get tiserve -o customizer-backup.json
yootheme customizer set tiserve customizer-edited.json

# settings panel (custom CSS/JS, cookie consent, favicon)
yootheme settings get tiserve --key custom_less -o custom.less
yootheme settings get tiserve --key consent          # cookie consent manager
yootheme settings get tiserve --key scripts          # custom JavaScript list
yootheme settings set tiserve custom_less new-custom.less

# style customizer panels live under their own keys too
yootheme settings get tiserve --key style            # active style picker (e.g. "fuse")
yootheme settings get tiserve --key header.layout    # any Layout/Theme-settings panel

# saved element presets ("My Presets")
yootheme presets list tiserve
yootheme presets export tiserve my-grid-preset preset.json
yootheme presets import tiserve my-grid-preset preset.json

# Joomla-only: article CRUD
yootheme article create loquehay --title "New article" --catid 9
yootheme article update loquehay 42 --title "Renamed"
yootheme article publish loquehay 42
yootheme article delete loquehay 42              # -> Joomla trash (recoverable)
yootheme article delete loquehay 42 --permanent  # destroys it, no undo

# Joomla-only: media manager (list / upload / delete)
yootheme media list loquehay --path images
yootheme media upload loquehay ./hero.jpg --dest images
yootheme media delete loquehay images/hero.jpg

# Joomla-only: menus and menu items
yootheme menu types loquehay
yootheme menu items loquehay --menutype mainmenu
yootheme menu get loquehay 101
yootheme menu set loquehay 101 patch.json

# Joomla-only: list YT template styles + assignment
yootheme templates loquehay

# global flag: print machine-readable JSON
yootheme --json list tiserve --limit 5
```

Most write commands prompt for confirmation; pass `--yes` to skip. `bulk-replace
--apply`, `presets import` and `article create/publish/unpublish` do NOT prompt —
in particular `--apply` rewrites every matching layout on the site, so read the
dry-run report first.

## Feature coverage

| Feature                                       | CLI / MCP support                                              |
|-----------------------------------------------|----------------------------------------------------------------|
| Page layouts (sections / rows / columns)      | `yootheme get/set/list/duplicate`                              |
| Element library / element-level props         | edit via `get` + `set`                                         |
| **Element Presets** (My Presets tab)          | `yootheme presets list/export/import`                          |
| Style Customizer                              | `yootheme customizer get/set`                                  |
| **Custom CSS / Less** (Settings panel)        | `yootheme settings ... --key custom_less`                      |
| **Custom JavaScript** (Settings → Scripts)    | `yootheme settings ... --key scripts`                          |
| **Cookie Consent Manager** (v5.0)             | `yootheme settings ... --key consent`                          |
| Favicon / Touch icon                          | `yootheme settings ... --key favicon` / `--key touchicon`      |
| Joomla **modules** with YT Builder layout     | `--type module` on get/set/list                                |
| Scroll-driven animations / parallax (v5.0)    | edit via `get` + `set` (MCP `inspect_animations` tool finds them) |
| Display Conditions, Menu Element, Date Filter, Margin/Padding splits, Multilingual switcher, lazy/on-click videos (all v5.0) | inside the layout JSON — edit via `get` + `set` |
| Joomla **article CRUD**                        | `yootheme article create/update/publish/unpublish/delete`     |
| Joomla **media manager**                       | `yootheme media list/upload/delete`                           |
| Joomla **menus & menu items**                  | `yootheme menu types/items/get/set`                           |
| Joomla **template styles** + assignment        | `yootheme templates`                                          |
| Pro Presets cloud library                     | NOT exposed — admin-only                                       |
| Child theme files                             | NOT exposed — use SFTP                                         |

### Customizer coverage map (vs. yootheme.com docs)

YOOtheme Pro's Customizer (tested against 5.0.x) groups everything into seven
areas. They all serialize into one JSON config blob (Joomla: the template
style's `params.config`; WordPress: `/wp-json/yootheme/v1/style` + `settings`),
which `customizer`/`settings get`/`set` read and write by key. Mapping:

| Customizer area (docs)                | Config key(s)                                          | How to reach it |
|---------------------------------------|--------------------------------------------------------|-----------------|
| **Style** → active style picker       | `style` (e.g. `"fuse"`)                                | `settings get/set --key style` |
| **Style** → Global / Theme / Inverse / component Less variables | `less` (map of `@variable: value`) | `customizer get/set` or `--key less` |
| **Style** → Google Fonts             | within `less` / `style`                                | `customizer get/set` |
| **Layout** → Site & Logo             | `site`, `logo`                                         | `settings --key site` / `logo` |
| **Layout** → Header & Navbar         | `header`, `navbar`, `top`, `bottom`                    | `settings --key header` … |
| **Layout** → Mobile header / off-canvas | `mobile`, `dialog`                                  | `settings --key mobile` |
| **Layout** → Sidebar                 | `main_sidebar`                                          | `settings --key main_sidebar` |
| **Layout** → Footer builder          | `footer`                                               | `settings --key footer` |
| **Layout** → Blog / Post / Category  | `blog`, `post`, `page_category`, `search_module`       | `settings --key blog` … |
| **Menu** (positions & items)         | `menu` (positions); Joomla menu items                  | `settings --key menu` / `menu` cmds |
| **Settings** → CSS (custom Less)     | `custom_less`                                          | `settings --key custom_less` |
| **Settings** → Scripts (custom JS)   | `scripts` (list of descriptors)                        | `settings --key scripts` |
| **Settings** → Consent Manager       | `consent`                                              | `settings --key consent` |
| **Settings** → Favicon / Touch icon  | `favicon`, `favicon_svg`, `touchicon`                  | `settings --key favicon` |
| **Settings** → Advanced              | `child_theme`, `media_folder`, `fontawesome`, `bootstrap`, `jquery`, `webp`, `lazyload`, `highlight` | `settings --key <name>` |
| **Settings** → External Services / API Key | `scripts` prebuilt entries / provider keys        | `settings get/set` |
| **Pages** (per-page builder layouts) | WP `post_content` / Joomla article `fulltext` JSON comment | `get/set/list/duplicate` |
| **Templates** (styles + assignment)  | Joomla template styles                                 | `templates` |
| **Modules**                          | Joomla module `content`                                | `--type module` |
| **Settings** → Cache / System Check / Recompile / Download Less | — (admin-only UI actions)          | NOT exposed |

> Every customizer/settings value is reachable through the generic
> `settings get/set --key <dotted.path>` (and `customizer get/set`). The only
> things with no REST surface are admin-UI actions (recompile, clear cache,
> download Less, system check) and the style library / cloud presets.

> **WordPress caveat (verified on Joomla only).** On Joomla `get_settings()`
> and `get_customizer()` are the *same* `config` blob, so `consent` /
> `custom_less` / `scripts` are always reachable. On WordPress they are
> *different* REST endpoints (`yootheme/v1/settings` vs `yootheme/v1/style`),
> and the Settings shortcut tools (`*_cookie_consent`, `*_custom_code`) read and
> write via `get_settings()`. Confirm those keys actually live in the settings
> blob on your WP install before relying on the shortcuts — compare the read-only
> `yootheme settings get <site> --key consent` against `yootheme customizer get
> <site>`: if the key appears only in the customizer output, the shortcuts are
> reading the wrong store.

## Install

Full English guide (this MCP + **elementor-mcp**): **[INSTALL.md](INSTALL.md)**  
Windows quick path: `extras/INSTALL_WINDOWS.md`

From PyPI (MCP Marketplace buyers, or to try the free read-only tools):

```bash
python -m venv .venv && source .venv/bin/activate    # PowerShell: .venv\Scripts\Activate.ps1
pip install yootheme-mcp

yootheme --version           # prints the installed version
```

```powershell
# Or on Windows (PowerShell), from the unzipped package folder bought on the GMC store:
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -e .
```

Windows step-by-step (config paths, Claude Desktop wiring): `extras/INSTALL_WINDOWS.md`.

## Configure your sites

A single JSON file holds every site you want to manage. Path:

- Linux/macOS: `~/.config/yootheme-mcp/sites.json`
- Windows:     `%APPDATA%\yootheme-mcp\sites.json`

Or set `YOOTHEME_MCP_CONFIG` to a custom location.

```json
{
  "sites": {
    "tiserve": {
      "platform": "wordpress",
      "url": "https://tiserve.it",
      "wp_username": "your-wp-username",
      "wp_app_password": "xxxx xxxx xxxx xxxx xxxx xxxx"
    },
    "loquehay": {
      "platform": "joomla",
      "url": "https://loquehay.es",
      "joomla_token": "base64TokenFromUserProfile"
    }
  }
}
```

See `extras/sites.example.windows.json` for a Windows-friendly starter file.

### WordPress Application Password

1. WP-Admin → Users → your profile → scroll to **Application Passwords**
2. Name it `yootheme-cli` → **Add New Application Password**
3. Copy the displayed value (format `xxxx xxxx xxxx xxxx xxxx xxxx`) — not shown again

> Your normal admin login password does NOT work for the REST API.
> Application Passwords are core to WordPress since 5.6.

### Joomla API Token

1. Enable plugins **API Authentication - Web Services Joomla Token** and **User - Joomla API Token**
2. Edit a Super User profile → **Joomla API Token** tab → Generate
3. Copy the token as displayed (already base64-encoded)

## WordPress native layout storage

YOOtheme Pro 5 stores its builder root in a JSON HTML comment in `post_content`.
The default WordPress REST adapter reads editable raw content and replaces only
that comment. Existing text before and after the comment is preserved, including
the search excerpt; regenerate that excerpt in the YOOtheme builder if needed.
Native layout access requires an account allowed to edit the target post, but
does not require the bundled meta-exposer MU-plugin.

Versions through 0.10.1 used `_yootheme_page` meta and could report a successful
write without changing the rendered page. A previous meta-only layout can be
read for recovery; writing it to an empty page now creates native content.
Converting a non-builder post requires explicit `--replace-body` / `replace_body`.
That operation returns the replaced text: retain it alongside the layout backup.
A non-default `wp_meta_key` retains custom meta integration behavior.

The optional SSH adapter still uses meta storage and has not received the native
content correction. Use WordPress REST for native YOOtheme 5 page layouts.

On Joomla an article layout is an HTML comment at the start of `fulltext`, while
a `mod_yootheme_builder` module stores its layout tree in `content`. Both are
accessible via the Web Services API without any extra step.

> **A Joomla article's builder layout IS its body.** YOOtheme renders it from an
> HTML comment at the start of `fulltext`, which has to be the whole body, so
> writing a layout onto an article that already has text replaces that text —
> irrecoverably, since the Web Services API keeps no version of it. `set`,
> `import` and `duplicate` refuse to do that and tell you so; pass
> `--replace-body` (CLI) or `replace_body: true` (MCP) to accept the loss. The
> replaced text comes back to you as `replaced_body`, and nowhere else.

## Run as MCP server (optional)

If you also want Claude Desktop to call these tools in chat:

```json
{
  "mcpServers": {
    "yootheme": {
      "command": "yootheme-mcp",
      "env": {
        "YOOTHEME_MCP_CONFIG": "C:\\Users\\<you>\\AppData\\Roaming\\yootheme-mcp\\sites.json",
        "MCP_LICENSE_KEY": "mcp_live_<your key - optional, enables the write tools>"
      }
    }
  }
}
```

Leave `MCP_LICENSE_KEY` out to run the free read-only tools and previews, or use
`YOOTHEME_MCP_LICENSE_KEY` / `license.key` for a key bought on the GMC store
(see [Licensing](#licensing)). Run the server over **stdio** only: `--http`
starts an HTTP transport with no authentication, for your own machine.

See `extras/INSTALL_WINDOWS.md` for the full Windows guide.

## Licensing

yootheme-mcp is commercial software with a **free read-only tier**.

| | Without a key | With a valid key |
|---|---|---|
| Read-only MCP tools (list, get, export, find, inspect, ping — 20 tools) | yes | yes |
| Read-only CLI commands (`sites`, `ping`, `list`, `get`, `customizer get`, `settings get`, `presets list` / `export`, `menu types` / `items` / `get`, `media list`, `templates`) | yes | yes |
| **Previews (dry runs)** of the write tools that have one — see below | yes | yes |
| Tools and commands that change a site (set, import, duplicate, replace, save, delete, upload, publish, patch — 21 tools) | **refused, with an explanation** | yes |

Each tool is declared read-only or writing in the code, and the MCP annotations
(`readOnlyHint`, `destructiveHint`) say the same thing to your client.

**Free previews.** Four write operations have a dry run, and the dry run is free:
the MCP tools `yootheme_replace_text`, `yootheme_replace_image` and
`yootheme_bulk_find_replace` (with `dry_run` left at its default, `true`), and the
CLI command `bulk-replace` without `--apply`. A dry run reads the site and reports
what would change; it writes nothing — not to the site, and no local backup
either. While a free preview runs, the tool is technically unable to send
anything but read requests. Applying the change (`dry_run: false`, or
`bulk-replace --apply`) needs a key. No other tool has a dry run: a confirmation
prompt (`--yes`) is not a preview.

Two kinds of key unlock the write tools; either one is enough:

| Key | Bought on | Set it as | Verified by |
|---|---|---|---|
| **MCP Marketplace** key (`mcp_live_…`) | MCP Marketplace (mcp-marketplace.io) — annual subscription, equivalent to the GMC **Solo** tier (1 person) | `MCP_LICENSE_KEY` in the environment of the MCP server or shell | MCP Marketplace |
| **GMC** key | https://fuerteventuratv.net/en/cms/yootheme-mcp — Solo, Base and Agency tiers | `YOOTHEME_MCP_LICENSE_KEY`, or one line in `license.key` in the config folder (`%APPDATA%\yootheme-mcp\` on Windows, `~/.config/yootheme-mcp/` on Linux) | GMC Licenses |

The prefix decides where a key is checked, so a key in the "wrong" variable
still works. How the check behaves:

- The key is checked the first time a write tool or command runs (and once when
  the MCP server starts, to report the status on stderr). A successful check is
  remembered on this machine — 24 hours for an MCP Marketplace key, 7 days for a
  GMC key — in a signed file that does not contain the key.
- If the licence server cannot be reached, a successful check from the **last 7
  days** still counts; otherwise write tools are refused until it can be reached.
  Read-only tools never need the network for licensing.
- A key the server rejects (revoked, expired, replaced, unknown) stops the write
  tools at once; the read-only tools keep working.
- There is no switch that skips the check.

Multi-seat and agency use: buy the Base or Agency tier on the GMC store. Full
terms: `LICENSE`.

## Privacy

yootheme-mcp runs on your computer. There is no telemetry.

- **Site credentials** (WordPress Application Passwords, Joomla API tokens, SSH
  details) are read from your local `sites.json`, environment or env file and are
  sent **only** to the sites you configured.
- **Page content and layouts** travel only between your machine and your own
  sites. Pre-write backups are written to your local disk.
- **Licence check**: only the licence key and the product slug (`yootheme-mcp`)
  are sent, and only to the server that issued that kind of key:
  - MCP Marketplace keys → `https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key`
    (MCP Marketplace's verification service, hosted on Supabase), as
    `{"key": …, "slug": …}`;
  - GMC keys → `https://fuerteventuratv.net` (GMC Licenses, `api.entitlement`),
    as `product` and `license_key` query parameters.

  Like any HTTPS request, these also reveal your IP address to that server.
  Without a key, nothing is sent anywhere except to your own sites.


## Architecture

```
yootheme_mcp/
  cli.py             # Click CLI (yootheme command)
  server.py          # FastMCP server (yootheme-mcp command)
  config.py          # sites.json + env-var config loader
  errors.py          # actionable error messages
  models.py          # Pydantic models
  license.py         # licence gate: free reads, key-checked writes
  adapters/
    base.py          # abstract adapter
    wordpress.py     # WP REST + Application Password
                     # auto-detect pretty vs Plain permalinks
    joomla.py        # Joomla Web Services + Bearer token
                     # supports article AND module object_types
  tools/             # MCP-only tools (sites, layouts, bulk, customizer,
                     # settings, presets, animations)
    _registry.py     # read_tool / write_tool: the one read-or-write declaration
```

Both `cli.py` and `tools/*.py` delegate to `adapters/` — same REST code,
two front-ends.

## Changelog

### v0.11.3 — Editing a Joomla menu item keeps its language links

- On a multilingual Joomla site, `yootheme menu set` and the MCP tool
  `yootheme_patch_menu_item` unlinked the menu item from its translations:
  Joomla drops a menu item's language associations when a save does not send
  them, and it drops them for the whole group, so the English, Italian and
  Spanish versions of the item all stopped pointing at each other in the
  language switcher. The tool now sends the item's current associations back
  with every change, so a title or publish change leaves them as they were.
- To unlink an item on purpose, send `"associations": {}` in the patch.
- If you changed menu items on a multilingual Joomla site with an earlier
  version, check their Associations tab in Joomla (Menus -> the item ->
  Associations, or Components -> Multilingual Associations) and re-link the
  translations that lost their link. Single-language sites were not affected,
  and nothing else about the items changed.

### v0.11.2 — `bulk-replace` scans a WordPress site once, every page

- The CLI `bulk-replace` on WordPress re-read the same page of results instead
  of moving on (up to ~50 times), then usually stopped with `400 Bad Request`
  when it asked for a page past the last one, without printing its report. It
  now reads each page of results once and each page and post exactly once.
- If you ran `bulk-replace --apply` on WordPress with a replacement that
  contains the search text (for example `foo` -> `foobar`), the objects on a
  re-read page were rewritten more than once. Unless backups were turned off,
  each rewrite left a backup in your backup folder: the oldest backup of an
  object is its original state.
- WordPress, pages and posts together (no `--type`, and the default of
  `yootheme_bulk_find_replace`): when one of the two ran out of pages before the
  other, WordPress answered `400` and the scan stopped (the MCP tool reported it
  as `aborted`). A type with no more pages is now simply finished.
- Joomla, articles and modules together (no `--type`): the CLI `bulk-replace`
  could skip objects when one of the two had more than 50. With 60 articles and
  5 modules, articles 51-55 were never scanned, so neither the preview nor
  `--apply` touched them. The MCP tool was not affected.

### v0.11.1 — Previews (dry runs) are free

- The dry run of `yootheme_replace_text`, `yootheme_replace_image` and
  `yootheme_bulk_find_replace` (the default, `dry_run: true`) and of the CLI
  `bulk-replace` (without `--apply`) now works without a licence key: see what a
  replace would change before you buy. Applying it still needs a key, and is
  refused before the site is contacted.
- A free preview cannot write: while it runs, the WordPress and Joomla clients
  refuse every request that is not a read, the SSH backend refuses its write
  commands, and no pre-write backup is taken (a preview never took one).
- The licence (section 1A) now includes previews in the free use.

### v0.11.0 — Free read-only tier, licence enforced on writes, ready for PyPI

- **Licensing changed.** The 20 read-only MCP tools and the read-only CLI
  commands now work without a key. The 21 tools and 14 commands that change a
  site need a valid key and refuse clearly without one — before any
  confirmation prompt and before any request to the site. Until 0.10.x a missing
  key or an unreachable licence server let everything run.
- **Two key types.** MCP Marketplace keys (`MCP_LICENSE_KEY`, `mcp_live_…`) are
  accepted next to GMC store keys (`YOOTHEME_MCP_LICENSE_KEY` / `license.key`).
- **No more exit at start-up.** A missing or rejected key no longer stops the MCP
  server or the CLI (exit code 2 in 0.10.x); the status is reported on stderr.
  `yootheme-mcp --help` no longer contacts the licence server.
- **Bypasses removed.** The two environment variables that skipped the check or
  kept a rejected key running no longer exist and are ignored if set.
- **Signed licence cache.** A successful check is cached in a signed file that
  holds a hash of the key instead of its last characters; an offline machine
  keeps write access for at most 7 days after the last successful check. The
  0.10.x cache file is ignored and replaced.
- **Joomla tools annotated.** The 12 Joomla tools now declare `readOnlyHint` /
  `destructiveHint` like the others, so MCP clients know which ones to confirm.
- **PyPI packaging.** Wheel and sdist are built from the same allowlist as the
  customer zip and audited against it (`scripts/build_pypi.py`). New `Licensing`
  and `Privacy` sections in this README; `server.json` for the MCP Registry.

### v0.10.2 — Native WordPress builder content

- Read and discover YOOtheme 5 layouts from editable post content.
- Write the native JSON comment and independently verify persisted content;
  preserve surrounding text and fail on missing edit access or ambiguous roots.
- Keep automatic pre-write layout backups. Refuse conversion of non-builder
  content unless explicitly requested; return the replaced body when requested.
- Escape HTML comment delimiters inside layout text and retain custom meta keys.
- Correct stale package and price information in this README.

### v0.10.1 — "local" is a hostname, not a word in the URL (security)

**Read this before updating: a site entry that this client accepted until now can be
rejected after the update.** If any site in your `sites.json` (or `.env`) uses `http://`
for anything other than the loopback machine itself — `localhost`, `127.0.0.1`, `::1` —
or writes the credentials inside the URL (`https://user:password@host/`), the client will
now refuse that entry with a configuration error instead of connecting. The fix is to give
the site its real `https://` URL and keep user and password in their own fields
(`wp_username` / `wp_app_password`, or `joomla_token`). Nothing else about your
configuration changes, and no data on any site is touched by this update.

- **The local-development exemption tested the whole URL, so remote sites could pass as
  local.** This client allows plain `http://` and relaxed TLS checking for a site running
  on the operator's own machine. That exemption was implemented as a text search for the
  loopback names *anywhere in the URL string*, rather than a test of the host the request
  actually goes to. A perfectly ordinary remote address could therefore satisfy it — for
  example because a loopback name happened to appear further along in the path, or because
  it formed the leading part of a longer, publicly registrable domain name. When that
  happened the client would talk to a real, remote site over unencrypted HTTP, or over
  HTTPS with certificate verification switched off — while still attaching the
  `Authorization` header (WordPress application password, or Joomla Bearer token) that it
  sends with every request. Anyone positioned on that network path could read the
  credentials, or present any certificate at all and be believed.
- **Now the decision is made on the host, not on the text.** The URL is parsed and its
  normalised hostname compared against an exact set (`localhost`, `127.0.0.1`, `::1`).
  Both the WordPress and the Joomla transport ask the same single function whether to
  verify TLS, so the rule enforced when a site is loaded and the rule enforced when a
  request goes out can no longer drift apart. URLs that carry credentials in the userinfo
  part, that have no host, or that use a scheme other than `http`/`https` are rejected
  outright.
- **A configuration error quoted your password back at you.** Pydantic renders the
  offending input value into its validation message; the config loader interpolated that
  message into its own error, and the MCP layer rendered it into the tool reply. So
  mistyping a field name — `wp_app_passwordd` for `wp_app_password`, say — made the
  *value* of that field part of the answer returned by `yootheme_list_sites`, a tool whose
  contract is that it never returns secrets. Validation errors now report only which field
  failed and why; the value is dropped. The two configuration models additionally set
  pydantic's own `hide_input_in_errors`, so a future validator cannot reintroduce the leak
  by accident.
- New offline regression suites `tests/test_loopback_transport.py` (it inspects the SSL
  context the HTTP client actually built, for both adapters, without opening a socket) and
  `tests/test_config_error_redaction.py` (a canary value, with a negative control that
  proves the leak is real when the fix is removed). Suite: 126 tests, all passing.

**Coming from a 0.9.x release?** Two earlier changes matter more than this one. **0.9.2**
fixed a fresh install that could not start the MCP server at all: the `mcp` dependency had
no upper bound, so a new `pip install` resolved a 2.x release that no longer contains the
module the server imports, and `python -m yootheme_mcp` — the exact command in the Claude
Desktop configuration — died on import while the `yootheme` CLI kept working. **0.10.0**
gave every write path a backup: a YOOtheme layout is versioned by neither WordPress nor
Joomla, so before that release a `set`, `import`, `duplicate` or find-replace through this
client was final. Each write now snapshots what it replaces and returns the path as
`backup_file`; re-importing that file is the undo. Install as usual — unpack the zip over a
fresh directory and run `pip install -e .` in it (full steps in `INSTALL.md`), then confirm
with `yootheme --version`.

### v0.10.0 — A half-failed write now leaves something to go back to
- **An overwrite was final, and the tool said otherwise.** `yootheme_set_layout` told the
  operator "on WordPress this is destructive but WP revisions usually allow recovery". It is
  not true: a YOOtheme layout lives in post meta on WordPress, and WordPress does not carry
  post meta into revisions; on Joomla it lives in an article's `fulltext` or a module's
  `content`, which have no builder versioning either. So *neither* platform could undo a
  `set` / `import` / `duplicate` / find-replace done through this client — the one product of
  the three (yootheme-mcp, elementor-mcp, gmcbuilder-mcp) with no backup and no restore.
  **Every write path now snapshots the layout it is about to replace to disk first** and
  returns the path as `backup_file`; feeding that file to `yootheme_import_layout` is the
  undo. Default `~/.yootheme-mcp/backups`, override with `YOOTHEME_BACKUP_DIR`.
- **A backup that cannot be taken now refuses the write** instead of proceeding quietly — a
  safety net nobody can tell is missing is worse than none. Set `YOOTHEME_BACKUP=0` to accept
  the loss deliberately. Likewise a *read* that fails for any reason other than "there is no
  layout here" blocks the write: a site that cannot be read reliably must not be overwritten
  blind.
- **A site-wide replace that died half way threw away the list of what it had already
  changed.** `yootheme_bulk_find_replace` writes one object at a time and is not atomic; when
  the paging call timed out at object 51, the 50 layouts already rewritten on the live site
  were reported as a single line reading `Error: Request timed out`. The per-object report is
  now always returned, with `aborted`, `aborted_error`, `objects_committed`, and a
  `backup_file` per committed object.
- **`yootheme bulk-replace --apply` asks before committing** (`--yes` to skip). It rewrites
  every matching layout on a whole site and had no prompt at all. `yootheme presets import`
  asks too — it overwrites any preset of the same name.
- New regression suite `tests/test_failure_recovery.py` (14 tests) drives the destructive
  paths against a transport double that fails exactly where told: mid-page timeouts, refused
  writes, an unwritable backup directory.

### v0.9.9 — The shared install guide names the twin's real versions
- **`INSTALL.md` (the guide mirrored with elementor-mcp) still described elementor-mcp
  `0.5.0`**: it pointed at the companion zip `gmc-elementor-mcp-0.5.0.zip` under the vendor's
  dev path `D:\elementor-mcp\...`, promised a ping answer of `plugin_version 0.5.0`, and the
  versions table was three elementor releases behind. A reader following it would fetch a
  superseded companion from a folder that only exists on the vendor's machine and then judge a
  correct install broken when ping answered a different number. Re-synced to the elementor-mcp
  0.5.6 mirror (the twin fixed its copy in its own 0.5.6 release): the companion is
  `gmc-elementor-mcp-0.5.1.zip` shipped inside the elementor package, ping answers `0.5.1`,
  and the versions note records that client (`0.5.6`) and companion (`0.5.1`) move
  independently. Docs-only — no code changes.

### v0.9.8 — The delivery zip tells the truth about itself
- **The 0.9.7 zip shipped a README that said 0.9.6** (no 0.9.7 changelog either): the zip
  was built from the tree *before* the release docs were committed. Docs are now aligned and
  the zip is rebuilt from the committed tree, so the package a customer opens names the
  version it actually is.
- **Install docs told you to run `INSTALL.ps1` — a file the zip deliberately does not
  contain** (it is the vendor's personal installer, excluded by the packaging allowlist since
  0.8.3). README / INSTALL.md / PRODUCTS.md now give the real steps: `pip install -e .` plus
  the shipped `extras/INSTALL_WINDOWS.md` guide.

### v0.9.7 — Windows APPDATA paths + MEDIUMTEXT ops
- Licence / default `sites.json` on Windows use `%APPDATA%\yootheme-mcp\` (aligned with docs).
- `scripts/alter_mediumtext.sql` shipped for sites still on TEXT 64KB; local jtest54 modules raised to MEDIUMTEXT.

### v0.9.6 — Unified MCP/CLI/Skill package + soft licence check

- One storefront SKU / one zip: MCP + CLI + agent Skill (skill/).
- Soft licence check against GMC Licenses (api.entitlement); key in %APPDATA%/yootheme-mcp/license.key or YOOTHEME_MCP_LICENSE_KEY.
- MCP Joomla ops parity: article CRUD, media, menus, templates (clear error on WordPress).
- Layout schemas document Joomla `module` (production path).
- List price **EUR 39** one-time (was 29). Existing keys stay valid.

### v0.9.5 — ArticleHelper-faithful article layouts + Pro 5.0.37

- Article `has_layout` / list use the leading `<!-- {json} -->` fulltext comment only
  (attribs mirror is write-only BC — no more false positives after body wipe).
- Serialize forces single-line compact JSON; extract matches `ArticleHelper::PATTERN`
  (start-anchored, no DOTALL).
- List requests `text` + `fulltext` for articles; clearer "no layout" errors.
- WordPress adapter: `verify=False` on localhost (parity with Joomla).
- Docs target bumped to **YOOtheme Pro 5.0.37**.

### v0.9.4 — Reliable failures, bulk operations and Joomla module layouts

- **Failures are no longer reported as success.** Joomla discovery now raises
  when every endpoint fails, WordPress settings writes verify that a real
  YOOtheme option changed, SSH list failures surface, and preset deletion reports
  whether anything was actually removed.
- **Bulk operations preserve a truthful report.** Read and write failures are
  returned per object, partial progress is retained, pagination terminates
  correctly, and the SSH backend can scan beyond its first page.
- **Joomla builder modules now use their real storage.** Reads and writes go
  through the module `content` field used by `mod_yootheme_builder`; the old
  `params.yootheme_pro` value remains only as a compatibility mirror.
- **CLI and settings correctness fixes.** Bracket-indexed settings paths can be
  written, `--http --port` honors the requested port, and multi-site `bulk`
  inserts the site argument in the right position.
- **Safer operator messaging.** Recursive media-folder deletion is described as
  permanent, commands that intentionally skip confirmation are documented, and
  the customer ZIP no longer refers to files it does not ship.

### v0.9.3 — The install example printed the old version too

- **The Install section showed `yootheme --version` returning `0.8.2`.** 0.9.2
  fixed the version the command prints but not the one the documentation
  promises, so the first thing a reader checks after installing still showed the
  superseded number — the same defect, one layer over. The example no longer
  pins a version literal at all, and a test refuses one, because a number
  restated in prose is a number that drifts.

### v0.9.2 — A fresh install starts the MCP server again

Packaging, plus four more destructive-path defects — two of them introduced by
the fixes in 0.9.0 and 0.9.1.

- **`article update --text` destroyed a builder layout.** The mirror image of
  what 0.9.0 fixed in the other direction: the read-more split sends
  `fulltext: ""` whenever the new body carries no marker, and `fulltext` is
  where an article's layout lives. The page reverted to plain text with no copy
  anywhere, while `list`/`get_layout` kept reporting a layout because they fall
  back to `attribs`. Now refused unless `replace_layout` is passed.
- **A failed trash was reported as trashed.** `article delete` tolerates
  400/409/422 because an already-trashed article answers that way — but on the
  trash-only path it then returned `{"trashed": true}` after a PATCH that had
  failed, so the article sat live and untouched while the operator was told it
  was in the trash. Only the destroy path may treat that as harmless.
- **`set --replace-body` discarded the body it promised to hand back.** It was
  returned "in the JSON output and nowhere else", and the default human mode has
  no JSON output at all. The replaced body is now written to a file beside the
  layout — or printed when there is nowhere to put it — in every mode, and
  `duplicate` surfaces it too.
- **The scripts-removal guard had a type-shaped hole.** It only ran when the
  value was a Python list, so a JSON-encoded string or a single descriptor dict
  skipped the check and replaced the whole list while reporting `wrote: true`.
  The value is normalised before the diff; anything that cannot be a scripts
  list is refused rather than written.
- **`mcp[cli]` had no upper bound, and mcp 2.x removed `mcp.server.fastmcp`.**
  A virtualenv created today resolved mcp 2.0.0, so `python -m yootheme_mcp` —
  the command in the Claude Desktop configuration — died at import with
  `ModuleNotFoundError`. The terminal CLI kept working, because it never
  imports the server: the failure was invisible from the command line and total
  in Claude Desktop. The requirement is now `>=1.2.0,<2`.
- **`yootheme --version` reported 0.8.2.** `__version__` had stayed behind while
  the package moved to 0.8.3, 0.9.0 and 0.9.1, so the CLI named the version the
  customer had just replaced. Both are now covered by
  `tests/test_packaging_contract.py`, which asserts the declared range and the
  version string rather than the local environment — the environment is what hid
  the first defect.

### v0.9.1 — A scripts write can no longer delete what it omits

- **`yootheme_set_custom_code(kind='scripts')` replaced the entire list**, so any
  script or External-Services entry the caller left out was deleted from a live
  site. The docstring said so; nothing enforced it, which makes it a trap rather
  than a warning — and an agent composing a list from memory drops entries very
  easily. The write is now refused when it would remove existing entries, naming
  them in `would_remove`; `allow_removals: true` accepts the loss and hands the
  removed descriptors back as `removed`. Adding entries needs no flag.

### v0.9.0 — Destructive commands stop lying

Three ways this tool could destroy work while reporting success. Each one is
covered by a regression test in `tests/test_destructive_commands.py`.

- **`article delete` destroyed the article while promising the trash.** Its
  confirmation read "Joomla trashes it first, second delete is permanent", but
  the implementation ran the trash *and* the permanent delete in that one
  confirmed command. The operator was told the article was still recoverable; it
  was already gone. **Delete now trashes** — recoverable from Joomla's trash —
  and destroying needs `--permanent`, whose prompt says exactly that.
- **`set --type article` blanked the article body.** A Joomla article's builder
  layout has to be the entire body (an HTML comment at the start of `fulltext`,
  with `introtext` empty), so writing a layout onto an article that had text
  replaced that text, silently, with no version to restore from. Writing a
  layout over a body is now **refused**; `--replace-body` / `replace_body: true`
  accepts the loss and returns the previous body as `replaced_body`. Editing an
  article that already has a layout is unaffected — no flag needed.
- **Every write through the SSH backend failed.** `_wp_stdin` passed a literal
  `-` as the value, on the belief that WP-CLI reads stdin when it sees a dash.
  It doesn't: `wp post meta update <id> <key> [<value>]` and `wp option update
  <key> [<value>]` read stdin when the value is **omitted**, so the dash was
  stored as the value — and under `--format=json` a bare `-` is invalid JSON, so
  the command errored. The command line is now built in one place, so a second
  copy cannot drift away from the first again.
- Also: the SSH runner waited for the remote exit status before draining stdout,
  which deadlocks whenever output exceeds the channel window — `wp option get
  yootheme` on a real site is comfortably that big.

### v0.8.2 — Settings and Joomla article updates
- **Fix: Settings convenience accessors used wrong YOOtheme Pro 5.x keys.**
  Verified against live Joomla 5.x sites and the yootheme.com Settings docs, the
  real config keys are `consent` (Cookie Consent Manager), `scripts` (custom
  JavaScript, a list of descriptors) and `custom_less` (Settings → CSS). The
  tool previously used `cookies`, `head_scripts`/`body_scripts` and `custom_css`
  — keys that don't exist in the config, so `yootheme_get/set_cookie_consent`
  and the `head`/`body`/`css` custom-code shortcuts were silent no-ops
  (`get` returned `{}`, `set` wrote dead keys). Now: cookie-consent reads/writes
  `consent` (with a `cookies` read-fallback); the custom-code `kind` is
  `css`/`less` → `custom_less` and `scripts` → the JS list. The generic
  `settings get/set --key <path>` was already correct (it operates on raw keys);
  only the docs/help examples were updated to match. Added a **Customizer
  coverage map** to the feature docs cross-referencing every customizer area
  against the yootheme.com documentation.
- **Fix: Joomla `article update` body edits now persist.** The update path sent
  the new body under `articletext`, but Joomla's JSON Web Services API ignores
  that key on PATCH (it's a form-layer alias) — the request returned HTTP 200 and
  bumped `modified`/`version` while leaving the stored body unchanged. The update
  path now remaps `articletext` → `introtext` (the writable model field). CREATE
  is unaffected (it still accepts `articletext`).

### v0.8.0 — Packaging & SSH adapter
- New **WordPress SSH adapter** (`wordpress_ssh.py`): routes WordPress sites with
  `ssh_host` configured through wp-cli over SSH instead of the REST API.
- Project packaged as a distributable wheel + sdist (`python -m build`).
- Canonical version is now declared once in `pyproject.toml` and re-exported as
  `yootheme_mcp.__version__`.

### v0.4.0 — CLI-first
- New **`yootheme` CLI** as the primary entry point.
- Removed `status=any` from the default page listing (was triggering HTTP 400
  on sites without elevated REST permissions).
- MCP server still ships under `yootheme-mcp` for Claude Desktop integration.
- README rewritten around CLI usage; MU-plugin downgraded from required to
  optional workaround.

### v0.3.0 — Real-world hardening
- WordPress: auto-detect of pretty vs Plain permalinks (auto-fallback to `?rest_route=`).
- Joomla: new `object_type='module'` alongside `'article'`.
- `extras/` folder with `mu-yootheme-rest.php`, Windows config samples and install guide.

### v0.2.0 — YOOtheme Pro 5.0.34 coverage
- Tools for Settings panel, Cookie Consent Manager, Element Presets, scroll animations inspector.

### v0.1.0
- Layouts, Customizer, bulk find/replace, WP + Joomla adapters.

## License

Commercial, sold per licence through GMC Licenses and MCP Marketplace — see
`LICENSE`. The read-only features may be used without a key (see
[Licensing](#licensing)). Not open-source: you may use and modify it for your
own sites, but not redistribute or resell it. The bundled WordPress companion
`extras/mu-yootheme-rest.php` is GPL-2.0-or-later. Third-party Python
dependencies keep their own licences.
