Skip to content

feat: install-theme installs from WordPress.org, not activated (#350) - #356

Merged
ifahimreza merged 5 commits into
mainfrom
feat/350-install-theme
Oct 12, 2026
Merged

ifahimreza merged 5 commits into
mainfrom
feat/350-install-theme

Conversation

@ifahimreza

@ifahimreza ifahimreza commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Closes #350

Stacked on #355 (base feat/349-install-plugin). Merge #353, #354, #355, then this, with merge commits.

What

A new tool, saddle/install-theme. It installs one theme from the WordPress.org directory, by slug, through WordPress's own installer in the background, after a confirmed preview. The theme is never activated; activate-theme stays its own step.

Why

The second half of #347's installs, with the same hard line as install-plugin.

How

  • Same shape as install-plugin:
    • a fresh themes_api() answer
    • a preview: version, installs, rating, last update, the WordPress and PHP it needs, and its parent theme if it is a child
    • a token bound to the version
    • a background run through Theme_Upgrader::install()
    • capabilities: switch_themes to call the tool, install_themes checked in the handler after any blocked_by() reason
    • the same refusals, including a package outside downloads.wordpress.org/theme/
  • Child themes. Core's theme installer fetches and installs a missing parent on its own (Theme_Upgrader::check_parent_theme_filter()), which nobody previewed. Three layers stop that:
    1. A child theme whose parent is not installed is refused before any token, naming the parent to install first.
    2. The run checks again, including that the parent's folder still exists. WP_Theme can answer from the object cache for up to 30 minutes after a delete; a test caught this.
    3. During the run, a one-shot themes_api filter answers only the confirmed slug. A test proves it: with the guard removed, the test fails because core asks WordPress.org for the parent.
  • Restructure. Saddle_Directory now only talks to the API (242 lines). The installed check and the refusals moved to Saddle_Install_Runner (253 lines), where both the run and the abilities reach them, as blocked_by() sits on the update runner. install_plugin and install_theme share one install( $type, $input ) (the class is 289 lines).
  • A fix found end to end: themes_api() was undefined in a real REST request. The update runner loads the admin theme.php keyed on wp_get_themes(), which wp-includes always defines. The suite loads admin files up front, which hid it. Saddle_Directory::load_api() now keys on themes_api. The update path is unchanged.
  • Copy. The context line, the undo reason and the readme now say plugins and themes. update-theme's description, External services item 7, and a 1.5.2 changelog line. The .pot is regenerated.

Testing

  • phpunit on PHP 8.2: 1490 tests, 8342 assertions, 1 skipped (the existing 11-plugin test).
  • phpcs on every changed PHP file: clean.
  • tests/installs-test.php: 10 theme tests, with fixtures recorded from the live themes API (Twenty Twenty-Five, and the real child theme legacy-kindergarten-child, which pins that WordPress.org names the parent in template and parent):
    • the tier test, and a preview that changes nothing
    • a confirm with zero upgrader calls, and single-use tokens
    • the version bind
    • a real install through Theme_Upgrader that never switches the theme
    • the child-theme refusal, the parent removed before the run, and the guard
    • the remaining refusals and the capability check
  • Real install, end to end (Playground CLI, built wporg zip, WordPress 7.1.3, PHP 8.2, Saddle key at Manage the site, small MCP client):
    • install-theme legacy-kindergarten-child was refused, naming its missing parent.
    • install-theme twentytwentytwo: preview, then confirm, then a page load ran cron. The result was installed, and list-themes shows Twenty Twenty-Two 2.2 with active: false while Twenty Twenty-Five stays active.
    • The plugin path was rerun on this build: classic-editor installed, inactive.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

ifahimreza and others added 5 commits October 12, 2026 04:37
Rewrite the hard line before any code: Saddle installs a plugin or
theme only from the WordPress.org directory, by slug, through
WordPress's own installer, and the new install arrives inactive. Core
updates are minor and security releases, plus a major one only behind
WordPress's own switch for all new versions. Translations go through
core's language pack upgrader. Nothing deletes a plugin or theme. Adds
the two "What NOT to do" bullets and the ROADMAP entry.

Refs #347

Co-Authored-By: Claude Opus 5.5 <[email protected]>
New admin-tier, read-only tool. It asks the WordPress.org directory
through core's own plugins_api(), the same request Plugins > Add New
makes, by search words or one exact slug, at most 10 results. Each row
has the slug, name, author, version, active installs, rating, last
update, tested up to, the WordPress and PHP it needs, and whether it is
installed and active here, by core's install_plugin_install_status().
The directory answer is cached for an hour; the installed state never
is. Tests answer the directory from responses recorded from the live
API, which pins the field names.

Refs #348

Co-Authored-By: Claude Opus 5.5 <[email protected]>
New admin-tier, gated tool. One slug per call. The preview asks the
directory fresh and shows the version, installs, rating, last update,
tested-up-to against this WordPress and the PHP it needs, with warnings
for untested, two years without an update, or plugins it needs. The
token binds the version. Refused before any token: a site that forbids
file changes, an account without install_plugins, an unknown or closed
slug, an installed plugin or a leftover folder, PHP or WordPress too
old, and a package outside downloads.wordpress.org.

A confirm queues the install on the existing runner; the request never
calls the installer (pinned, and the update-plugin test now counts
upgrader calls too). Saddle_Install_Runner asks the directory again,
installs only the confirmed version through Plugin_Upgrader::install(),
and the plugin arrives inactive. Saddle_Directory holds the lookups and
refusals both sides need. The installed check reads core's rule
without install_plugin_install_status(), which forces an update check.

Refs #349

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ated

Same shape as install-plugin: one slug, a fresh directory answer
through themes_api(), a preview with the version, installs, rating,
last update and the WordPress and PHP it needs, a token bound to the
version, a background run through Theme_Upgrader::install(). The theme
is never activated. The permission capability is switch_themes and the
handler checks install_themes after naming any blocked_by() reason.

Core's theme installer fetches a missing parent theme on its own. A
child theme whose parent is not installed is refused before a token,
the run checks again (the folder too, since WP_Theme can answer from
the object cache after a delete), and during the run the directory
answers only the confirmed slug.

Saddle_Directory now only talks to the API. The installed check and
the refusals moved to Saddle_Install_Runner, where the run and the
abilities both reach them, as blocked_by() sits on the update runner.

Refs #350

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Found end to end on Playground: install-theme over MCP failed with
"Call to undefined function themes_api()". The update runner's loader
keys the admin theme.php on wp_get_themes(), which wp-includes always
defines, so a REST request never loaded it. The test suite loads the
admin files up front, which hid it.

Refs #350

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@ifahimreza
ifahimreza changed the base branch from feat/349-install-plugin to main October 12, 2026 10:03
@ifahimreza ifahimreza linked an issue Oct 12, 2026 that may be closed by this pull request
@ifahimreza
ifahimreza merged commit c36f874 into main Oct 12, 2026
8 checks passed
@ifahimreza
ifahimreza deleted the feat/350-install-theme branch October 12, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

install-theme: install a theme from WordPress.org, by slug

1 participant