diff --git a/apps/cli/ai/inspector/page-script.ts b/apps/cli/ai/inspector/page-script.ts index e6c709f1ee..6aaa8d96e1 100644 --- a/apps/cli/ai/inspector/page-script.ts +++ b/apps/cli/ai/inspector/page-script.ts @@ -45,7 +45,7 @@ export const INSPECTOR_PAGE_SCRIPT = /* -------------------------------------------------------------------- * Element identification — build a CSS selector that's specific enough - * for the agent to find the element again via wp_cli/theme files. + * for the agent to find the element again via WP-CLI or theme files. * ------------------------------------------------------------------ */ function buildSelector( el ) { if ( ! el || el.nodeType !== 1 ) return ''; diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 8e4b80cb5c..fe552b64db 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import path from 'path'; import { type AgentTool } from '@earendil-works/pi-agent-core'; import { type Credential, @@ -50,6 +51,11 @@ import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths'; import { getFileToolPrompt } from './file-tool-prompts'; import { getPendingWork, type PendingWork } from './pending-work'; import { stripStaleImagesFromContext } from './strip-stale-images'; +import { + ensureStudioWpShimDirectory, + getStudioWpShimScript, + prependToPath, +} from './studio-wp-shim'; import { getIncompleteToolCallReason, getPayloadLimitViolation, @@ -603,6 +609,15 @@ function toToolDefinition( }; } +// Every chunk of the CLI bundle sits next to `main.mjs`, so it is this +// module's sibling; `STUDIO_CLI_BIN` points elsewhere in development. +function getStudioWpSpawnHook() { + const cliPath = process.env.STUDIO_CLI_BIN ?? path.join( import.meta.dirname, 'main.mjs' ); + return prependToPath( + ensureStudioWpShimDirectory( getStudioWpShimScript( process.execPath, cliPath ) ) + ); +} + function buildAgentTools( config: ResolvedStudioAgentTurnConfig, chatArtifactsEnabled: boolean, @@ -637,13 +652,17 @@ function buildAgentTools( renameTool( createReadTool( STUDIO_SITES_ROOT ), 'Read' ), designSystemPreview( renameTool( createWriteTool( STUDIO_SITES_ROOT ), 'Write' ) ), designSystemPreview( renameTool( createEditTool( STUDIO_SITES_ROOT ), 'Edit' ) ), - renameTool( createBashTool( STUDIO_SITES_ROOT ), 'Bash' ), + renameTool( + createBashTool( STUDIO_SITES_ROOT, { spawnHook: getStudioWpSpawnHook() } ), + 'Bash' + ), renameTool( createGrepTool( STUDIO_SITES_ROOT ), 'Grep' ), renameTool( createFindTool( STUDIO_SITES_ROOT ), 'Glob' ), renameTool( createLsTool( STUDIO_SITES_ROOT ), 'Ls' ), ]; const studioTools = resolveStudioToolDefinitions( { emitChatArtifacts: chatArtifactsEnabled, + shellRunsStudioWp: true, imageGeneration: imageGenerationEnabled, visionEnabled, canAskUser: Boolean( config.onAskUser ), diff --git a/apps/cli/ai/runtimes/pi/studio-wp-shim.ts b/apps/cli/ai/runtimes/pi/studio-wp-shim.ts new file mode 100644 index 0000000000..1f8d71c5e3 --- /dev/null +++ b/apps/cli/ai/runtimes/pi/studio-wp-shim.ts @@ -0,0 +1,51 @@ +import { createHash } from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { getConfigDirectory } from '@studio/common/lib/well-known-paths'; +import type { BashSpawnHook } from '@earendil-works/pi-coding-agent'; + +function shellQuote( value: string ): string { + return `'${ value.replace( /'/g, `'\\''` ) }'`; +} + +// `wp` in the agent's shell runs `studio wp`: WP-CLI on Studio's PHP binary for the site, +// with Studio's SQLite integration loaded. A system `wp`, where one is installed at all, +// runs on whatever PHP the machine has and cannot open a Studio site's database. +export function getStudioWpShimScript( nodePath: string, cliPath: string ): string { + return `#!/bin/sh\nexec ${ shellQuote( nodePath ) } ${ shellQuote( cliPath ) } wp "$@"\n`; +} + +// Writes the shim into a directory named after its content, so concurrent Studio +// processes (or different installs) never overwrite each other's script. +export function ensureStudioWpShimDirectory( + script: string, + root: string = path.join( getConfigDirectory(), 'tmp', 'agent-bin' ) +): string { + const directory = path.join( + root, + createHash( 'sha256' ).update( script ).digest( 'hex' ).slice( 0, 12 ) + ); + const shimPath = path.join( directory, 'wp' ); + if ( ! fs.existsSync( shimPath ) ) { + fs.mkdirSync( directory, { recursive: true } ); + const temporaryPath = `${ shimPath }.${ process.pid }`; + fs.writeFileSync( temporaryPath, script, { mode: 0o755 } ); + fs.renameSync( temporaryPath, shimPath ); + } + return directory; +} + +export function prependToPath( directory: string ): BashSpawnHook { + return ( context ) => { + const pathKey = + Object.keys( context.env ).find( ( key ) => key.toUpperCase() === 'PATH' ) ?? 'PATH'; + const current = context.env[ pathKey ]; + return { + ...context, + env: { + ...context.env, + [ pathKey ]: current ? `${ directory }${ path.delimiter }${ current }` : directory, + }, + }; + }; +} diff --git a/apps/cli/ai/skills/annotate/SKILL.md b/apps/cli/ai/skills/annotate/SKILL.md index 89d1375013..77e05177aa 100644 --- a/apps/cli/ai/skills/annotate/SKILL.md +++ b/apps/cli/ai/skills/annotate/SKILL.md @@ -49,8 +49,8 @@ If the user wants to re-annotate, point them back to the open browser and call ` For each annotation: 1. **Identify what to change:** - Use the CSS selector to find the element in theme templates or stylesheets - - Use `wp_cli` with `post list --post_type=wp_template --format=json` to check if it's in a template override - - Use `wp_cli` with `eval "echo wp_get_custom_css();"` to check existing custom CSS + - Use `wp post list --post_type=wp_template --format=json` to check if it's in a template override + - Use `wp eval "echo wp_get_custom_css();"` to check existing custom CSS 2. **Apply the change using the right approach:** - For style changes (colors, sizes, spacing): use Global Styles custom CSS with the selector from the annotation - For content changes (text, headings, block structure): edit the template or post content via WP-CLI diff --git a/apps/cli/ai/skills/block-content/SKILL.md b/apps/cli/ai/skills/block-content/SKILL.md index 4507f69167..0ad11d768d 100644 --- a/apps/cli/ai/skills/block-content/SKILL.md +++ b/apps/cli/ai/skills/block-content/SKILL.md @@ -93,7 +93,7 @@ Pick one of the two per page: - **Designed page** whose hero carries its own heading (home, landing, anything built from sections): assign the no-title template. Themes from `scaffold_theme` ship `templates/page-no-title.html`, registered in `theme.json` under `customTemplates`. Assign it to the page after creating it: ```text -wp_cli post meta update _wp_page_template page-no-title +wp post meta update _wp_page_template page-no-title ``` Do **not** solve this by deleting `core/post-title` from `page.html` — every ordinary page then renders untitled, and a missing `h1` is worse than a duplicated one. In a theme with no no-title template, add one (a copy of `page.html` without the title group) and register it in `customTemplates` rather than stripping the title. @@ -146,7 +146,7 @@ For long page content: 1. Create the page empty: ```text -wp_cli post create --post_content="" +wp post create --post_content="" ``` 2. Write `/tmp/page-.html`, not a file inside the theme, with small section anchors: @@ -158,16 +158,16 @@ wp_cli post create --post_content="" ``` 3. Fill the anchors with editable blocks, several per `Edit` call: one `edits[]` entry per section, under ~8KB of new text per call. Never wrap a section in `core/html`. -4. **Validate before applying (mandatory gate).** Once all anchors are filled, you MUST call `validate_blocks` with `filePath` pointing at `/tmp/page-.html` and get a passing result — the core/html policy passes and editor validation reports all blocks valid. This is not optional and not a step you can defer to after `wp_cli eval`: the scratch file is block content, so it must be validated as a file while it still lives in a file. If validation reports invalid blocks, fix them in the file and call `validate_blocks` again until it passes. Never apply block content you have not validated. +4. **Validate before applying (mandatory gate).** Once all anchors are filled, you MUST call `validate_blocks` with `filePath` pointing at `/tmp/page-.html` and get a passing result — the core/html policy passes and editor validation reports all blocks valid. This is not optional and not a step you can defer to after `wp eval`: the scratch file is block content, so it must be validated as a file while it still lives in a file. If validation reports invalid blocks, fix them in the file and call `validate_blocks` again until it passes. Never apply block content you have not validated. 5. Apply the validated content once: ```text -wp_cli eval '$content = file_get_contents(ABSPATH . "tmp/page-.html"); wp_update_post(["ID" => , "post_content" => $content]); echo "ok";' +wp eval '$content = file_get_contents(ABSPATH . "tmp/page-.html"); wp_update_post(["ID" => , "post_content" => $content]); echo "ok";' ``` Do not use `--post_content-file=`: it is not a WP-CLI option, so WP-CLI never reads the file and the post content is not updated. ## Validation -- Validation is a mandatory gate, not a cleanup step. You MUST call `validate_blocks` and get a passing result for any block content you generate **before** that content reaches the live site — before `wp_cli post create/update`, before `wp_cli eval`, and before importing a scratch file. Never apply, import, or save block content you have not validated. A build that skips validation is incomplete, even if the page renders. -- Run `validate_blocks` after every write or edit that creates or changes block content. Call it with `filePath` whenever the content lives in a file — including scratch files such as `/tmp/page-.html` that you later import with `wp_cli eval`. The scratch file is the block content; validate the file, not just the eventual post. Theme `templates/*.html` and `parts/*.html` files count too — they are live as soon as they are written, so validate each one right after every write or edit. It first runs a static `core/html` policy check: if that reports invalid `core/html` blocks, editor validation is skipped — rewrite only those blocks as editable core or plugin blocks, then call `validate_blocks` again. Once the policy passes it validates in the live editor and applies safe serialization fixes directly to the file. If it says an auto-fix was applied, do not manually replace markup or call validation again unless you intentionally change block markup afterward. Use the diff only to inspect structural changes for CSS impact. Classes added or removed by the validator can affect layout and styling. +- Validation is a mandatory gate, not a cleanup step. You MUST call `validate_blocks` and get a passing result for any block content you generate **before** that content reaches the live site — before `wp post create/update`, before `wp eval`, and before importing a scratch file. Never apply, import, or save block content you have not validated. A build that skips validation is incomplete, even if the page renders. +- Run `validate_blocks` after every write or edit that creates or changes block content. Call it with `filePath` whenever the content lives in a file — including scratch files such as `/tmp/page-.html` that you later import with `wp eval`. The scratch file is the block content; validate the file, not just the eventual post. Theme `templates/*.html` and `parts/*.html` files count too — they are live as soon as they are written, so validate each one right after every write or edit. It first runs a static `core/html` policy check: if that reports invalid `core/html` blocks, editor validation is skipped — rewrite only those blocks as editable core or plugin blocks, then call `validate_blocks` again. Once the policy passes it validates in the live editor and applies safe serialization fixes directly to the file. If it says an auto-fix was applied, do not manually replace markup or call validation again unless you intentionally change block markup afterward. Use the diff only to inspect structural changes for CSS impact. Classes added or removed by the validator can affect layout and styling. diff --git a/apps/cli/ai/skills/build-site/SKILL.md b/apps/cli/ai/skills/build-site/SKILL.md index 8e21f60062..5a27863b86 100644 --- a/apps/cli/ai/skills/build-site/SKILL.md +++ b/apps/cli/ai/skills/build-site/SKILL.md @@ -61,7 +61,7 @@ With the Site Spec stated, start small: call `scaffold_theme` on its own, and ne ## 4. Provision the site -Use wp_cli to activate the theme, install and activate the plugins the Site Spec lists, and set options. Do this before validating — the live editor only recognizes the active theme and registered plugin blocks. The site must be running. +Use WP-CLI (`wp`) to activate the theme, install and activate the plugins the Site Spec lists, and set options. Do this before validating — the live editor only recognizes the active theme and registered plugin blocks. The site must be running. ## 5. Validate and publish the content diff --git a/apps/cli/ai/skills/imagery/SKILL.md b/apps/cli/ai/skills/imagery/SKILL.md index 4be3339f07..a98a3d8694 100644 --- a/apps/cli/ai/skills/imagery/SKILL.md +++ b/apps/cli/ai/skills/imagery/SKILL.md @@ -69,7 +69,7 @@ The look is picked on one image and the layout on a set, both from the `build-si When `generate_images` is not available but you have an image tool of your own (such as `image_gen`), make the same images with it, including the look image and the site's set below: plan them exactly as above, and write what you would pass as `siteContext`, `pageContext`, `style` and `imageGrade` into each image's prompt, with no text in the image. Then put each file where its destination says: -- **Content imagery**: copy the files into the site's `tmp/` folder and import them in one `wp_cli` call, `media import tmp/.png tmp/.png --porcelain`, which prints their attachment IDs in order; `post list --post_type=attachment --post__in= --fields=ID,guid` gives their URLs. +- **Content imagery**: copy the files into the site's `tmp/` folder and import them in one call, `wp media import tmp/.png tmp/.png --porcelain`, which prints their attachment IDs in order; `post list --post_type=attachment --post__in= --fields=ID,guid` gives their URLs. - **Theme imagery**: copy the file to its path in the theme's `assets/images`. For the look image, pass the file where your tool saved it as each look's `image`; import it only if the site uses it. diff --git a/apps/cli/ai/skills/need-for-speed/SKILL.md b/apps/cli/ai/skills/need-for-speed/SKILL.md index a38c6da100..c3fd489633 100644 --- a/apps/cli/ai/skills/need-for-speed/SKILL.md +++ b/apps/cli/ai/skills/need-for-speed/SKILL.md @@ -39,7 +39,7 @@ Run a performance audit on a WordPress site to measure Core Web Vitals and page Based on the metrics, suggest specific actions: -- **High TTFB**: Enable page caching (e.g., `wp_cli: plugin install wp-super-cache --activate`), check for slow database queries, consider object caching. +- **High TTFB**: Enable page caching (e.g., `wp plugin install wp-super-cache --activate`), check for slow database queries, consider object caching. - **High LCP / FCP**: Check for render-blocking CSS/JS, add lazy loading for below-the-fold images, defer non-critical scripts. - **Large JS payload**: Identify heavy plugins from the scripts URL list, suggest deactivating unused plugins, check for jQuery dependency chains. - **Large CSS payload**: Look for unused theme stylesheets, check for multiple Google Fonts loads. diff --git a/apps/cli/ai/skills/plugin-recommendations/SKILL.md b/apps/cli/ai/skills/plugin-recommendations/SKILL.md index bceee3d8de..23f52d2837 100644 --- a/apps/cli/ai/skills/plugin-recommendations/SKILL.md +++ b/apps/cli/ai/skills/plugin-recommendations/SKILL.md @@ -39,14 +39,14 @@ When a feature needs a plugin, reach for one of the preferred plugins below befo 1. List active plugins: ```text -wp_cli plugin list --status=active --format=json +wp plugin list --status=active --format=json ``` 2. If considering a plugin, check whether it is already installed or active. 3. Discover registered blocks: ```text -wp_cli eval 'foreach (\WP_Block_Type_Registry::get_instance()->get_all_registered() as $n => $b) echo $n . PHP_EOL;' +wp eval 'foreach (\WP_Block_Type_Registry::get_instance()->get_all_registered() as $n => $b) echo $n . PHP_EOL;' ``` 4. If you expect a plugin block but it is missing, check whether the plugin uses modules or feature flags, then activate the relevant module. @@ -64,21 +64,21 @@ This is not optional and is never downgraded by how the request is framed. Size, 1. Install and activate WooCommerce: ```text -wp_cli plugin install woocommerce --activate +wp plugin install woocommerce --activate ``` 2. Activation automatically creates the Shop, Cart, Checkout, and My Account pages. Suppress the setup-wizard redirect so the storefront is usable right away: ```text -wp_cli option delete _transient__wc_activation_redirect +wp option delete _transient__wc_activation_redirect ``` 3. Configure store basics. Match the currency and base location to the user's context when known, otherwise use sensible defaults. Also turn off "Coming soon" mode, which fresh WooCommerce installs enable by default: ```text -wp_cli option update woocommerce_coming_soon no -wp_cli option update woocommerce_currency USD -wp_cli option update woocommerce_default_country "US:CA" +wp option update woocommerce_coming_soon no +wp option update woocommerce_currency USD +wp option update woocommerce_default_country "US:CA" ``` 4. Add products that match what the shop actually sells. Create real, contextual products - coffee products for a coffee shop, books for a bookstore, plants for a plant store - rather than generic placeholders. Only fall back to generic sample products when the shop's niche is genuinely unknown. Populate the catalog first: create each product with its core details (name, type, price, description, category) right away so the shop is functional, then enrich them with images. Sourcing images is the slowest part - never let it block products from being created, and never skip product creation because images are not ready yet. @@ -88,20 +88,20 @@ Products should ideally have a real, relevant image, and a storefront with produ ```text # Without generate_images: download the image to the site's uploads with a real .jpg/.png/.webp name (use the Bash tool with the site path from site_info), then import it for its attachment ID: # curl -L "" -o "/wp-content/uploads/premium-dog-food.jpg" -wp_cli media import wp-content/uploads/premium-dog-food.jpg --porcelain -wp_cli wc product create --name="Premium Dog Food" --type=simple --regular_price=42 --status=publish --description="High-protein, grain-free kibble made with real chicken." --images='[{"id":}]' --categories='[{"id":N}]' --user=admin +wp media import wp-content/uploads/premium-dog-food.jpg --porcelain +wp wc product create --name="Premium Dog Food" --type=simple --regular_price=42 --status=publish --description="High-protein, grain-free kibble made with real chicken." --images='[{"id":}]' --categories='[{"id":N}]' --user=admin ``` Create several products (aim for 4-8) so the shop and catalog pages look populated. Group related products with a category by passing its term ID via `--categories='[{"id":N}]'` after creating the category: ```text -wp_cli wc product_cat create --name="Single Origin" --user=admin +wp wc product_cat create --name="Single Origin" --user=admin ``` 5. Discover the registered WooCommerce blocks so storefront and landing pages use editable plugin blocks rather than raw HTML: ```text -wp_cli eval 'foreach (\WP_Block_Type_Registry::get_instance()->get_all_registered() as $n => $b) if (strpos($n, "woocommerce/") === 0 && (!isset($b->supports["inserter"]) || $b->supports["inserter"] !== false)) echo $n . PHP_EOL;' +wp eval 'foreach (\WP_Block_Type_Registry::get_instance()->get_all_registered() as $n => $b) if (strpos($n, "woocommerce/") === 0 && (!isset($b->supports["inserter"]) || $b->supports["inserter"] !== false)) echo $n . PHP_EOL;' ``` Use blocks such as `woocommerce/product-collection`, `woocommerce/featured-product`, and `woocommerce/all-products` to surface the catalog. @@ -117,8 +117,8 @@ When the user asks for a contact form, feedback form, survey, or any other inter Install the plugin and activate the `contact-form` Jetpack module first if not already active. Both steps are required, otherwise the form blocks render as empty `
` elements on the frontend: ```text -wp_cli plugin install jetpack --activate -wp_cli jetpack module activate contact-form +wp plugin install jetpack --activate +wp jetpack module activate contact-form ``` Then build the form with blocks. Each field is a container block that holds a `jetpack/label` and a `jetpack/input` child. The submit button is a standard `core/button` (written as `wp:button` in block markup) placed directly inside the form container. @@ -162,7 +162,7 @@ For an email newsletter, a subscriber list, or a "subscribe to get new posts by Note that the `subscriptions` module does not work on a local Studio site: it relies on WordPress.com to manage subscribers and send emails, so `wp jetpack module activate subscriptions` fails locally and the Subscribe block cannot be fully enabled there. The feature works once the site runs on (or is connected to) WordPress.com. Install Jetpack so the product is in place and recommend Jetpack Newsletter as the right tool, but do not fall back to building your own signup form when the module is unavailable locally. ```text -wp_cli plugin install jetpack --activate +wp plugin install jetpack --activate ``` ## Sensei LMS (online courses and quizzes) @@ -170,12 +170,12 @@ wp_cli plugin install jetpack --activate When the user wants to teach, sell, or host online courses, lessons, or quizzes - an LMS, a course catalog, a "learning site", student progress, or certificates - use Sensei LMS. Do not hand-build course or lesson pages as ordinary posts. ```text -wp_cli plugin install sensei-lms --activate +wp plugin install sensei-lms --activate ``` Sensei works fully on a local site. Course delivery, lessons, quizzes, and student progress are free; *selling* courses needs WooCommerce (and, for some commerce features, the paid Sensei Pro) - set up WooCommerce alongside Sensei only when the user wants paid courses. -- Courses and lessons are custom post types (`course`, `lesson`). Create them with `wp_cli post create --post_type=course …` / `--post_type=lesson …`, or in the editor. Quizzes and questions (`quiz`, `question` post types) are managed through the Sensei quiz block inside a lesson — do not create them directly via `wp_cli post create`. +- Courses and lessons are custom post types (`course`, `lesson`). Create them with `wp post create --post_type=course …` / `--post_type=lesson …`, or in the editor. Quizzes and questions (`quiz`, `question` post types) are managed through the Sensei quiz block inside a lesson — do not create them directly via `wp post create`. - Build course and lesson pages from Sensei's registered blocks rather than plain markup. Discover them with the block-registry command from the Discovery Workflow (filter on `sensei-lms/`). Common ones: `sensei-lms/course-outline`, `sensei-lms/course-progress`, `sensei-lms/button-take-course`, `sensei-lms/lesson-actions`, `sensei-lms/quiz`, and `sensei-lms/learner-courses`. - Activating Sensei creates the Courses page and core Sensei pages automatically; wire a "Courses" link into the primary navigation. - Validate any block markup you generate with `validate_blocks`. @@ -185,7 +185,7 @@ Sensei works fully on a local site. Course delivery, lessons, quizzes, and stude For polls, surveys, ratings, NPS, feedback prompts, or applause/reactions, use Crowdsignal's blocks rather than raw HTML or a generic form. ```text -wp_cli plugin install crowdsignal-forms --activate +wp plugin install crowdsignal-forms --activate ``` Registered blocks: `crowdsignal-forms/poll`, `crowdsignal-forms/vote` (with `crowdsignal-forms/vote-item` children), `crowdsignal-forms/feedback`, `crowdsignal-forms/nps`, and `crowdsignal-forms/applause`. These register and stay editable on a local site, so use them in block markup and validate with `validate_blocks`. @@ -209,25 +209,25 @@ When it applies: 1. Make sure Jetpack is active: ```text -wp_cli plugin install jetpack --activate +wp plugin install jetpack --activate ``` 2. Discover candidate Jetpack blocks by listing what Jetpack has registered: ```text -wp_cli eval 'foreach (\WP_Block_Type_Registry::get_instance()->get_all_registered() as $n => $b) if (strpos($n, "jetpack/") === 0 && (!isset($b->supports["inserter"]) || $b->supports["inserter"] !== false)) echo $n . PHP_EOL;' +wp eval 'foreach (\WP_Block_Type_Registry::get_instance()->get_all_registered() as $n => $b) if (strpos($n, "jetpack/") === 0 && (!isset($b->supports["inserter"]) || $b->supports["inserter"] !== false)) echo $n . PHP_EOL;' ``` If the block you expect is not listed, the relevant Jetpack module is probably inactive. Run: ```text -wp_cli jetpack module list +wp jetpack module list ``` Then activate the needed module: ```text -wp_cli jetpack module activate +wp jetpack module activate ``` 3. Use the registered block in page markup and validate with `validate_blocks`, passing `filePath` when possible. diff --git a/apps/cli/ai/skills/rank-me-up/SKILL.md b/apps/cli/ai/skills/rank-me-up/SKILL.md index 6051597dd7..7ccb415465 100644 --- a/apps/cli/ai/skills/rank-me-up/SKILL.md +++ b/apps/cli/ai/skills/rank-me-up/SKILL.md @@ -24,7 +24,7 @@ The audit only sees rendered HTML — it can't tell *why* a tag is missing. Befo **Step 1 — list active plugins:** ``` -wp_cli: plugin list --status=active --field=name +wp plugin list --status=active --field=name ``` Look for known SEO providers: @@ -41,7 +41,7 @@ Look for known SEO providers: **Step 2 — if Jetpack is active, list its enabled modules** (Jetpack is modular; SEO features live in the `seo-tools` module and don't run unless that specific module is on): ``` -wp_cli: jetpack module list +wp jetpack module list ``` Then map the audit findings: @@ -49,7 +49,7 @@ Then map the audit findings: - **Jetpack active + `seo-tools` module active** → don't install anything. Tell the user the issue is in *configuration* (e.g. empty default title format, missing per-post meta description). Point them at **Jetpack → Settings → Traffic → SEO Tools** in `wp-admin`. - **Jetpack active + `seo-tools` module inactive** → enable the module instead of installing a new plugin: ``` - wp_cli: jetpack module activate seo-tools + wp jetpack module activate seo-tools ``` Then re-run `rank_me_up` to confirm the tags appear. - **Another SEO plugin active** → don't recommend installing Jetpack on top of it (two SEO plugins fight over the same `` tags). Surface the *settings page* for the active plugin instead — e.g. for Yoast: **SEO → Search Appearance**; for Rank Math: **Rank Math → Titles & Meta**. @@ -58,7 +58,7 @@ Then map the audit findings: **Also worth checking for non-plugin fixes:** ``` -wp_cli: option get blog_public +wp option get blog_public ``` If it returns `0`, the site has "Discourage search engines" enabled — no plugin can override that. Fix with `wp option update blog_public 1`. @@ -119,8 +119,8 @@ Missing OG tags don't hurt rankings directly but kill click-through from social **Install, activate, and enable the SEO module:** ``` -wp_cli: plugin install jetpack --activate -wp_cli: jetpack module activate seo-tools +wp plugin install jetpack --activate +wp jetpack module activate seo-tools ``` **What the `seo-tools` module covers:** @@ -141,7 +141,7 @@ Based on the metrics, suggest specific actions. Route each fix through whatever - **Missing title/description**: - *Jetpack active + `seo-tools` on*: user needs to *configure* title formats at **Jetpack → Settings → Traffic → SEO Tools** and fill meta descriptions per-post (paid plan required for per-post). - - *Jetpack active + `seo-tools` off*: `wp_cli: jetpack module activate seo-tools`. + - *Jetpack active + `seo-tools` off*: `wp jetpack module activate seo-tools`. - *Another SEO plugin active*: point to its title/meta settings page — don't install Jetpack on top. - *Nothing active*: install Jetpack (see "Recommended SEO Plugin" above). - *Plugins refused*: set the title via theme `wp_title()` and description via a `` tag in `header.php` / a block theme template part. @@ -154,7 +154,7 @@ Based on the metrics, suggest specific actions. Route each fix through whatever - *Another SEO plugin active*: enable its social-meta feature (Yoast: **SEO → Social**; Rank Math: **Rank Math → Titles & Meta → Social Meta**). - *Nothing active*: install Jetpack, or hook into `wp_head` to emit tags manually. - **No JSON-LD**: WordPress core does not emit JSON-LD. Jetpack adds basic `WebSite` / `SearchAction` markup when the `seo-tools` module is on. For richer schemas (Product, FAQ, Article, BreadcrumbList) use a dedicated schema plugin (e.g. Yoast/Rank Math handle these) or custom `wp_head` output. -- **`robotsTxtBlocksAll: true`**: Run `wp_cli: option update blog_public 1` to undo the "Discourage search engines" setting. No plugin can override this. +- **`robotsTxtBlocksAll: true`**: Run `wp option update blog_public 1` to undo the "Discourage search engines" setting. No plugin can override this. - **Missing sitemap**: Verify `blog_public` is `1` first. Then check `/wp-sitemap.xml` (WP core since 5.5) or `/sitemap.xml` (Jetpack). If Jetpack is active but the sitemap is missing, enable the `seo-tools` module. If another SEO plugin is active, it may have disabled core sitemaps in favor of its own — check its sitemap settings. - **Missing viewport meta**: Theme bug — add `` in the ``. Not a plugin concern. - **Missing `htmlLang`**: Theme should output `>` (block themes do this automatically; classic themes need it added). Not a plugin concern. diff --git a/apps/cli/ai/skills/taxonomist/SKILL.md b/apps/cli/ai/skills/taxonomist/SKILL.md index f984f3120c..902f12b846 100644 --- a/apps/cli/ai/skills/taxonomist/SKILL.md +++ b/apps/cli/ai/skills/taxonomist/SKILL.md @@ -66,7 +66,7 @@ All data files go in a `taxonomist-data/` directory inside the site root: 2. If multiple sites exist, ask the user which one to analyze 3. Use `site_info` to verify the site is running 4. If the site is stopped, start it with `site_start` -5. Verify WordPress is working: `wp_cli` with `eval 'echo "OK";'` +5. Verify WordPress is working: `wp eval 'echo "OK";'` ## Step 2: Export @@ -74,23 +74,19 @@ Create the working directory structure, then export posts and categories. ### Export categories -Use `wp_cli`: +Run: ``` -term list category --format=json --fields=term_id,name,slug,description,count,parent +wp term list category --format=json --fields=term_id,name,slug,description,count,parent ``` Save the output to `taxonomist-data/export/categories.json`. ### Export posts -Use `wp_cli`: +Run: ``` -eval-file tmp/taxonomist/export-posts.php -``` - -With the environment variable: -``` -TAXONOMIST_OUTPUT={site_path}/taxonomist-data/export/posts.json +TAXONOMIST_OUTPUT={site_path}/taxonomist-data/export/posts.json \ +wp eval-file tmp/taxonomist/export-posts.php ``` ### Post-export summary @@ -106,14 +102,10 @@ Report to the user: Create a full taxonomy snapshot before any analysis. -Use `wp_cli`: -``` -eval-file tmp/taxonomist/backup.php -``` - -With the environment variable: +Run: ``` -TAXONOMIST_OUTPUT={site_path}/taxonomist-data/backups/pre-analysis-{timestamp}.json +TAXONOMIST_OUTPUT={site_path}/taxonomist-data/backups/pre-analysis-{timestamp}.json \ +wp eval-file tmp/taxonomist/backup.php ``` ## Step 4: Analyze @@ -203,68 +195,61 @@ If the user wants adjustments, iterate on the plan and use `AskUserQuestion` aga ## Step 7: Apply Descriptions -After approval, first create any new categories and update descriptions using `wp_cli`: +After approval, first create any new categories and update descriptions: ``` # Create new categories -term create category "Category Name" --slug=category-slug --description="Description here" +wp term create category "Category Name" --slug=category-slug --description="Description here" # Update existing category descriptions -term update category {term_id} --description="Updated description" +wp term update category {term_id} --description="Updated description" ``` ## Step 8: Apply Categories Prepare the suggestions JSON file from the approved plan, then run the apply script. -First, do a **preview** (dry run) using `wp_cli`: +First, do a **preview** (dry run): ``` -eval-file tmp/taxonomist/apply-changes.php +TAXONOMIST_SUGGESTIONS={site_path}/taxonomist-data/results/suggestions.json \ +TAXONOMIST_LOG={site_path}/taxonomist-data/logs/changes-{timestamp}.tsv \ +TAXONOMIST_MODE=preview \ +wp eval-file tmp/taxonomist/apply-changes.php ``` -With environment variables: -``` -TAXONOMIST_SUGGESTIONS={site_path}/taxonomist-data/results/suggestions.json -TAXONOMIST_LOG={site_path}/taxonomist-data/logs/changes-{timestamp}.tsv -TAXONOMIST_MODE=preview -``` - -Show the user the preview results. **Use `AskUserQuestion` to confirm** before applying. After they confirm, run again with: +Show the user the preview results. **Use `AskUserQuestion` to confirm** before applying. After they confirm, run it in apply mode: ``` -TAXONOMIST_SUGGESTIONS={site_path}/taxonomist-data/results/suggestions.json -TAXONOMIST_LOG={site_path}/taxonomist-data/logs/changes-{timestamp}.tsv -TAXONOMIST_MODE=apply -TAXONOMIST_REMOVE_CATS=uncategorized +TAXONOMIST_SUGGESTIONS={site_path}/taxonomist-data/results/suggestions.json \ +TAXONOMIST_LOG={site_path}/taxonomist-data/logs/changes-{timestamp}.tsv \ +TAXONOMIST_MODE=apply \ +TAXONOMIST_REMOVE_CATS=uncategorized \ +wp eval-file tmp/taxonomist/apply-changes.php ``` ## Step 9: Verify After applying changes: -1. List categories with counts using `wp_cli`: `term list category --format=table --fields=term_id,name,slug,count` -2. Check for posts with no categories: `eval 'echo count(get_posts(["posts_per_page" => -1, "category__in" => [get_option("default_category")]]));'` +1. List categories with counts: `wp term list category --format=table --fields=term_id,name,slug,count` +2. Check for posts with no categories: `wp eval 'echo count(get_posts(["posts_per_page" => -1, "category__in" => [get_option("default_category")]]));'` 3. Report the change log location to the user 4. Remind them that a full backup exists and can be restored ## Restoring from Backup -If the user wants to undo all changes, use `wp_cli`: - -``` -eval-file tmp/taxonomist/restore.php -``` +If the user wants to undo all changes, run: -With environment variable: ``` -TAXONOMIST_BACKUP={site_path}/taxonomist-data/backups/pre-analysis-{timestamp}.json +TAXONOMIST_BACKUP={site_path}/taxonomist-data/backups/pre-analysis-{timestamp}.json \ +wp eval-file tmp/taxonomist/restore.php ``` ## Important Notes -- **Use the Studio tools** (`site_list`, `site_info`, `site_start`, `wp_cli`, etc.) — not shell commands +- **Manage sites with the Studio tools** (`site_list`, `site_info`, `site_start`) and run the WP-CLI commands above with `wp` - **Category slugs are the stable identifier** — always use slugs (not names or IDs) when referencing categories across steps - **Never modify WordPress core files** — all changes go through WP-CLI commands -- **The default category cannot be deleted** — change it first via `wp_cli`: `option update default_category {new_id}` if needed +- **The default category cannot be deleted** — change it first with `wp option update default_category {new_id}` if needed - **All data stays local** — exported posts, analysis results, and backups remain in the site's `taxonomist-data/` directory diff --git a/apps/cli/ai/system-prompt.ts b/apps/cli/ai/system-prompt.ts index 68283fc194..82cbbaf026 100644 --- a/apps/cli/ai/system-prompt.ts +++ b/apps/cli/ai/system-prompt.ts @@ -48,7 +48,9 @@ export function buildSystemPrompt( options: BuildSystemPromptOptions = {} ): str ## General rules -- You MUST use your Studio tools to manage WordPress sites. Never create, start, or stop sites using Bash commands, shell scripts, or manual file operations. Never run \`wp\` commands via Bash — always use the wp_cli tool instead. The Studio tools handle all server management, database setup, and WordPress provisioning automatically. +- You MUST use your Studio tools to manage WordPress sites. Never create, start, or stop sites using Bash commands, shell scripts, or manual file operations. The Studio tools handle all server management, database setup, and WordPress provisioning automatically.${ + options.external ? ` ${ EXTERNAL_WP_CLI }` : '' + } - ${ PLAN_DATA_GUARDRAIL } - The user's WordPress.com sites are live. For any question about or change to one — the active-site line says WordPress.com, or the user names one of their WordPress.com sites — load the \`edit-manage-remote-site\` skill first and follow it: it starts with a plan check, and such sites change only through wpcom_request, never WP-CLI, Bash, or local files. - A request works on exactly one site, settled before any change. Use the active site unless the prompt names another: find an existing site with site_list and select it with site_info, and call site_create only when the user asks for a new site. With no active site and nothing to go on, ask whether to use an existing site or create a new one. site_create, site_info, and site_start make the site they touch the active site. @@ -64,6 +66,11 @@ ${ SKILL_ROUTING }${ buildUserInstructionsSection( options.userInstructions ) } `; } +// External agents run commands in their own shell, where a `wp` binary (if any) +// uses the machine's PHP and cannot load a Studio site's database. +const EXTERNAL_WP_CLI = + 'Run the `wp` commands in skills with the wp_cli tool or `studio wp`: a plain `wp` in your shell runs outside the site runtime.'; + function buildUserInstructionsSection( userInstructions?: string ): string { if ( ! userInstructions ) { return ''; @@ -104,7 +111,7 @@ const EXTERNAL_INTRO = `These are the instructions for building and managing loc const WORKING_CADENCE = `## Working cadence -- One file per turn: a single \`Write\`, or a single \`Edit\` call (read-only \`site_info\`, \`site_list\`, \`wp_cli\` queries may be combined). Short prose between tools — no long design-plan essays. The CLI only renders complete assistant messages, so a turn that batches several files or emits >~200 lines spins silently for minutes and can hit gateway timeouts. +- One file per turn: a single \`Write\`, or a single \`Edit\` call (read-only \`site_info\`, \`site_list\` and WP-CLI queries may be combined). Short prose between tools — no long design-plan essays. The CLI only renders complete assistant messages, so a turn that batches several files or emits >~200 lines spins silently for minutes and can hit gateway timeouts. - Read files with Read, not cat or sed. Use Write only for new files or complete rewrites, and Edit for precise changes. - Put every change you have ready for a file into one Edit call — all the anchors you can fill or a whole batch of fixes — instead of one call per anchor; each extra call costs a full round trip. - Keep an Edit call's new text under ~${ formatKilobytes( diff --git a/apps/cli/ai/tests/studio-wp-shim.test.ts b/apps/cli/ai/tests/studio-wp-shim.test.ts new file mode 100644 index 0000000000..2a010efb93 --- /dev/null +++ b/apps/cli/ai/tests/studio-wp-shim.test.ts @@ -0,0 +1,90 @@ +import { execFileSync, spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + ensureStudioWpShimDirectory, + getStudioWpShimScript, + prependToPath, +} from 'cli/ai/runtimes/pi/studio-wp-shim'; + +describe.skipIf( process.platform === 'win32' )( 'studio wp shim', () => { + let root: string; + + beforeEach( () => { + root = fs.mkdtempSync( path.join( os.tmpdir(), "studio-wp-shim-it's-" ) ); + } ); + + afterEach( () => { + fs.rmSync( root, { recursive: true, force: true } ); + } ); + + it( 'makes `wp` in the Bash tool run `studio wp` with the same arguments', () => { + // Stands in for the CLI entry: prints the arguments it receives. + const cliPath = path.join( root, 'main.mjs' ); + fs.writeFileSync( cliPath, 'console.log( JSON.stringify( process.argv.slice( 2 ) ) );' ); + const directory = ensureStudioWpShimDirectory( + getStudioWpShimScript( process.execPath, cliPath ), + path.join( root, 'agent-bin' ) + ); + const { env } = prependToPath( directory )( { + command: '', + cwd: root, + env: { PATH: '/usr/bin:/bin' }, + } ); + + const output = execFileSync( + '/bin/sh', + [ '-c', `wp post create --post_title="Ember & Oak" page.html` ], + { env, encoding: 'utf8' } + ); + + expect( JSON.parse( output ) ).toEqual( [ + 'wp', + 'post', + 'create', + '--post_title=Ember & Oak', + 'page.html', + ] ); + } ); + + it( 'passes stdin, inline environment variables and exit code through', () => { + // Stands in for the CLI entry: echoes stdin and an environment variable, then exits 3. + const cliPath = path.join( root, 'main.mjs' ); + fs.writeFileSync( + cliPath, + [ + "import fs from 'fs';", + 'process.stdout.write( `${ process.env.TAXONOMIST_MODE }:${ fs.readFileSync( 0, "utf8" ) }` );', + 'process.exit( 3 );', + ].join( '\n' ) + ); + const directory = ensureStudioWpShimDirectory( + getStudioWpShimScript( process.execPath, cliPath ), + path.join( root, 'agent-bin' ) + ); + const { env } = prependToPath( directory )( { + command: '', + cwd: root, + env: { PATH: '/usr/bin:/bin' }, + } ); + + const result = spawnSync( + '/bin/sh', + [ '-c', 'TAXONOMIST_MODE=apply wp eval-file taxonomist.php' ], + { env, input: 'post content', encoding: 'utf8' } + ); + + expect( result.stdout ).toBe( 'apply:post content' ); + expect( result.status ).toBe( 3 ); + } ); + + it( 'reuses the shim directory for the same script', () => { + const script = getStudioWpShimScript( '/node', '/cli/main.mjs' ); + const agentBin = path.join( root, 'agent-bin' ); + expect( ensureStudioWpShimDirectory( script, agentBin ) ).toBe( + ensureStudioWpShimDirectory( script, agentBin ) + ); + } ); +} ); diff --git a/apps/cli/ai/tests/tools.test.ts b/apps/cli/ai/tests/tools.test.ts index 9f8e86a56c..7d2a3fe760 100644 --- a/apps/cli/ai/tests/tools.test.ts +++ b/apps/cli/ai/tests/tools.test.ts @@ -375,6 +375,13 @@ describe( 'Studio AI MCP tools', () => { expect( namesWithArtifacts ).toContain( 'refresh_browser' ); } ); + it( 'leaves wp_cli out when the agent shell runs Studio WP-CLI', () => { + expect( resolveStudioToolDefinitions().map( ( tool ) => tool.name ) ).toContain( 'wp_cli' ); + expect( + resolveStudioToolDefinitions( { shellRunsStudioWp: true } ).map( ( tool ) => tool.name ) + ).not.toContain( 'wp_cli' ); + } ); + it( 'refresh_browser emits a preview.reload event and is registered', async () => { expect( studioToolDefinitions.map( ( tool ) => tool.name ) ).toContain( 'refresh_browser' ); const emitEventMock = vi.mocked( emitEvent ); diff --git a/apps/cli/ai/tools/index.ts b/apps/cli/ai/tools/index.ts index 6b25fdde78..3aaaa15963 100644 --- a/apps/cli/ai/tools/index.ts +++ b/apps/cli/ai/tools/index.ts @@ -83,6 +83,9 @@ export interface CreateStudioToolsOptions { // How a local image is linked in the agent's reply so the host shows it to the user. // Set by hosts without Studio's chat UI, which shows images as chat artifacts instead. imageLink?: ( file: string ) => string; + // The agent has a shell whose `wp` runs Studio's WP-CLI (Studio Code's Bash + // tool), so wp_cli would be a second way to do the same thing. + shellRunsStudioWp?: boolean; } export function resolveStudioToolDefinitions( @@ -95,6 +98,9 @@ export function resolveStudioToolDefinitions( if ( candidate.name === refreshBrowserTool.name && options.emitChatArtifacts !== true ) { return []; } + if ( candidate.name === runWpCliTool.name && options.shellRunsStudioWp ) { + return []; + } if ( candidate.name === generateImagesTool.name && ! options.imageGeneration ) { return []; } diff --git a/apps/cli/ai/tools/install-taxonomy-scripts.ts b/apps/cli/ai/tools/install-taxonomy-scripts.ts index 79f25fa0ff..b7419dce73 100644 --- a/apps/cli/ai/tools/install-taxonomy-scripts.ts +++ b/apps/cli/ai/tools/install-taxonomy-scripts.ts @@ -9,7 +9,7 @@ const TAXONOMIST_SCRIPTS_DIR = 'tmp/taxonomist'; export const installTaxonomyScriptsTool = defineTool( 'install_taxonomy_scripts', - 'Copies the Taxonomist PHP scripts into a site so they can be run via wp_cli eval-file. ' + + 'Copies the Taxonomist PHP scripts into a site so they can be run with `wp eval-file`. ' + 'Call this once before running any Taxonomist eval-file commands.', { nameOrPath: Type.String( { description: 'The site name or file system path to the site' } ), diff --git a/apps/cli/ai/tools/validate-blocks.ts b/apps/cli/ai/tools/validate-blocks.ts index c5a13cfac3..6324c7e2cd 100644 --- a/apps/cli/ai/tools/validate-blocks.ts +++ b/apps/cli/ai/tools/validate-blocks.ts @@ -41,7 +41,7 @@ function formatMarkdownFence( language: string, content: string ): string { export const validateBlocksTool = defineTool( 'validate_blocks', - "Validates WordPress block content in two stages and returns a combined report. First runs a static core/html block policy check; if it finds invalid core/html blocks, it returns only those (rewrite them as editable core or plugin blocks and call again) without touching the editor. Once the policy check passes, it validates the content in the site's real block editor: with filePath it applies safe live-editor serialization fixes directly to the file and returns a CSS-review diff; with inline content it returns the exact fixed block content plus the diff. The site must be running, with the theme and the plugins the content uses already active: the editor only recognizes the active theme and registered plugin blocks. Validate block content before it reaches the site: before `wp post create/update`, and before `wp_cli eval` that imports a scratch file such as `/tmp/page-.html`. Theme `templates/*.html` and `parts/*.html` files are live the moment they are written, so validate each one with `filePath` right after writing or editing it. If an auto-fix was applied, the file already holds the fixed content; do not replace markup or re-validate unless you change the markup, and use the diff only to update CSS selectors for class/nesting changes. For inline content, use the returned fixed content exactly.", + "Validates WordPress block content in two stages and returns a combined report. First runs a static core/html block policy check; if it finds invalid core/html blocks, it returns only those (rewrite them as editable core or plugin blocks and call again) without touching the editor. Once the policy check passes, it validates the content in the site's real block editor: with filePath it applies safe live-editor serialization fixes directly to the file and returns a CSS-review diff; with inline content it returns the exact fixed block content plus the diff. The site must be running, with the theme and the plugins the content uses already active: the editor only recognizes the active theme and registered plugin blocks. Validate block content before it reaches the site: before `wp post create/update`, and before a `wp eval` that imports a scratch file such as `/tmp/page-.html`. Theme `templates/*.html` and `parts/*.html` files are live the moment they are written, so validate each one with `filePath` right after writing or editing it. If an auto-fix was applied, the file already holds the fixed content; do not replace markup or re-validate unless you change the markup, and use the diff only to update CSS selectors for class/nesting changes. For inline content, use the returned fixed content exactly.", { nameOrPath: Type.String( { description: 'The site name or file system path — the site must be running', diff --git a/apps/cli/ai/tools/wp-cli.ts b/apps/cli/ai/tools/wp-cli.ts index 4df8cab63c..63ef6a5e65 100644 --- a/apps/cli/ai/tools/wp-cli.ts +++ b/apps/cli/ai/tools/wp-cli.ts @@ -1,6 +1,7 @@ import { Type } from 'typebox'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; +import { getUnsupportedWpCliOptionMessage } from 'cli/lib/wp-cli-options'; import { getUnsupportedWpCliPostContentMessage } from 'cli/lib/wp-cli-post-content'; import { defineTool, REMOTE_OVERWRITE } from './define-tool'; import { resolveSite } from './utils'; @@ -104,16 +105,6 @@ function splitCommandArgs( command: string ): string[] { return splitBasicCommandArgs( command ); } -// LLMs sometimes emit en/em dashes (`‐porcelain`, `–color`); WP-CLI silently -// ignores them, so reject up-front and let the agent retry with ASCII hyphens. -function getUnsupportedWpCliOptionMessage( args: string[] ): string | null { - const unsupportedOption = args.find( ( arg ) => /^[‐-―]\S+/.test( arg ) ); - if ( ! unsupportedOption ) { - return null; - } - return `Unsupported WP-CLI option "${ unsupportedOption }": use ASCII hyphens, for example "--porcelain", not a typographic dash.`; -} - export const runWpCliTool = defineTool( 'wp_cli', 'Runs a WP-CLI command on a specific WordPress site. The site must be running (site_start if needed). ' + diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index 245ad24a53..6c52b90829 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -903,7 +903,6 @@ export class AiChatUI implements AiOutputAdapter { } break; } - case 'wp_cli': case 'preview_create': case 'preview_list': case 'preview_update': diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index ea29b62ba7..f16f44d493 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -1,9 +1,10 @@ import { __ } from '@wordpress/i18n'; import { ArgumentsCamelCase } from 'yargs'; import yargsParser from 'yargs-parser'; -import { getSiteByFolder } from 'cli/lib/cli-config/sites'; +import { getSiteContainingFolder } from 'cli/lib/cli-config/sites'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; import { validatePhpVersion } from 'cli/lib/utils'; +import { getUnsupportedWpCliOptionMessage } from 'cli/lib/wp-cli-options'; import { Logger, LoggerError } from 'cli/logger'; import { GlobalOptions } from 'cli/types'; @@ -14,7 +15,7 @@ export async function runCommand( args: string[], options: { phpVersion?: string } = {} ): Promise< void > { - const site = await getSiteByFolder( siteFolder ); + const site = await getSiteContainingFolder( siteFolder ); const phpVersion = validatePhpVersion( options.phpVersion ?? site.phpVersion ); // Connect the PHP child directly to the terminal for piped/interactive stdin, live @@ -57,6 +58,11 @@ export async function commandHandler( argv: ArgumentsCamelCase< GlobalOptions > ); } + const unsupportedOptionMessage = getUnsupportedWpCliOptionMessage( wpCliArgv ); + if ( unsupportedOptionMessage ) { + throw new LoggerError( unsupportedOptionMessage ); + } + const phpVersion = parsedWpCliArgs[ 'php-version' ] !== undefined ? String( parsedWpCliArgs[ 'php-version' ] ) diff --git a/apps/cli/lib/cli-config/sites.ts b/apps/cli/lib/cli-config/sites.ts index 05e5bac6b1..eed615540f 100644 --- a/apps/cli/lib/cli-config/sites.ts +++ b/apps/cli/lib/cli-config/sites.ts @@ -1,3 +1,4 @@ +import path from 'path'; import { arePathsEqual, isWordPressDirectory } from '@studio/common/lib/fs-utils'; import { __ } from '@wordpress/i18n'; import { LoggerError } from 'cli/logger'; @@ -26,6 +27,20 @@ export async function getSiteByFolder( siteFolder: string ): Promise< SiteData > return site; } +// Like WP-CLI, finds the site from any directory inside it, not only its root. +export async function getSiteContainingFolder( folder: string ): Promise< SiteData > { + const config = await readCliConfig(); + for ( let directory = path.resolve( folder ); ; directory = path.dirname( directory ) ) { + const site = config.sites.find( ( candidate ) => arePathsEqual( candidate.path, directory ) ); + if ( site ) { + return site; + } + if ( path.dirname( directory ) === directory ) { + return getSiteByFolder( folder ); + } + } +} + export async function findSiteByFolder( siteFolder: string ): Promise< SiteData | undefined > { const config = await readCliConfig(); return config.sites.find( ( site ) => arePathsEqual( site.path, siteFolder ) ); diff --git a/apps/cli/lib/cli-config/tests/sites.test.ts b/apps/cli/lib/cli-config/tests/sites.test.ts new file mode 100644 index 0000000000..2164023ead --- /dev/null +++ b/apps/cli/lib/cli-config/tests/sites.test.ts @@ -0,0 +1,41 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readCliConfig } from 'cli/lib/cli-config/core'; +import { getSiteContainingFolder } from 'cli/lib/cli-config/sites'; + +vi.mock( 'cli/lib/cli-config/core', () => ( { + readCliConfig: vi.fn(), +} ) ); + +describe( 'getSiteContainingFolder', () => { + let root: string; + let sitePath: string; + + beforeEach( () => { + root = fs.realpathSync( fs.mkdtempSync( path.join( os.tmpdir(), 'studio-sites-' ) ) ); + sitePath = path.join( root, 'my-site' ); + fs.mkdirSync( path.join( sitePath, 'wp-content', 'themes', 'demo' ), { recursive: true } ); + vi.mocked( readCliConfig ).mockResolvedValue( { + sites: [ { id: '1', name: 'My Site', path: sitePath } ], + } as unknown as Awaited< ReturnType< typeof readCliConfig > > ); + } ); + + afterEach( () => { + fs.rmSync( root, { recursive: true, force: true } ); + } ); + + it( 'finds the site from its root', async () => { + expect( ( await getSiteContainingFolder( sitePath ) ).path ).toBe( sitePath ); + } ); + + it( 'finds the site from a directory inside it', async () => { + const theme = path.join( sitePath, 'wp-content', 'themes', 'demo' ); + expect( ( await getSiteContainingFolder( theme ) ).path ).toBe( sitePath ); + } ); + + it( 'reports a directory outside every site', async () => { + await expect( getSiteContainingFolder( root ) ).rejects.toThrow( 'not added to Studio' ); + } ); +} ); diff --git a/apps/cli/lib/tests/wp-cli-options.test.ts b/apps/cli/lib/tests/wp-cli-options.test.ts new file mode 100644 index 0000000000..8c804da8a6 --- /dev/null +++ b/apps/cli/lib/tests/wp-cli-options.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { getUnsupportedWpCliOptionMessage } from 'cli/lib/wp-cli-options'; + +describe( 'getUnsupportedWpCliOptionMessage', () => { + it( 'names an option written with a typographic dash', () => { + expect( getUnsupportedWpCliOptionMessage( [ 'media', 'import', 'a.png', '–porcelain' ] ) ).toBe( + 'Unsupported WP-CLI option "–porcelain": use ASCII hyphens, for example "--porcelain", not a typographic dash.' + ); + } ); + + it( 'accepts ASCII options and dashes inside values', () => { + expect( + getUnsupportedWpCliOptionMessage( [ + 'post', + 'create', + '--post_title=Ember – Oak', + '--porcelain', + ] ) + ).toBeNull(); + } ); +} ); diff --git a/apps/cli/lib/wp-cli-options.ts b/apps/cli/lib/wp-cli-options.ts new file mode 100644 index 0000000000..e4f3434866 --- /dev/null +++ b/apps/cli/lib/wp-cli-options.ts @@ -0,0 +1,9 @@ +// LLMs sometimes emit en/em dashes (`‐porcelain`, `–color`); WP-CLI silently +// ignores them, so reject up-front and let the caller retry with ASCII hyphens. +export function getUnsupportedWpCliOptionMessage( args: string[] ): string | null { + const unsupportedOption = args.find( ( arg ) => /^[‐-―]\S+/.test( arg ) ); + if ( ! unsupportedOption ) { + return null; + } + return `Unsupported WP-CLI option "${ unsupportedOption }": use ASCII hyphens, for example "--porcelain", not a typographic dash.`; +}