From b00b8c27a75732df7fe90daeca8e7c410961accd Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 16 Oct 2026 18:39:33 -0400 Subject: [PATCH 1/6] Run Studio's WP-CLI for wp in the agent's shell instead of forbidding it in the prompt --- apps/cli/ai/runtimes/pi/file-tool-prompts.ts | 2 +- apps/cli/ai/runtimes/pi/index.ts | 20 ++++++- apps/cli/ai/runtimes/pi/studio-wp-shim.ts | 51 +++++++++++++++++ apps/cli/ai/system-prompt.ts | 9 ++- apps/cli/ai/tests/studio-wp-shim.test.ts | 59 ++++++++++++++++++++ apps/cli/commands/wp.ts | 4 +- apps/cli/lib/cli-config/sites.ts | 15 +++++ apps/cli/lib/cli-config/tests/sites.test.ts | 41 ++++++++++++++ 8 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 apps/cli/ai/runtimes/pi/studio-wp-shim.ts create mode 100644 apps/cli/ai/tests/studio-wp-shim.test.ts create mode 100644 apps/cli/lib/cli-config/tests/sites.test.ts diff --git a/apps/cli/ai/runtimes/pi/file-tool-prompts.ts b/apps/cli/ai/runtimes/pi/file-tool-prompts.ts index ce52b4c0ec..f5f001bea1 100644 --- a/apps/cli/ai/runtimes/pi/file-tool-prompts.ts +++ b/apps/cli/ai/runtimes/pi/file-tool-prompts.ts @@ -9,7 +9,7 @@ const FILE_TOOL_SNIPPETS: Record< string, string > = { Read: 'Read file contents', Write: 'Create or overwrite files', Edit: 'Make precise file edits with exact text replacement, several disjoint edits in one call', - Bash: 'Execute shell commands (ls, grep, find, etc.)', + Bash: 'Execute shell commands (ls, grep, find, etc.); `wp` runs WP-CLI on the Studio site that contains the working directory', Grep: 'Search file contents for patterns (respects .gitignore)', Glob: 'Find files by glob pattern (respects .gitignore)', Ls: 'List directory contents', diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 8e4b80cb5c..79a3abea9f 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,7 +652,10 @@ 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' ), 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/system-prompt.ts b/apps/cli/ai/system-prompt.ts index 68283fc194..327fa102e5 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 WP-CLI 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 ''; 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..aecc7291d8 --- /dev/null +++ b/apps/cli/ai/tests/studio-wp-shim.test.ts @@ -0,0 +1,59 @@ +import { execFileSync } 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( '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/commands/wp.ts b/apps/cli/commands/wp.ts index ea29b62ba7..a33c67128b 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -1,7 +1,7 @@ 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 { Logger, LoggerError } from 'cli/logger'; @@ -14,7 +14,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 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' ); + } ); +} ); From 30db78ef121524fc499032fe908c4a960a023adc Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 16 Oct 2026 18:58:28 -0400 Subject: [PATCH 2/6] Give Studio Code WP-CLI through its shell only, and write skills as plain wp commands --- apps/cli/ai/inspector/page-script.ts | 2 +- apps/cli/ai/runtimes/pi/index.ts | 1 + apps/cli/ai/skills/annotate/SKILL.md | 4 +- apps/cli/ai/skills/block-content/SKILL.md | 12 +-- apps/cli/ai/skills/build-site/SKILL.md | 2 +- apps/cli/ai/skills/imagery/SKILL.md | 2 +- apps/cli/ai/skills/need-for-speed/SKILL.md | 2 +- .../ai/skills/plugin-recommendations/SKILL.md | 42 +++++------ apps/cli/ai/skills/rank-me-up/SKILL.md | 16 ++-- apps/cli/ai/skills/taxonomist/SKILL.md | 75 ++++++++----------- apps/cli/ai/system-prompt.ts | 4 +- apps/cli/ai/tests/tools.test.ts | 7 ++ apps/cli/ai/tools/index.ts | 6 ++ apps/cli/ai/tools/install-taxonomy-scripts.ts | 2 +- apps/cli/ai/tools/validate-blocks.ts | 2 +- apps/cli/ai/tools/wp-cli.ts | 11 +-- apps/cli/ai/ui.ts | 1 - apps/cli/commands/wp.ts | 6 ++ apps/cli/lib/tests/wp-cli-options.test.ts | 21 ++++++ apps/cli/lib/wp-cli-options.ts | 9 +++ 20 files changed, 126 insertions(+), 101 deletions(-) create mode 100644 apps/cli/lib/tests/wp-cli-options.test.ts create mode 100644 apps/cli/lib/wp-cli-options.ts 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 79a3abea9f..fe552b64db 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -662,6 +662,7 @@ function buildAgentTools( ]; const studioTools = resolveStudioToolDefinitions( { emitChatArtifacts: chatArtifactsEnabled, + shellRunsStudioWp: true, imageGeneration: imageGenerationEnabled, visionEnabled, canAskUser: Boolean( config.onAskUser ), 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 327fa102e5..82cbbaf026 100644 --- a/apps/cli/ai/system-prompt.ts +++ b/apps/cli/ai/system-prompt.ts @@ -69,7 +69,7 @@ ${ 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 WP-CLI with the wp_cli tool or `studio wp`: a plain `wp` in your shell runs outside the site runtime.'; + '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 ) { @@ -111,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/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 a33c67128b..f16f44d493 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -4,6 +4,7 @@ import yargsParser from 'yargs-parser'; 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'; @@ -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/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.`; +} From cca3baede7fba4a79539bbaceb67622f7edad73d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 16 Oct 2026 19:02:36 -0400 Subject: [PATCH 3/6] Drop the Bash tool's wp note: the agent finds wp without it --- apps/cli/ai/runtimes/pi/file-tool-prompts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/ai/runtimes/pi/file-tool-prompts.ts b/apps/cli/ai/runtimes/pi/file-tool-prompts.ts index f5f001bea1..ce52b4c0ec 100644 --- a/apps/cli/ai/runtimes/pi/file-tool-prompts.ts +++ b/apps/cli/ai/runtimes/pi/file-tool-prompts.ts @@ -9,7 +9,7 @@ const FILE_TOOL_SNIPPETS: Record< string, string > = { Read: 'Read file contents', Write: 'Create or overwrite files', Edit: 'Make precise file edits with exact text replacement, several disjoint edits in one call', - Bash: 'Execute shell commands (ls, grep, find, etc.); `wp` runs WP-CLI on the Studio site that contains the working directory', + Bash: 'Execute shell commands (ls, grep, find, etc.)', Grep: 'Search file contents for patterns (respects .gitignore)', Glob: 'Find files by glob pattern (respects .gitignore)', Ls: 'List directory contents', From 26fcfa67e63a51af95e4813f36cc904e850136b8 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 16 Oct 2026 20:37:15 -0400 Subject: [PATCH 4/6] Run wp in the agent shell on PHP directly, with the command line from a broker in the agent process --- apps/cli/ai/runtimes/pi/index.ts | 19 ++- apps/cli/ai/runtimes/pi/studio-wp-shim.ts | 171 +++++++++++++++++-- apps/cli/ai/tests/studio-wp-shim.test.ts | 139 ++++++++++++--- apps/cli/commands/wp.ts | 51 +----- apps/cli/lib/run-wp-cli-command.ts | 72 +++++--- apps/cli/lib/studio-wp-command.ts | 77 +++++++++ apps/cli/lib/tests/studio-wp-command.test.ts | 29 ++++ 7 files changed, 431 insertions(+), 127 deletions(-) create mode 100644 apps/cli/lib/studio-wp-command.ts create mode 100644 apps/cli/lib/tests/studio-wp-command.test.ts diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index fe552b64db..745e212ca4 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -26,6 +26,7 @@ import { SettingsManager, type AgentSession, type AgentSessionEvent, + type BashSpawnHook, type SessionManager, type ToolDefinition, } from '@earendil-works/pi-coding-agent'; @@ -53,8 +54,9 @@ import { getPendingWork, type PendingWork } from './pending-work'; import { stripStaleImagesFromContext } from './strip-stale-images'; import { ensureStudioWpShimDirectory, - getStudioWpShimScript, prependToPath, + startStudioWpBroker, + STUDIO_WP_BROKER_ENV, } from './studio-wp-shim'; import { getIncompleteToolCallReason, @@ -611,11 +613,16 @@ 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 ) ) - ); +let studioWpSpawnHook: BashSpawnHook | undefined; +function getStudioWpSpawnHook(): BashSpawnHook { + if ( ! studioWpSpawnHook ) { + const cliPath = process.env.STUDIO_CLI_BIN ?? path.join( import.meta.dirname, 'main.mjs' ); + const broker = startStudioWpBroker(); + studioWpSpawnHook = prependToPath( ensureStudioWpShimDirectory( process.execPath, cliPath ), { + [ STUDIO_WP_BROKER_ENV ]: broker.address, + } ); + } + return studioWpSpawnHook; } function buildAgentTools( diff --git a/apps/cli/ai/runtimes/pi/studio-wp-shim.ts b/apps/cli/ai/runtimes/pi/studio-wp-shim.ts index 1f8d71c5e3..9ffadfa7f8 100644 --- a/apps/cli/ai/runtimes/pi/studio-wp-shim.ts +++ b/apps/cli/ai/runtimes/pi/studio-wp-shim.ts @@ -1,41 +1,175 @@ -import { createHash } from 'crypto'; +import { createHash, randomUUID } from 'crypto'; import fs from 'fs'; +import net from 'net'; +import os from 'os'; import path from 'path'; import { getConfigDirectory } from '@studio/common/lib/well-known-paths'; +import { withoutOversizedEnvValues } from 'cli/lib/child-env'; +import { prepareStudioWpCommand } from 'cli/lib/studio-wp-command'; import type { BashSpawnHook } from '@earendil-works/pi-coding-agent'; +import type { WpCliCommandLine } from 'cli/lib/run-wp-cli-command'; + +export const STUDIO_WP_BROKER_ENV = 'STUDIO_WP_BROKER'; + +export type StudioWpRequest = { cwd: string; argv: string[]; env: NodeJS.ProcessEnv }; +export type StudioWpReply = ( WpCliCommandLine & { env: NodeJS.ProcessEnv } ) | { error: string }; 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`; +// `wp` in the agent's shell is 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. +// +// Starting the Studio CLI for each command costs seconds, so the client asks the broker in +// the agent's own process for the PHP command line and runs PHP itself, inheriting the +// shell's stdio and environment. Without a broker it runs `studio wp`. +export function getStudioWpClientScript( nodePath: string, cliPath: string ): string { + return `import net from 'node:net'; +import { spawn } from 'node:child_process'; + +const argv = process.argv.slice( 2 ); + +function run( command, args, options ) { + const child = spawn( command, args, { stdio: 'inherit', ...options } ); + for ( const signal of [ 'SIGINT', 'SIGTERM', 'SIGHUP' ] ) { + process.on( signal, () => child.kill( signal ) ); + } + child.on( 'error', ( error ) => { + process.stderr.write( error.message + '\\n' ); + process.exit( 1 ); + } ); + child.on( 'exit', ( code, signal ) => process.exit( code ?? ( signal ? 128 : 1 ) ) ); +} + +let ranStudioWp = false; +function runStudioWp() { + if ( ! ranStudioWp ) { + ranStudioWp = true; + run( ${ JSON.stringify( nodePath ) }, [ ${ JSON.stringify( cliPath ) }, 'wp', ...argv ] ); + } +} + +const broker = process.env.${ STUDIO_WP_BROKER_ENV }; +if ( ! broker ) { + runStudioWp(); +} else { + let reply = ''; + const socket = net.connect( broker ); + socket.setEncoding( 'utf8' ); + socket.on( 'error', runStudioWp ); + socket.on( 'data', ( chunk ) => ( reply += chunk ) ); + socket.on( 'end', () => { + const line = JSON.parse( reply ); + if ( line.error ) { + process.stderr.write( line.error + '\\n' ); + process.exit( 1 ); + } + run( line.command, line.args, { cwd: line.cwd, env: line.env } ); + } ); + socket.end( JSON.stringify( { cwd: process.cwd(), argv, env: process.env } ) + '\\n' ); +} +`; +} + +export function getStudioWpShimScript( nodePath: string, clientPath: string ): string { + return `#!/bin/sh\nexec ${ shellQuote( nodePath ) } ${ shellQuote( clientPath ) } "$@"\n`; +} + +function writeAtomically( file: string, content: string, mode: number ) { + if ( ! fs.existsSync( file ) ) { + const temporaryPath = `${ file }.${ process.pid }`; + fs.writeFileSync( temporaryPath, content, { mode } ); + fs.renameSync( temporaryPath, file ); + } } -// Writes the shim into a directory named after its content, so concurrent Studio -// processes (or different installs) never overwrite each other's script. +// Writes `wp` and its client into a directory named after their content, so concurrent +// Studio processes (or different installs) never overwrite each other's files. export function ensureStudioWpShimDirectory( - script: string, + nodePath: string, + cliPath: string, root: string = path.join( getConfigDirectory(), 'tmp', 'agent-bin' ) ): string { + const client = getStudioWpClientScript( nodePath, cliPath ); const directory = path.join( root, - createHash( 'sha256' ).update( script ).digest( 'hex' ).slice( 0, 12 ) + createHash( 'sha256' ).update( nodePath ).update( client ).digest( 'hex' ).slice( 0, 12 ) + ); + const clientPath = path.join( directory, 'wp-client.mjs' ); + fs.mkdirSync( directory, { recursive: true } ); + writeAtomically( clientPath, client, 0o644 ); + writeAtomically( + path.join( directory, 'wp' ), + getStudioWpShimScript( nodePath, clientPath ), + 0o755 ); - 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 { +export async function answerStudioWpRequest( + request: StudioWpRequest, + prepare: typeof prepareStudioWpCommand = prepareStudioWpCommand +): Promise< StudioWpReply > { + try { + const commandLine = await prepare( request.cwd, request.argv ); + return { + ...commandLine, + env: withoutOversizedEnvValues( { ...request.env, WP_CLI_ALLOW_ROOT: '1' } ), + }; + } catch ( error ) { + return { error: error instanceof Error ? error.message : String( error ) }; + } +} + +function getBrokerAddress(): { address: string; cleanup: () => void } { + if ( process.platform === 'win32' ) { + return { address: `\\\\.\\pipe\\studio-wp-${ randomUUID() }`, cleanup: () => {} }; + } + // A private directory, since the socket answers anyone who can reach it. + const directory = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-wp-' ) ); + return { + address: path.join( directory, 'broker.sock' ), + cleanup: () => fs.rmSync( directory, { recursive: true, force: true } ), + }; +} + +export function startStudioWpBroker( + prepare: typeof prepareStudioWpCommand = prepareStudioWpCommand +): { address: string; close: () => void } { + const { address, cleanup } = getBrokerAddress(); + const server = net.createServer( { allowHalfOpen: true }, ( socket ) => { + let request = ''; + socket.setEncoding( 'utf8' ); + socket.on( 'error', () => socket.destroy() ); + socket.on( 'data', ( chunk ) => ( request += chunk ) ); + socket.on( 'end', async () => { + let reply: StudioWpReply; + try { + reply = await answerStudioWpRequest( JSON.parse( request ) as StudioWpRequest, prepare ); + } catch ( error ) { + reply = { error: error instanceof Error ? error.message : String( error ) }; + } + socket.end( JSON.stringify( reply ) ); + } ); + } ); + server.on( 'error', () => {} ); + server.listen( address ); + // The agent's process should exit when its work does, broker or not. + server.unref(); + const close = () => { + server.close(); + cleanup(); + }; + process.once( 'exit', close ); + return { address, close }; +} + +export function prependToPath( + directory: string, + extraEnv: NodeJS.ProcessEnv = {} +): BashSpawnHook { return ( context ) => { const pathKey = Object.keys( context.env ).find( ( key ) => key.toUpperCase() === 'PATH' ) ?? 'PATH'; @@ -44,6 +178,7 @@ export function prependToPath( directory: string ): BashSpawnHook { ...context, env: { ...context.env, + ...extraEnv, [ pathKey ]: current ? `${ directory }${ path.delimiter }${ current }` : directory, }, }; diff --git a/apps/cli/ai/tests/studio-wp-shim.test.ts b/apps/cli/ai/tests/studio-wp-shim.test.ts index aecc7291d8..2fa8327c1b 100644 --- a/apps/cli/ai/tests/studio-wp-shim.test.ts +++ b/apps/cli/ai/tests/studio-wp-shim.test.ts @@ -1,59 +1,142 @@ -import { execFileSync } from 'child_process'; +import { spawn } 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, + startStudioWpBroker, + STUDIO_WP_BROKER_ENV, } from 'cli/ai/runtimes/pi/studio-wp-shim'; +import { LoggerError } from 'cli/logger'; -describe.skipIf( process.platform === 'win32' )( 'studio wp shim', () => { +// Runs `command` the way the Bash tool does, and collects what it prints. +function runInShell( + command: string, + env: NodeJS.ProcessEnv, + input = '' +): Promise< { stdout: string; stderr: string; code: number | null } > { + return new Promise( ( resolve, reject ) => { + const child = spawn( '/bin/sh', [ '-c', command ], { env } ); + let stdout = ''; + let stderr = ''; + child.stdout.on( 'data', ( chunk ) => ( stdout += chunk ) ); + child.stderr.on( 'data', ( chunk ) => ( stderr += chunk ) ); + child.on( 'error', reject ); + child.on( 'exit', ( code ) => resolve( { stdout, stderr, code } ) ); + child.stdin.end( input ); + } ); +} + +describe.skipIf( process.platform === 'win32' )( 'wp in the agent shell', () => { let root: string; + let directory: string; + // Stands in for PHP running WP-CLI: reports what it was started with, then exits 3. + let fakePhp: string; + const closers: Array< () => void > = []; beforeEach( () => { root = fs.mkdtempSync( path.join( os.tmpdir(), "studio-wp-shim-it's-" ) ); + // Stands in for the Studio 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 ) ) );' ); + directory = ensureStudioWpShimDirectory( process.execPath, cliPath, path.join( root, 'bin' ) ); + fakePhp = path.join( root, 'php.mjs' ); + fs.writeFileSync( + fakePhp, + `let stdin = ''; +process.stdin.on( 'data', ( chunk ) => ( stdin += chunk ) ); +process.stdin.on( 'end', () => { + console.log( JSON.stringify( { + args: process.argv.slice( 2 ), + cwd: process.cwd(), + stdin, + taxonomist: process.env.TAXONOMIST_MODE, + allowRoot: process.env.WP_CLI_ALLOW_ROOT, + } ) ); + process.exit( 3 ); +} );` + ); } ); afterEach( () => { + closers.splice( 0 ).forEach( ( close ) => close() ); 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 )( { + function shellEnv( extra: NodeJS.ProcessEnv = {} ) { + return prependToPath( + directory, + extra + )( { command: '', cwd: root, env: { PATH: '/usr/bin:/bin' }, + } ).env; + } + + it( 'runs the command line the broker prepares, with the shell stdin, env and exit code', async () => { + const broker = startStudioWpBroker( async ( cwd, argv ) => ( { + command: process.execPath, + args: [ fakePhp, `--path=${ cwd }`, ...argv ], + cwd: os.tmpdir(), + } ) ); + closers.push( broker.close ); + + const result = await runInShell( + `cd '${ root.replace( + /'/g, + `'\\''` + ) }' && TAXONOMIST_MODE=apply wp post create --post_title="Ember & Oak" -`, + shellEnv( { [ STUDIO_WP_BROKER_ENV ]: broker.address } ), + '

Hi

' + ); + + expect( result.code ).toBe( 3 ); + expect( JSON.parse( result.stdout ) ).toEqual( { + args: [ + `--path=${ fs.realpathSync( root ) }`, + 'post', + 'create', + '--post_title=Ember & Oak', + '-', + ], + cwd: fs.realpathSync( os.tmpdir() ), + stdin: '

Hi

', + taxonomist: 'apply', + allowRoot: '1', } ); + } ); - const output = execFileSync( - '/bin/sh', - [ '-c', `wp post create --post_title="Ember & Oak" page.html` ], - { env, encoding: 'utf8' } + it( 'reports why the broker could not prepare the command', async () => { + const broker = startStudioWpBroker( async () => { + throw new LoggerError( 'Could not find Studio site.' ); + } ); + closers.push( broker.close ); + + const result = await runInShell( + 'wp option get siteurl', + shellEnv( { [ STUDIO_WP_BROKER_ENV ]: broker.address } ) ); - expect( JSON.parse( output ) ).toEqual( [ - 'wp', - 'post', - 'create', - '--post_title=Ember & Oak', - 'page.html', - ] ); + expect( result ).toEqual( { stdout: '', stderr: 'Could not find Studio site.\n', code: 1 } ); } ); - 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 ) + it( 'runs `studio wp` when no broker answers', async () => { + const result = await runInShell( + 'wp option get siteurl', + shellEnv( { [ STUDIO_WP_BROKER_ENV ]: path.join( root, 'missing.sock' ) } ) ); + + expect( JSON.parse( result.stdout ) ).toEqual( [ 'wp', 'option', 'get', 'siteurl' ] ); + expect( result.code ).toBe( 0 ); + } ); + + it( 'reuses the shim directory for the same Node and CLI', () => { + const bin = path.join( root, 'bin' ); + expect( + ensureStudioWpShimDirectory( process.execPath, path.join( root, 'main.mjs' ), bin ) + ).toBe( directory ); } ); } ); diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index f16f44d493..d51715f86e 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -1,10 +1,9 @@ import { __ } from '@wordpress/i18n'; import { ArgumentsCamelCase } from 'yargs'; -import yargsParser from 'yargs-parser'; import { getSiteContainingFolder } from 'cli/lib/cli-config/sites'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; +import { parseStudioWpArguments } from 'cli/lib/studio-wp-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'; @@ -24,53 +23,11 @@ export async function runCommand( process.exitCode = await command.exitCode; } -function removeArgumentFromArgv( - argv: string[], - argName: string, - hasValue: boolean = true -): string[] { - argv = argv.slice( 0 ); - - while ( argv.indexOf( `--${ argName }` ) !== -1 ) { - const argIndex = argv.indexOf( `--${ argName }` ); - // Remove 2 elements for --arg value, or 1 element for boolean flags like --no-path - argv.splice( argIndex, hasValue ? 2 : 1 ); - } - - while ( argv.find( ( arg ) => arg.startsWith( `--${ argName }=` ) ) ) { - const argIndex = argv.findIndex( ( arg ) => arg.startsWith( `--${ argName }=` ) ); - argv.splice( argIndex, 1 ); - } - - return argv; -} - export async function commandHandler( argv: ArgumentsCamelCase< GlobalOptions > ) { try { - let wpCliArgv = removeArgumentFromArgv( process.argv.slice( 3 ), 'path' ); - const parsedWpCliArgs = yargsParser( wpCliArgv ); - - if ( parsedWpCliArgs._[ 0 ] === 'shell' ) { - throw new LoggerError( - __( - 'Studio CLI does not support the WP-CLI `shell` command. Consider adding your code to a file and using the `eval` command.' - ) - ); - } - - const unsupportedOptionMessage = getUnsupportedWpCliOptionMessage( wpCliArgv ); - if ( unsupportedOptionMessage ) { - throw new LoggerError( unsupportedOptionMessage ); - } - - const phpVersion = - parsedWpCliArgs[ 'php-version' ] !== undefined - ? String( parsedWpCliArgs[ 'php-version' ] ) - : undefined; - wpCliArgv = removeArgumentFromArgv( wpCliArgv, 'php-version' ); - wpCliArgv = removeArgumentFromArgv( wpCliArgv, 'avoid-telemetry', false ); - - await runCommand( argv.path, wpCliArgv, { phpVersion } ); + // `--path` is Studio's global option, already parsed into `argv.path`. + const { args, phpVersion } = parseStudioWpArguments( process.argv.slice( 3 ) ); + await runCommand( argv.path, args, { phpVersion } ); } catch ( error ) { if ( error instanceof LoggerError ) { logger.reportError( error ); diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index ecddfbc55f..d6d503bd0b 100644 --- a/apps/cli/lib/run-wp-cli-command.ts +++ b/apps/cli/lib/run-wp-cli-command.ts @@ -192,24 +192,19 @@ type DisposableExitCode = Disposable & { exitCode: Promise< number >; }; -// Passing `stdio: 'inherit'` connects the child to the parent's terminal fds for -// piped/interactive stdin, live streaming output and TTY detection (colors), and -// returns only the exit code. -export async function runWpCliCommand( - site: SiteData, - args: string[], - options: RunWpCliCommandOptions & { stdio: 'inherit' } -): Promise< DisposableExitCode >; -export async function runWpCliCommand( - site: SiteData, - args: string[], - options?: RunWpCliCommandOptions -): Promise< DisposableWpCliResponse >; -export async function runWpCliCommand( +export type WpCliCommandLine = { + command: string; + args: string[]; + cwd: string; +}; + +// The PHP process a WP-CLI command runs as, with the site's PHP binary and the +// mu-plugins it needs in place. Anything that runs WP-CLI for a site spawns this. +export async function prepareWpCliCommand( site: SiteData, args: string[], - options: RunWpCliCommandOptions = {} -): Promise< DisposableWpCliResponse | DisposableExitCode > { + options: Pick< RunWpCliCommandOptions, 'phpVersion' | 'requireSqliteCliCommand' > = {} +): Promise< WpCliCommandLine > { const phpVersion = resolveSupportedPhpVersion( options.phpVersion ?? DEFAULT_PHP_VERSION ); await ensurePhpBinaryAvailable( phpVersion ); await writeStudioMuPlugins( site.path, site.isWpAutoUpdating ); @@ -221,23 +216,44 @@ export async function runWpCliCommand( : loadImportedRuntimeStartOptions( site )?.autoPrependFile; // Don't apply open_basedir or disable_functions to the WP-CLI process const defaultArgs = getDefaultPhpArgs( phpVersion, { autoPrependFile } ); - const wpCliArgs = applyWpCliCommandOptions( args, options ); - const child = spawn( - getPhpBinaryPath( phpVersion ), - [ + return { + command: getPhpBinaryPath( phpVersion ), + args: [ ...defaultArgs, ...getWpCliPhpIniArgs(), getWpCliPharPath(), `--path=${ site.path }`, - ...wpCliArgs, + ...applyWpCliCommandOptions( args, options ), ], - { - cwd: site.path, - env: getPhpChildEnv(), - stdio: options.stdio === 'inherit' ? 'inherit' : [ 'ignore', 'pipe', 'pipe' ], - detached: DETACH_FOR_GROUP_KILL, - } - ); + cwd: site.path, + }; +} + +// Passing `stdio: 'inherit'` connects the child to the parent's terminal fds for +// piped/interactive stdin, live streaming output and TTY detection (colors), and +// returns only the exit code. +export async function runWpCliCommand( + site: SiteData, + args: string[], + options: RunWpCliCommandOptions & { stdio: 'inherit' } +): Promise< DisposableExitCode >; +export async function runWpCliCommand( + site: SiteData, + args: string[], + options?: RunWpCliCommandOptions +): Promise< DisposableWpCliResponse >; +export async function runWpCliCommand( + site: SiteData, + args: string[], + options: RunWpCliCommandOptions = {} +): Promise< DisposableWpCliResponse | DisposableExitCode > { + const commandLine = await prepareWpCliCommand( site, args, options ); + const child = spawn( commandLine.command, commandLine.args, { + cwd: commandLine.cwd, + env: getPhpChildEnv(), + stdio: options.stdio === 'inherit' ? 'inherit' : [ 'ignore', 'pipe', 'pipe' ], + detached: DETACH_FOR_GROUP_KILL, + } ); await ensureChildSpawned( child ); const removeReaper = reapPhpTreeOnInterrupt( child ); diff --git a/apps/cli/lib/studio-wp-command.ts b/apps/cli/lib/studio-wp-command.ts new file mode 100644 index 0000000000..6bbfd87281 --- /dev/null +++ b/apps/cli/lib/studio-wp-command.ts @@ -0,0 +1,77 @@ +import path from 'node:path'; +import { __ } from '@wordpress/i18n'; +import yargsParser from 'yargs-parser'; +import { getSiteContainingFolder } from 'cli/lib/cli-config/sites'; +import { prepareWpCliCommand, type WpCliCommandLine } from 'cli/lib/run-wp-cli-command'; +import { validatePhpVersion } from 'cli/lib/utils'; +import { getUnsupportedWpCliOptionMessage } from 'cli/lib/wp-cli-options'; +import { LoggerError } from 'cli/logger'; + +function removeArgumentFromArgv( + argv: string[], + argName: string, + hasValue: boolean = true +): string[] { + argv = argv.slice( 0 ); + + while ( argv.indexOf( `--${ argName }` ) !== -1 ) { + const argIndex = argv.indexOf( `--${ argName }` ); + // Remove 2 elements for --arg value, or 1 element for boolean flags like --no-path + argv.splice( argIndex, hasValue ? 2 : 1 ); + } + + while ( argv.find( ( arg ) => arg.startsWith( `--${ argName }=` ) ) ) { + const argIndex = argv.findIndex( ( arg ) => arg.startsWith( `--${ argName }=` ) ); + argv.splice( argIndex, 1 ); + } + + return argv; +} + +export type StudioWpArguments = { + // The WP-CLI arguments, without the options `studio wp` handles itself. + args: string[]; + // From `--path`: a folder inside the site to run on. + folder?: string; + phpVersion?: string; +}; + +// Splits the arguments of `studio wp ` into WP-CLI's and Studio's own. +export function parseStudioWpArguments( argv: string[] ): StudioWpArguments { + const parsed = yargsParser( argv ); + + if ( parsed._[ 0 ] === 'shell' ) { + throw new LoggerError( + __( + 'Studio CLI does not support the WP-CLI `shell` command. Consider adding your code to a file and using the `eval` command.' + ) + ); + } + + const unsupportedOptionMessage = getUnsupportedWpCliOptionMessage( argv ); + if ( unsupportedOptionMessage ) { + throw new LoggerError( unsupportedOptionMessage ); + } + + let args = removeArgumentFromArgv( argv, 'path' ); + args = removeArgumentFromArgv( args, 'php-version' ); + args = removeArgumentFromArgv( args, 'avoid-telemetry', false ); + return { + args, + folder: parsed.path !== undefined ? String( parsed.path ) : undefined, + phpVersion: + parsed[ 'php-version' ] !== undefined ? String( parsed[ 'php-version' ] ) : undefined, + }; +} + +// The PHP process `studio wp ` runs, run from `cwd`. +export async function prepareStudioWpCommand( + cwd: string, + argv: string[] +): Promise< WpCliCommandLine > { + const { args, folder, phpVersion } = parseStudioWpArguments( argv ); + const site = await getSiteContainingFolder( path.resolve( cwd, folder ?? '.' ) ); + return prepareWpCliCommand( site, args, { + phpVersion: validatePhpVersion( phpVersion ?? site.phpVersion ), + } ); +} diff --git a/apps/cli/lib/tests/studio-wp-command.test.ts b/apps/cli/lib/tests/studio-wp-command.test.ts new file mode 100644 index 0000000000..a6a36a8085 --- /dev/null +++ b/apps/cli/lib/tests/studio-wp-command.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { parseStudioWpArguments } from 'cli/lib/studio-wp-command'; + +describe( 'parseStudioWpArguments', () => { + it( 'takes out the options Studio handles and keeps the rest for WP-CLI', () => { + expect( + parseStudioWpArguments( [ + 'plugin', + 'list', + '--path=wp-content/plugins', + '--php-version', + '8.3', + '--avoid-telemetry', + '--format=json', + ] ) + ).toEqual( { + args: [ 'plugin', 'list', '--format=json' ], + folder: 'wp-content/plugins', + phpVersion: '8.3', + } ); + } ); + + it( 'refuses `wp shell` and options written with a typographic dash', () => { + expect( () => parseStudioWpArguments( [ 'shell' ] ) ).toThrow( /does not support/ ); + expect( () => parseStudioWpArguments( [ 'post', 'list', '–porcelain' ] ) ).toThrow( + /typographic dash/ + ); + } ); +} ); From 91b1c58ba676006e45142c345e02c8ac3b898d52 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 17 Oct 2026 09:11:12 -0400 Subject: [PATCH 5/6] Go back to running studio wp from the agent shell's wp, without a broker The broker saved about a second per call, but its socket and client script are more than the agent needs. Faster CLI startup is tracked in #5169. --- apps/cli/ai/runtimes/pi/index.ts | 19 +-- apps/cli/ai/runtimes/pi/studio-wp-shim.ts | 171 ++----------------- apps/cli/ai/tests/studio-wp-shim.test.ts | 139 +++------------ apps/cli/commands/wp.ts | 51 +++++- apps/cli/lib/run-wp-cli-command.ts | 72 +++----- apps/cli/lib/studio-wp-command.ts | 77 --------- apps/cli/lib/tests/studio-wp-command.test.ts | 29 ---- 7 files changed, 127 insertions(+), 431 deletions(-) delete mode 100644 apps/cli/lib/studio-wp-command.ts delete mode 100644 apps/cli/lib/tests/studio-wp-command.test.ts diff --git a/apps/cli/ai/runtimes/pi/index.ts b/apps/cli/ai/runtimes/pi/index.ts index 745e212ca4..fe552b64db 100644 --- a/apps/cli/ai/runtimes/pi/index.ts +++ b/apps/cli/ai/runtimes/pi/index.ts @@ -26,7 +26,6 @@ import { SettingsManager, type AgentSession, type AgentSessionEvent, - type BashSpawnHook, type SessionManager, type ToolDefinition, } from '@earendil-works/pi-coding-agent'; @@ -54,9 +53,8 @@ import { getPendingWork, type PendingWork } from './pending-work'; import { stripStaleImagesFromContext } from './strip-stale-images'; import { ensureStudioWpShimDirectory, + getStudioWpShimScript, prependToPath, - startStudioWpBroker, - STUDIO_WP_BROKER_ENV, } from './studio-wp-shim'; import { getIncompleteToolCallReason, @@ -613,16 +611,11 @@ 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. -let studioWpSpawnHook: BashSpawnHook | undefined; -function getStudioWpSpawnHook(): BashSpawnHook { - if ( ! studioWpSpawnHook ) { - const cliPath = process.env.STUDIO_CLI_BIN ?? path.join( import.meta.dirname, 'main.mjs' ); - const broker = startStudioWpBroker(); - studioWpSpawnHook = prependToPath( ensureStudioWpShimDirectory( process.execPath, cliPath ), { - [ STUDIO_WP_BROKER_ENV ]: broker.address, - } ); - } - return studioWpSpawnHook; +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( diff --git a/apps/cli/ai/runtimes/pi/studio-wp-shim.ts b/apps/cli/ai/runtimes/pi/studio-wp-shim.ts index 9ffadfa7f8..1f8d71c5e3 100644 --- a/apps/cli/ai/runtimes/pi/studio-wp-shim.ts +++ b/apps/cli/ai/runtimes/pi/studio-wp-shim.ts @@ -1,175 +1,41 @@ -import { createHash, randomUUID } from 'crypto'; +import { createHash } from 'crypto'; import fs from 'fs'; -import net from 'net'; -import os from 'os'; import path from 'path'; import { getConfigDirectory } from '@studio/common/lib/well-known-paths'; -import { withoutOversizedEnvValues } from 'cli/lib/child-env'; -import { prepareStudioWpCommand } from 'cli/lib/studio-wp-command'; import type { BashSpawnHook } from '@earendil-works/pi-coding-agent'; -import type { WpCliCommandLine } from 'cli/lib/run-wp-cli-command'; - -export const STUDIO_WP_BROKER_ENV = 'STUDIO_WP_BROKER'; - -export type StudioWpRequest = { cwd: string; argv: string[]; env: NodeJS.ProcessEnv }; -export type StudioWpReply = ( WpCliCommandLine & { env: NodeJS.ProcessEnv } ) | { error: string }; function shellQuote( value: string ): string { return `'${ value.replace( /'/g, `'\\''` ) }'`; } -// `wp` in the agent's shell is 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. -// -// Starting the Studio CLI for each command costs seconds, so the client asks the broker in -// the agent's own process for the PHP command line and runs PHP itself, inheriting the -// shell's stdio and environment. Without a broker it runs `studio wp`. -export function getStudioWpClientScript( nodePath: string, cliPath: string ): string { - return `import net from 'node:net'; -import { spawn } from 'node:child_process'; - -const argv = process.argv.slice( 2 ); - -function run( command, args, options ) { - const child = spawn( command, args, { stdio: 'inherit', ...options } ); - for ( const signal of [ 'SIGINT', 'SIGTERM', 'SIGHUP' ] ) { - process.on( signal, () => child.kill( signal ) ); - } - child.on( 'error', ( error ) => { - process.stderr.write( error.message + '\\n' ); - process.exit( 1 ); - } ); - child.on( 'exit', ( code, signal ) => process.exit( code ?? ( signal ? 128 : 1 ) ) ); -} - -let ranStudioWp = false; -function runStudioWp() { - if ( ! ranStudioWp ) { - ranStudioWp = true; - run( ${ JSON.stringify( nodePath ) }, [ ${ JSON.stringify( cliPath ) }, 'wp', ...argv ] ); - } -} - -const broker = process.env.${ STUDIO_WP_BROKER_ENV }; -if ( ! broker ) { - runStudioWp(); -} else { - let reply = ''; - const socket = net.connect( broker ); - socket.setEncoding( 'utf8' ); - socket.on( 'error', runStudioWp ); - socket.on( 'data', ( chunk ) => ( reply += chunk ) ); - socket.on( 'end', () => { - const line = JSON.parse( reply ); - if ( line.error ) { - process.stderr.write( line.error + '\\n' ); - process.exit( 1 ); - } - run( line.command, line.args, { cwd: line.cwd, env: line.env } ); - } ); - socket.end( JSON.stringify( { cwd: process.cwd(), argv, env: process.env } ) + '\\n' ); -} -`; -} - -export function getStudioWpShimScript( nodePath: string, clientPath: string ): string { - return `#!/bin/sh\nexec ${ shellQuote( nodePath ) } ${ shellQuote( clientPath ) } "$@"\n`; -} - -function writeAtomically( file: string, content: string, mode: number ) { - if ( ! fs.existsSync( file ) ) { - const temporaryPath = `${ file }.${ process.pid }`; - fs.writeFileSync( temporaryPath, content, { mode } ); - fs.renameSync( temporaryPath, file ); - } +// `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 `wp` and its client into a directory named after their content, so concurrent -// Studio processes (or different installs) never overwrite each other's files. +// 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( - nodePath: string, - cliPath: string, + script: string, root: string = path.join( getConfigDirectory(), 'tmp', 'agent-bin' ) ): string { - const client = getStudioWpClientScript( nodePath, cliPath ); const directory = path.join( root, - createHash( 'sha256' ).update( nodePath ).update( client ).digest( 'hex' ).slice( 0, 12 ) - ); - const clientPath = path.join( directory, 'wp-client.mjs' ); - fs.mkdirSync( directory, { recursive: true } ); - writeAtomically( clientPath, client, 0o644 ); - writeAtomically( - path.join( directory, 'wp' ), - getStudioWpShimScript( nodePath, clientPath ), - 0o755 + createHash( 'sha256' ).update( script ).digest( 'hex' ).slice( 0, 12 ) ); - return directory; -} - -export async function answerStudioWpRequest( - request: StudioWpRequest, - prepare: typeof prepareStudioWpCommand = prepareStudioWpCommand -): Promise< StudioWpReply > { - try { - const commandLine = await prepare( request.cwd, request.argv ); - return { - ...commandLine, - env: withoutOversizedEnvValues( { ...request.env, WP_CLI_ALLOW_ROOT: '1' } ), - }; - } catch ( error ) { - return { error: error instanceof Error ? error.message : String( error ) }; + 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; } -function getBrokerAddress(): { address: string; cleanup: () => void } { - if ( process.platform === 'win32' ) { - return { address: `\\\\.\\pipe\\studio-wp-${ randomUUID() }`, cleanup: () => {} }; - } - // A private directory, since the socket answers anyone who can reach it. - const directory = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-wp-' ) ); - return { - address: path.join( directory, 'broker.sock' ), - cleanup: () => fs.rmSync( directory, { recursive: true, force: true } ), - }; -} - -export function startStudioWpBroker( - prepare: typeof prepareStudioWpCommand = prepareStudioWpCommand -): { address: string; close: () => void } { - const { address, cleanup } = getBrokerAddress(); - const server = net.createServer( { allowHalfOpen: true }, ( socket ) => { - let request = ''; - socket.setEncoding( 'utf8' ); - socket.on( 'error', () => socket.destroy() ); - socket.on( 'data', ( chunk ) => ( request += chunk ) ); - socket.on( 'end', async () => { - let reply: StudioWpReply; - try { - reply = await answerStudioWpRequest( JSON.parse( request ) as StudioWpRequest, prepare ); - } catch ( error ) { - reply = { error: error instanceof Error ? error.message : String( error ) }; - } - socket.end( JSON.stringify( reply ) ); - } ); - } ); - server.on( 'error', () => {} ); - server.listen( address ); - // The agent's process should exit when its work does, broker or not. - server.unref(); - const close = () => { - server.close(); - cleanup(); - }; - process.once( 'exit', close ); - return { address, close }; -} - -export function prependToPath( - directory: string, - extraEnv: NodeJS.ProcessEnv = {} -): BashSpawnHook { +export function prependToPath( directory: string ): BashSpawnHook { return ( context ) => { const pathKey = Object.keys( context.env ).find( ( key ) => key.toUpperCase() === 'PATH' ) ?? 'PATH'; @@ -178,7 +44,6 @@ export function prependToPath( ...context, env: { ...context.env, - ...extraEnv, [ pathKey ]: current ? `${ directory }${ path.delimiter }${ current }` : directory, }, }; diff --git a/apps/cli/ai/tests/studio-wp-shim.test.ts b/apps/cli/ai/tests/studio-wp-shim.test.ts index 2fa8327c1b..aecc7291d8 100644 --- a/apps/cli/ai/tests/studio-wp-shim.test.ts +++ b/apps/cli/ai/tests/studio-wp-shim.test.ts @@ -1,142 +1,59 @@ -import { spawn } from 'child_process'; +import { execFileSync } 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, - startStudioWpBroker, - STUDIO_WP_BROKER_ENV, } from 'cli/ai/runtimes/pi/studio-wp-shim'; -import { LoggerError } from 'cli/logger'; -// Runs `command` the way the Bash tool does, and collects what it prints. -function runInShell( - command: string, - env: NodeJS.ProcessEnv, - input = '' -): Promise< { stdout: string; stderr: string; code: number | null } > { - return new Promise( ( resolve, reject ) => { - const child = spawn( '/bin/sh', [ '-c', command ], { env } ); - let stdout = ''; - let stderr = ''; - child.stdout.on( 'data', ( chunk ) => ( stdout += chunk ) ); - child.stderr.on( 'data', ( chunk ) => ( stderr += chunk ) ); - child.on( 'error', reject ); - child.on( 'exit', ( code ) => resolve( { stdout, stderr, code } ) ); - child.stdin.end( input ); - } ); -} - -describe.skipIf( process.platform === 'win32' )( 'wp in the agent shell', () => { +describe.skipIf( process.platform === 'win32' )( 'studio wp shim', () => { let root: string; - let directory: string; - // Stands in for PHP running WP-CLI: reports what it was started with, then exits 3. - let fakePhp: string; - const closers: Array< () => void > = []; beforeEach( () => { root = fs.mkdtempSync( path.join( os.tmpdir(), "studio-wp-shim-it's-" ) ); - // Stands in for the Studio 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 ) ) );' ); - directory = ensureStudioWpShimDirectory( process.execPath, cliPath, path.join( root, 'bin' ) ); - fakePhp = path.join( root, 'php.mjs' ); - fs.writeFileSync( - fakePhp, - `let stdin = ''; -process.stdin.on( 'data', ( chunk ) => ( stdin += chunk ) ); -process.stdin.on( 'end', () => { - console.log( JSON.stringify( { - args: process.argv.slice( 2 ), - cwd: process.cwd(), - stdin, - taxonomist: process.env.TAXONOMIST_MODE, - allowRoot: process.env.WP_CLI_ALLOW_ROOT, - } ) ); - process.exit( 3 ); -} );` - ); } ); afterEach( () => { - closers.splice( 0 ).forEach( ( close ) => close() ); fs.rmSync( root, { recursive: true, force: true } ); } ); - function shellEnv( extra: NodeJS.ProcessEnv = {} ) { - return prependToPath( - directory, - extra - )( { + 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' }, - } ).env; - } - - it( 'runs the command line the broker prepares, with the shell stdin, env and exit code', async () => { - const broker = startStudioWpBroker( async ( cwd, argv ) => ( { - command: process.execPath, - args: [ fakePhp, `--path=${ cwd }`, ...argv ], - cwd: os.tmpdir(), - } ) ); - closers.push( broker.close ); - - const result = await runInShell( - `cd '${ root.replace( - /'/g, - `'\\''` - ) }' && TAXONOMIST_MODE=apply wp post create --post_title="Ember & Oak" -`, - shellEnv( { [ STUDIO_WP_BROKER_ENV ]: broker.address } ), - '

Hi

' - ); - - expect( result.code ).toBe( 3 ); - expect( JSON.parse( result.stdout ) ).toEqual( { - args: [ - `--path=${ fs.realpathSync( root ) }`, - 'post', - 'create', - '--post_title=Ember & Oak', - '-', - ], - cwd: fs.realpathSync( os.tmpdir() ), - stdin: '

Hi

', - taxonomist: 'apply', - allowRoot: '1', } ); - } ); - it( 'reports why the broker could not prepare the command', async () => { - const broker = startStudioWpBroker( async () => { - throw new LoggerError( 'Could not find Studio site.' ); - } ); - closers.push( broker.close ); - - const result = await runInShell( - 'wp option get siteurl', - shellEnv( { [ STUDIO_WP_BROKER_ENV ]: broker.address } ) + const output = execFileSync( + '/bin/sh', + [ '-c', `wp post create --post_title="Ember & Oak" page.html` ], + { env, encoding: 'utf8' } ); - expect( result ).toEqual( { stdout: '', stderr: 'Could not find Studio site.\n', code: 1 } ); + expect( JSON.parse( output ) ).toEqual( [ + 'wp', + 'post', + 'create', + '--post_title=Ember & Oak', + 'page.html', + ] ); } ); - it( 'runs `studio wp` when no broker answers', async () => { - const result = await runInShell( - 'wp option get siteurl', - shellEnv( { [ STUDIO_WP_BROKER_ENV ]: path.join( root, 'missing.sock' ) } ) + 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 ) ); - - expect( JSON.parse( result.stdout ) ).toEqual( [ 'wp', 'option', 'get', 'siteurl' ] ); - expect( result.code ).toBe( 0 ); - } ); - - it( 'reuses the shim directory for the same Node and CLI', () => { - const bin = path.join( root, 'bin' ); - expect( - ensureStudioWpShimDirectory( process.execPath, path.join( root, 'main.mjs' ), bin ) - ).toBe( directory ); } ); } ); diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index d51715f86e..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 { getSiteContainingFolder } from 'cli/lib/cli-config/sites'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; -import { parseStudioWpArguments } from 'cli/lib/studio-wp-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'; @@ -23,11 +24,53 @@ export async function runCommand( process.exitCode = await command.exitCode; } +function removeArgumentFromArgv( + argv: string[], + argName: string, + hasValue: boolean = true +): string[] { + argv = argv.slice( 0 ); + + while ( argv.indexOf( `--${ argName }` ) !== -1 ) { + const argIndex = argv.indexOf( `--${ argName }` ); + // Remove 2 elements for --arg value, or 1 element for boolean flags like --no-path + argv.splice( argIndex, hasValue ? 2 : 1 ); + } + + while ( argv.find( ( arg ) => arg.startsWith( `--${ argName }=` ) ) ) { + const argIndex = argv.findIndex( ( arg ) => arg.startsWith( `--${ argName }=` ) ); + argv.splice( argIndex, 1 ); + } + + return argv; +} + export async function commandHandler( argv: ArgumentsCamelCase< GlobalOptions > ) { try { - // `--path` is Studio's global option, already parsed into `argv.path`. - const { args, phpVersion } = parseStudioWpArguments( process.argv.slice( 3 ) ); - await runCommand( argv.path, args, { phpVersion } ); + let wpCliArgv = removeArgumentFromArgv( process.argv.slice( 3 ), 'path' ); + const parsedWpCliArgs = yargsParser( wpCliArgv ); + + if ( parsedWpCliArgs._[ 0 ] === 'shell' ) { + throw new LoggerError( + __( + 'Studio CLI does not support the WP-CLI `shell` command. Consider adding your code to a file and using the `eval` command.' + ) + ); + } + + const unsupportedOptionMessage = getUnsupportedWpCliOptionMessage( wpCliArgv ); + if ( unsupportedOptionMessage ) { + throw new LoggerError( unsupportedOptionMessage ); + } + + const phpVersion = + parsedWpCliArgs[ 'php-version' ] !== undefined + ? String( parsedWpCliArgs[ 'php-version' ] ) + : undefined; + wpCliArgv = removeArgumentFromArgv( wpCliArgv, 'php-version' ); + wpCliArgv = removeArgumentFromArgv( wpCliArgv, 'avoid-telemetry', false ); + + await runCommand( argv.path, wpCliArgv, { phpVersion } ); } catch ( error ) { if ( error instanceof LoggerError ) { logger.reportError( error ); diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index d6d503bd0b..ecddfbc55f 100644 --- a/apps/cli/lib/run-wp-cli-command.ts +++ b/apps/cli/lib/run-wp-cli-command.ts @@ -192,43 +192,6 @@ type DisposableExitCode = Disposable & { exitCode: Promise< number >; }; -export type WpCliCommandLine = { - command: string; - args: string[]; - cwd: string; -}; - -// The PHP process a WP-CLI command runs as, with the site's PHP binary and the -// mu-plugins it needs in place. Anything that runs WP-CLI for a site spawns this. -export async function prepareWpCliCommand( - site: SiteData, - args: string[], - options: Pick< RunWpCliCommandOptions, 'phpVersion' | 'requireSqliteCliCommand' > = {} -): Promise< WpCliCommandLine > { - const phpVersion = resolveSupportedPhpVersion( options.phpVersion ?? DEFAULT_PHP_VERSION ); - await ensurePhpBinaryAvailable( phpVersion ); - await writeStudioMuPlugins( site.path, site.isWpAutoUpdating ); - - // Reprint-pulled sites wire SQLite through runtime.php (loaded as auto_prepend_file), - // so load it here too. No-op for normal sites (helper returns undefined). - const autoPrependFile = options.requireSqliteCliCommand - ? undefined - : loadImportedRuntimeStartOptions( site )?.autoPrependFile; - // Don't apply open_basedir or disable_functions to the WP-CLI process - const defaultArgs = getDefaultPhpArgs( phpVersion, { autoPrependFile } ); - return { - command: getPhpBinaryPath( phpVersion ), - args: [ - ...defaultArgs, - ...getWpCliPhpIniArgs(), - getWpCliPharPath(), - `--path=${ site.path }`, - ...applyWpCliCommandOptions( args, options ), - ], - cwd: site.path, - }; -} - // Passing `stdio: 'inherit'` connects the child to the parent's terminal fds for // piped/interactive stdin, live streaming output and TTY detection (colors), and // returns only the exit code. @@ -247,13 +210,34 @@ export async function runWpCliCommand( args: string[], options: RunWpCliCommandOptions = {} ): Promise< DisposableWpCliResponse | DisposableExitCode > { - const commandLine = await prepareWpCliCommand( site, args, options ); - const child = spawn( commandLine.command, commandLine.args, { - cwd: commandLine.cwd, - env: getPhpChildEnv(), - stdio: options.stdio === 'inherit' ? 'inherit' : [ 'ignore', 'pipe', 'pipe' ], - detached: DETACH_FOR_GROUP_KILL, - } ); + const phpVersion = resolveSupportedPhpVersion( options.phpVersion ?? DEFAULT_PHP_VERSION ); + await ensurePhpBinaryAvailable( phpVersion ); + await writeStudioMuPlugins( site.path, site.isWpAutoUpdating ); + + // Reprint-pulled sites wire SQLite through runtime.php (loaded as auto_prepend_file), + // so load it here too. No-op for normal sites (helper returns undefined). + const autoPrependFile = options.requireSqliteCliCommand + ? undefined + : loadImportedRuntimeStartOptions( site )?.autoPrependFile; + // Don't apply open_basedir or disable_functions to the WP-CLI process + const defaultArgs = getDefaultPhpArgs( phpVersion, { autoPrependFile } ); + const wpCliArgs = applyWpCliCommandOptions( args, options ); + const child = spawn( + getPhpBinaryPath( phpVersion ), + [ + ...defaultArgs, + ...getWpCliPhpIniArgs(), + getWpCliPharPath(), + `--path=${ site.path }`, + ...wpCliArgs, + ], + { + cwd: site.path, + env: getPhpChildEnv(), + stdio: options.stdio === 'inherit' ? 'inherit' : [ 'ignore', 'pipe', 'pipe' ], + detached: DETACH_FOR_GROUP_KILL, + } + ); await ensureChildSpawned( child ); const removeReaper = reapPhpTreeOnInterrupt( child ); diff --git a/apps/cli/lib/studio-wp-command.ts b/apps/cli/lib/studio-wp-command.ts deleted file mode 100644 index 6bbfd87281..0000000000 --- a/apps/cli/lib/studio-wp-command.ts +++ /dev/null @@ -1,77 +0,0 @@ -import path from 'node:path'; -import { __ } from '@wordpress/i18n'; -import yargsParser from 'yargs-parser'; -import { getSiteContainingFolder } from 'cli/lib/cli-config/sites'; -import { prepareWpCliCommand, type WpCliCommandLine } from 'cli/lib/run-wp-cli-command'; -import { validatePhpVersion } from 'cli/lib/utils'; -import { getUnsupportedWpCliOptionMessage } from 'cli/lib/wp-cli-options'; -import { LoggerError } from 'cli/logger'; - -function removeArgumentFromArgv( - argv: string[], - argName: string, - hasValue: boolean = true -): string[] { - argv = argv.slice( 0 ); - - while ( argv.indexOf( `--${ argName }` ) !== -1 ) { - const argIndex = argv.indexOf( `--${ argName }` ); - // Remove 2 elements for --arg value, or 1 element for boolean flags like --no-path - argv.splice( argIndex, hasValue ? 2 : 1 ); - } - - while ( argv.find( ( arg ) => arg.startsWith( `--${ argName }=` ) ) ) { - const argIndex = argv.findIndex( ( arg ) => arg.startsWith( `--${ argName }=` ) ); - argv.splice( argIndex, 1 ); - } - - return argv; -} - -export type StudioWpArguments = { - // The WP-CLI arguments, without the options `studio wp` handles itself. - args: string[]; - // From `--path`: a folder inside the site to run on. - folder?: string; - phpVersion?: string; -}; - -// Splits the arguments of `studio wp ` into WP-CLI's and Studio's own. -export function parseStudioWpArguments( argv: string[] ): StudioWpArguments { - const parsed = yargsParser( argv ); - - if ( parsed._[ 0 ] === 'shell' ) { - throw new LoggerError( - __( - 'Studio CLI does not support the WP-CLI `shell` command. Consider adding your code to a file and using the `eval` command.' - ) - ); - } - - const unsupportedOptionMessage = getUnsupportedWpCliOptionMessage( argv ); - if ( unsupportedOptionMessage ) { - throw new LoggerError( unsupportedOptionMessage ); - } - - let args = removeArgumentFromArgv( argv, 'path' ); - args = removeArgumentFromArgv( args, 'php-version' ); - args = removeArgumentFromArgv( args, 'avoid-telemetry', false ); - return { - args, - folder: parsed.path !== undefined ? String( parsed.path ) : undefined, - phpVersion: - parsed[ 'php-version' ] !== undefined ? String( parsed[ 'php-version' ] ) : undefined, - }; -} - -// The PHP process `studio wp ` runs, run from `cwd`. -export async function prepareStudioWpCommand( - cwd: string, - argv: string[] -): Promise< WpCliCommandLine > { - const { args, folder, phpVersion } = parseStudioWpArguments( argv ); - const site = await getSiteContainingFolder( path.resolve( cwd, folder ?? '.' ) ); - return prepareWpCliCommand( site, args, { - phpVersion: validatePhpVersion( phpVersion ?? site.phpVersion ), - } ); -} diff --git a/apps/cli/lib/tests/studio-wp-command.test.ts b/apps/cli/lib/tests/studio-wp-command.test.ts deleted file mode 100644 index a6a36a8085..0000000000 --- a/apps/cli/lib/tests/studio-wp-command.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { parseStudioWpArguments } from 'cli/lib/studio-wp-command'; - -describe( 'parseStudioWpArguments', () => { - it( 'takes out the options Studio handles and keeps the rest for WP-CLI', () => { - expect( - parseStudioWpArguments( [ - 'plugin', - 'list', - '--path=wp-content/plugins', - '--php-version', - '8.3', - '--avoid-telemetry', - '--format=json', - ] ) - ).toEqual( { - args: [ 'plugin', 'list', '--format=json' ], - folder: 'wp-content/plugins', - phpVersion: '8.3', - } ); - } ); - - it( 'refuses `wp shell` and options written with a typographic dash', () => { - expect( () => parseStudioWpArguments( [ 'shell' ] ) ).toThrow( /does not support/ ); - expect( () => parseStudioWpArguments( [ 'post', 'list', '–porcelain' ] ) ).toThrow( - /typographic dash/ - ); - } ); -} ); From acdab1482359a46ef7aa9ee2b09e9e63b180fe38 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 17 Oct 2026 09:16:51 -0400 Subject: [PATCH 6/6] Test that wp in the agent shell passes stdin, environment and exit code through --- apps/cli/ai/tests/studio-wp-shim.test.ts | 33 +++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/cli/ai/tests/studio-wp-shim.test.ts b/apps/cli/ai/tests/studio-wp-shim.test.ts index aecc7291d8..2a010efb93 100644 --- a/apps/cli/ai/tests/studio-wp-shim.test.ts +++ b/apps/cli/ai/tests/studio-wp-shim.test.ts @@ -1,4 +1,4 @@ -import { execFileSync } from 'child_process'; +import { execFileSync, spawnSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -49,6 +49,37 @@ describe.skipIf( process.platform === 'win32' )( 'studio wp shim', () => { ] ); } ); + 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' );